diff --git a/.formatter.exs b/.formatter.exs index abd91dbbe..a96afe758 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,3 +1,14 @@ [ - inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}", "priv/repo/migrations/*.exs", "priv/repo/optional_migrations/**/*.exs", "priv/scrubbers/*.ex"] + import_deps: [:ecto, :ecto_sql, :phoenix], + subdirectories: ["priv/*/migrations"], + plugins: [Phoenix.LiveView.HTMLFormatter], + inputs: [ + "mix.exs", + "*.{heex,ex,exs}", + "{config,lib,test}/**/*.{heex,ex,exs}", + "priv/*/seeds.exs", + "priv/repo/migrations/*.exs", + "priv/repo/optional_migrations/**/*.exs", + "priv/scrubbers/*.ex" + ] ] diff --git a/.gitattributes b/.gitattributes index eb0c94757..febafe62f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,10 +1,4 @@ *.ex diff=elixir *.exs diff=elixir -priv/static/instance/static.css diff=css - -# Most of js/css files included in the repo are minified bundles, -# and we don't want to search/diff those as text files. -*.js binary -*.js.map binary -*.css binary +*.css diff=css diff --git a/.gitea/issue_template/bug.yml b/.gitea/issue_template/bug.yml new file mode 100644 index 000000000..d14f429cd --- /dev/null +++ b/.gitea/issue_template/bug.yml @@ -0,0 +1,87 @@ +name: "Bug report" +about: "Something isn't working as expected" +title: "[bug] " +labels: +- bug +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to file this bug report! Please try to be as specific and detailed as you can, so we can track down the issue and fix it as soon as possible. + + # General information + - type: dropdown + id: installation + attributes: + label: "Your setup" + description: "What sort of installation are you using?" + options: + - "OTP" + - "From source" + - "Docker" + validations: + required: true + - type: input + id: setup-details + attributes: + label: "Extra details" + description: "If installing from source or docker, please specify your distro or docker setup." + placeholder: "e.g. Alpine Linux edge" + - type: input + id: version + attributes: + label: "Version" + description: "Which version of Akkoma are you running? If running develop, specify the commit hash." + placeholder: "e.g. 2022.11, 4e4bd248" + - type: input + id: postgres + attributes: + label: "PostgreSQL version" + placeholder: "14" + validations: + required: true + - type: markdown + attributes: + value: "# The issue" + - type: textarea + id: attempt + attributes: + label: "What were you trying to do?" + validations: + required: true + - type: textarea + id: expectation + attributes: + label: "What did you expect to happen?" + validations: + required: true + - type: textarea + id: reality + attributes: + label: "What actually happened?" + validations: + required: true + - type: textarea + id: logs + attributes: + label: "Logs" + description: "Please copy and paste any relevant log output, if applicable." + render: shell + - type: dropdown + id: severity + attributes: + label: "Severity" + description: "Does this issue prevent you from using the software as normal?" + options: + - "I cannot use the software" + - "I cannot use it as easily as I'd like" + - "I can manage" + validations: + required: true + - type: checkboxes + id: searched + attributes: + label: "Have you searched for this issue?" + description: "Please double-check that your issue is not already being tracked on [the forums](https://meta.akkoma.dev) or [the issue tracker](https://akkoma.dev/AkkomaGang/akkoma/issues)." + options: + - label: "I have double-checked and have not found this issue mentioned anywhere." diff --git a/.gitea/issue_template/feat.yml b/.gitea/issue_template/feat.yml new file mode 100644 index 000000000..260f77ab4 --- /dev/null +++ b/.gitea/issue_template/feat.yml @@ -0,0 +1,32 @@ +name: "Feature request" +about: "I'd like something to be added to Akkoma" +title: "[feat] " +labels: +- "feature request" + +body: + - type: markdown + attributes: + value: "Thanks for taking the time to request a new feature! Please be as concise and clear as you can in your proposal, so we could understand what you're going for." + - type: textarea + id: idea + attributes: + label: "The idea" + description: "What do you think you should be able to do in Akkoma?" + validations: + required: true + - type: textarea + id: reason + attributes: + label: "The reasoning" + description: "Why would this be a worthwhile feature? Does it solve any problems? Have people talked about wanting it?" + validations: + required: true + - type: checkboxes + id: searched + attributes: + label: "Have you searched for this feature request?" + description: "Please double-check that your issue is not already being tracked on [the forums](https://meta.akkoma.dev), [the issue tracker](https://akkoma.dev/AkkomaGang/akkoma/issues), or the one for [pleroma-fe](https://akkoma.dev/AkkomaGang/pleroma-fe/issues)." + options: + - label: "I have double-checked and have not found this feature request mentioned anywhere." + - label: "This feature is related to the Akkoma backend specifically, and not pleroma-fe." diff --git a/.gitignore b/.gitignore index f9de4ed49..b7411cb3c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # App artifacts docs/site +*.zip *.sw* secret /_build @@ -72,6 +73,9 @@ pleroma.iml # Generated documentation docs/site +docs/venv # docker stuff docker-db +*.iml +docker-compose.override.yml diff --git a/.woodpecker.yml b/.woodpecker/build-amd64.yml similarity index 50% rename from .woodpecker.yml rename to .woodpecker/build-amd64.yml index c97e3eb4f..938c62665 100644 --- a/.woodpecker.yml +++ b/.woodpecker/build-amd64.yml @@ -1,3 +1,8 @@ +platform: linux/amd64 + +depends_on: + - test + variables: - &scw-secrets - SCW_ACCESS_KEY @@ -12,8 +17,6 @@ variables: branch: - develop - stable - - refs/tags/v* - - refs/tags/stable-* - &on-stable when: event: @@ -21,14 +24,6 @@ variables: - tag branch: - stable - - refs/tags/stable-* - - &on-point-release - when: - event: - - push - branch: - - develop - - stable - &on-pr-open when: event: @@ -39,63 +34,10 @@ variables: - &clean "(rm -rf release || true) && (rm -rf _build || true) && (rm -rf /root/.mix)" - &mix-clean "mix deps.clean --all && mix clean" -services: - postgres: - image: postgres:13 - when: - event: - - pull_request - environment: - POSTGRES_DB: pleroma_test - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - pipeline: - lint: - <<: *on-pr-open - image: akkoma/ci-base:latest - commands: - - mix local.hex --force - - mix local.rebar --force - - mix format --check-formatted - - build: - image: akkoma/ci-base:latest - <<: *on-pr-open - environment: - MIX_ENV: test - POSTGRES_DB: pleroma_test - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - DB_HOST: postgres - commands: - - mix local.hex --force - - mix local.rebar --force - - mix deps.get - - mix compile - - test: - image: akkoma/ci-base:latest - <<: *on-pr-open - environment: - MIX_ENV: test - POSTGRES_DB: pleroma_test - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - DB_HOST: postgres - commands: - - mix local.hex --force - - mix local.rebar --force - - mix deps.get - - mix compile - - mix ecto.drop -f -q - - mix ecto.create - - mix ecto.migrate - - mix test --preload-modules --exclude erratic --exclude federated --max-cases 4 - # Canonical amd64 - ubuntu22: - image: hexpm/elixir:1.13.4-erlang-24.3.4.5-ubuntu-jammy-20220428 + debian-bookworm: + image: hexpm/elixir:1.15.4-erlang-26.0.2-debian-bookworm-20230612 <<: *on-release environment: MIX_ENV: prod @@ -108,50 +50,50 @@ pipeline: - *tag-build - mix deps.get --only prod - mix release --path release - - zip akkoma-ubuntu-jammy.zip -r release - - release-ubuntu22: - image: akkoma/releaser - <<: *on-release - secrets: *scw-secrets - commands: - - export SOURCE=akkoma-ubuntu-jammy.zip - - export DEST=scaleway:akkoma-updates/$${CI_COMMIT_TAG:-"$CI_COMMIT_BRANCH"}/akkoma-ubuntu-jammy.zip - - /bin/sh /entrypoint.sh - - export DEST=scaleway:akkoma-updates/$${CI_COMMIT_TAG:-"$CI_COMMIT_BRANCH"}/akkoma-amd64-ubuntu-jammy.zip - - /bin/sh /entrypoint.sh - - debian-bullseye: - image: hexpm/elixir:1.13.4-erlang-24.3.4.5-debian-bullseye-20220801 - <<: *on-release - environment: - MIX_ENV: prod - DEBIAN_FRONTEND: noninteractive - commands: - - apt-get update && apt-get install -y cmake libmagic-dev rclone zip imagemagick libmagic-dev git build-essential gcc make g++ wget - - *clean - - echo "import Config" > config/prod.secret.exs - - *setup-hex - - *tag-build - - *mix-clean - - mix deps.get --only prod - - mix release --path release - zip akkoma-amd64.zip -r release - release-debian: + release-debian-bookworm: image: akkoma/releaser <<: *on-release secrets: *scw-secrets commands: - export SOURCE=akkoma-amd64.zip + # AMD64 - export DEST=scaleway:akkoma-updates/$${CI_COMMIT_TAG:-"$CI_COMMIT_BRANCH"}/akkoma-amd64.zip - /bin/sh /entrypoint.sh - - export DEST=scaleway:akkoma-updates/$${CI_COMMIT_TAG:-"$CI_COMMIT_BRANCH"}/akkoma-debian-stable.zip + # Ubuntu jammy (currently compatible) + - export DEST=scaleway:akkoma-updates/$${CI_COMMIT_TAG:-"$CI_COMMIT_BRANCH"}/akkoma-amd64-ubuntu-jammy.zip + - /bin/sh /entrypoint.sh + + debian-bullseye: + image: hexpm/elixir:1.15.4-erlang-26.0.2-debian-bullseye-20230612 + <<: *on-release + environment: + MIX_ENV: prod + DEBIAN_FRONTEND: noninteractive + commands: + - apt-get update && apt-get install -y cmake libmagic-dev rclone zip imagemagick libmagic-dev git build-essential g++ wget + - *clean + - echo "import Config" > config/prod.secret.exs + - *setup-hex + - *tag-build + - mix deps.get --only prod + - mix release --path release + - zip akkoma-amd64-debian-bullseye.zip -r release + + release-debian-bullseye: + image: akkoma/releaser + <<: *on-release + secrets: *scw-secrets + commands: + - export SOURCE=akkoma-amd64-debian-bullseye.zip + # AMD64 + - export DEST=scaleway:akkoma-updates/$${CI_COMMIT_TAG:-"$CI_COMMIT_BRANCH"}/akkoma-amd64-debian-bullseye.zip - /bin/sh /entrypoint.sh # Canonical amd64-musl musl: - image: hexpm/elixir:1.13.4-erlang-24.3.4.5-alpine-3.15.6 + image: hexpm/elixir:1.15.4-erlang-26.0.2-alpine-3.18.2 <<: *on-stable environment: MIX_ENV: prod @@ -173,25 +115,3 @@ pipeline: - export SOURCE=akkoma-amd64-musl.zip - export DEST=scaleway:akkoma-updates/$${CI_COMMIT_TAG:-"$CI_COMMIT_BRANCH"}/akkoma-amd64-musl.zip - /bin/sh /entrypoint.sh - - docs: - <<: *on-point-release - secrets: - - SCW_ACCESS_KEY - - SCW_SECRET_KEY - - SCW_DEFAULT_ORGANIZATION_ID - environment: - CI: "true" - image: python:3.10-slim - commands: - - apt-get update && apt-get install -y rclone wget git zip - - wget https://github.com/scaleway/scaleway-cli/releases/download/v2.5.1/scaleway-cli_2.5.1_linux_amd64 - - mv scaleway-cli_2.5.1_linux_amd64 scaleway-cli - - chmod +x scaleway-cli - - ./scaleway-cli object config install type=rclone - - cd docs - - pip install -r requirements.txt - - mkdocs build - - zip -r docs.zip site/* - - cd site - - rclone copy . scaleway:akkoma-docs/$CI_COMMIT_BRANCH/ diff --git a/.woodpecker/build-arm64.yml b/.woodpecker/build-arm64.yml new file mode 100644 index 000000000..1ce1fac44 --- /dev/null +++ b/.woodpecker/build-arm64.yml @@ -0,0 +1,89 @@ +platform: linux/arm64 + +depends_on: + - test + +variables: + - &scw-secrets + - SCW_ACCESS_KEY + - SCW_SECRET_KEY + - SCW_DEFAULT_ORGANIZATION_ID + - &setup-hex "mix local.hex --force && mix local.rebar --force" + - &on-release + when: + event: + - push + - tag + branch: + - stable + - develop + - &on-stable + when: + event: + - push + - tag + branch: + - stable + - &on-pr-open + when: + event: + - pull_request + + - &tag-build "export BUILD_TAG=$${CI_COMMIT_TAG:-\"$CI_COMMIT_BRANCH\"} && export PLEROMA_BUILD_BRANCH=$BUILD_TAG" + + - &clean "(rm -rf release || true) && (rm -rf _build || true) && (rm -rf /root/.mix)" + - &mix-clean "mix deps.clean --all && mix clean" + +pipeline: + # Canonical arm64 + debian-bookworm: + image: hexpm/elixir:1.15.4-erlang-26.0.2-debian-bookworm-20230612 + <<: *on-release + environment: + MIX_ENV: prod + DEBIAN_FRONTEND: noninteractive + commands: + - apt-get update && apt-get install -y cmake libmagic-dev rclone zip imagemagick libmagic-dev git build-essential g++ wget + - *clean + - echo "import Config" > config/prod.secret.exs + - *setup-hex + - *tag-build + - mix deps.get --only prod + - mix release --path release + - zip akkoma-arm64.zip -r release + + release-debian-bookworm: + image: akkoma/releaser:arm64 + <<: *on-release + secrets: *scw-secrets + commands: + - export SOURCE=akkoma-arm64.zip + - export DEST=scaleway:akkoma-updates/$${CI_COMMIT_TAG:-"$CI_COMMIT_BRANCH"}/akkoma-arm64-ubuntu-jammy.zip + - /bin/sh /entrypoint.sh + - export DEST=scaleway:akkoma-updates/$${CI_COMMIT_TAG:-"$CI_COMMIT_BRANCH"}/akkoma-arm64.zip + - /bin/sh /entrypoint.sh + + # Canonical arm64-musl + musl: + image: hexpm/elixir:1.15.4-erlang-26.0.2-alpine-3.18.2 + <<: *on-stable + environment: + MIX_ENV: prod + commands: + - apk add git gcc g++ musl-dev make cmake file-dev rclone wget zip imagemagick + - *clean + - *setup-hex + - *mix-clean + - *tag-build + - mix deps.get --only prod + - mix release --path release + - zip akkoma-arm64-musl.zip -r release + + release-musl: + image: akkoma/releaser:arm64 + <<: *on-stable + secrets: *scw-secrets + commands: + - export SOURCE=akkoma-arm64-musl.zip + - export DEST=scaleway:akkoma-updates/$${CI_COMMIT_TAG:-"$CI_COMMIT_BRANCH"}/akkoma-arm64-musl.zip + - /bin/sh /entrypoint.sh diff --git a/.woodpecker/docs.yml b/.woodpecker/docs.yml new file mode 100644 index 000000000..cc4017659 --- /dev/null +++ b/.woodpecker/docs.yml @@ -0,0 +1,69 @@ +platform: linux/amd64 + +depends_on: + - test + - build-amd64 + +variables: + - &scw-secrets + - SCW_ACCESS_KEY + - SCW_SECRET_KEY + - SCW_DEFAULT_ORGANIZATION_ID + - &setup-hex "mix local.hex --force && mix local.rebar --force" + - &on-release + when: + event: + - push + - tag + branch: + - develop + - stable + - refs/tags/v* + - refs/tags/stable-* + - &on-stable + when: + event: + - push + - tag + branch: + - stable + - refs/tags/stable-* + - &on-point-release + when: + event: + - push + branch: + - develop + - stable + - &on-pr-open + when: + event: + - pull_request + + - &tag-build "export BUILD_TAG=$${CI_COMMIT_TAG:-\"$CI_COMMIT_BRANCH\"} && export PLEROMA_BUILD_BRANCH=$BUILD_TAG" + + - &clean "(rm -rf release || true) && (rm -rf _build || true) && (rm -rf /root/.mix)" + - &mix-clean "mix deps.clean --all && mix clean" + +pipeline: + docs: + <<: *on-point-release + secrets: + - SCW_ACCESS_KEY + - SCW_SECRET_KEY + - SCW_DEFAULT_ORGANIZATION_ID + environment: + CI: "true" + image: python:3.10-slim + commands: + - apt-get update && apt-get install -y rclone wget git zip + - wget https://github.com/scaleway/scaleway-cli/releases/download/v2.5.1/scaleway-cli_2.5.1_linux_amd64 + - mv scaleway-cli_2.5.1_linux_amd64 scaleway-cli + - chmod +x scaleway-cli + - ./scaleway-cli object config install type=rclone + - cd docs + - pip install -r requirements.txt + - mkdocs build + - zip -r docs.zip site/* + - cd site + - rclone copy . scaleway:akkoma-docs/$CI_COMMIT_BRANCH/ diff --git a/.woodpecker/lint.yml b/.woodpecker/lint.yml new file mode 100644 index 000000000..8308e57d7 --- /dev/null +++ b/.woodpecker/lint.yml @@ -0,0 +1,55 @@ +platform: linux/amd64 + +variables: + - &scw-secrets + - SCW_ACCESS_KEY + - SCW_SECRET_KEY + - SCW_DEFAULT_ORGANIZATION_ID + - &setup-hex "mix local.hex --force && mix local.rebar --force" + - &on-release + when: + event: + - push + - tag + branch: + - develop + - stable + - refs/tags/v* + - refs/tags/stable-* + - &on-stable + when: + event: + - push + - tag + branch: + - stable + - refs/tags/stable-* + - &on-point-release + when: + event: + - push + branch: + - develop + - stable + - &on-pr-open + when: + event: + - pull_request + + - &tag-build "export BUILD_TAG=$${CI_COMMIT_TAG:-\"$CI_COMMIT_BRANCH\"} && export PLEROMA_BUILD_BRANCH=$BUILD_TAG" + + - &clean "(rm -rf release || true) && (rm -rf _build || true) && (rm -rf /root/.mix)" + - &mix-clean "mix deps.clean --all && mix clean" + +pipeline: + lint: + image: akkoma/ci-base:1.15-otp26 + <<: *on-pr-open + environment: + MIX_ENV: test + commands: + - mix local.hex --force + - mix local.rebar --force + - mix deps.get + - mix compile + - mix format --check-formatted diff --git a/.woodpecker/test.yml b/.woodpecker/test.yml new file mode 100644 index 000000000..c8b819ce8 --- /dev/null +++ b/.woodpecker/test.yml @@ -0,0 +1,96 @@ +platform: linux/amd64 + +depends_on: + - lint + +matrix: + ELIXIR_VERSION: + - 1.14 + - 1.15 + - 1.16 + OTP_VERSION: + - 25 + - 26 + include: + - ELIXIR_VERSION: 1.14 + OTP_VERSION: 25 + - ELIXIR_VERSION: 1.15 + OTP_VERSION: 25 + - ELIXIR_VERSION: 1.15 + OTP_VERSION: 26 + - ELIXIR_VERSION: 1.16 + OTP_VERSION: 26 + +variables: + - &scw-secrets + - SCW_ACCESS_KEY + - SCW_SECRET_KEY + - SCW_DEFAULT_ORGANIZATION_ID + - &setup-hex "mix local.hex --force && mix local.rebar --force" + - &on-release + when: + event: + - push + - tag + branch: + - develop + - stable + - refs/tags/v* + - refs/tags/stable-* + - &on-stable + when: + event: + - push + - tag + branch: + - stable + - refs/tags/stable-* + - &on-point-release + when: + event: + - push + branch: + - develop + - stable + - &on-pr-open + when: + event: + - pull_request + + - &tag-build "export BUILD_TAG=$${CI_COMMIT_TAG:-\"$CI_COMMIT_BRANCH\"} && export PLEROMA_BUILD_BRANCH=$BUILD_TAG" + + - &clean "(rm -rf release || true) && (rm -rf _build || true) && (rm -rf /root/.mix)" + - &mix-clean "mix deps.clean --all && mix clean" + +services: + postgres: + image: postgres:15 + when: + event: + - pull_request + environment: + POSTGRES_DB: pleroma_test_${ELIXIR_VERSION}_${OTP_VERSION} + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + +pipeline: + test: + image: akkoma/ci-base:${ELIXIR_VERSION}-otp${OTP_VERSION} + <<: *on-pr-open + environment: + MIX_ENV: test + POSTGRES_DB: pleroma_test_${ELIXIR_VERSION}_${OTP_VERSION} + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + DB_HOST: postgres + commands: + - mix local.hex --force + - mix local.rebar --force + - mix deps.get + - mix compile + - mix ecto.drop -f -q + - mix ecto.create + - mix ecto.migrate + - mkdir -p test/tmp + - mix test --preload-modules --exclude erratic --exclude federated --exclude mocked + - mix test --preload-modules --only mocked diff --git a/CHANGELOG.md b/CHANGELOG.md index 65980c28c..8a226aa71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,14 +7,262 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## Unreleased ## Added -- Officially supported docker release -- Ability to remove followers unilaterally without a block +- Support for [FEP-fffd](https://codeberg.org/fediverse/fep/src/branch/main/fep/fffd/fep-fffd.md) (proxy objects) +- Verified support for elixir 1.16 - Uploadfilter `Pleroma.Upload.Filter.Exiftool.ReadDescription` returns description values to the FE so they can pre fill the image description field -## Changes +## Changed +- Uploadfilter `Pleroma.Upload.Filter.Exiftool` has been renamed to `Pleroma.Upload.Filter.Exiftool.StripLocation` + +## Fixed +- Issue preventing fetching anything from IPv6-only instances +- Issue allowing post content to leak via opengraph tags despite :estrict\_unauthenticated being set + +## 2024.03 + +## Added +- CLI tasks best-effort checking for past abuse of the recent spoofing exploit +- new `:mrf_steal_emoji, :download_unknown_size` option; defaults to `false` + +## Changed +- `Pleroma.Upload, :base_url` now MUST be configured explicitly if used; + use of the same domain as the instance is **strongly** discouraged +- `:media_proxy, :base_url` now MUST be configured explicitly if used; + use of the same domain as the instance is **strongly** discouraged +- StealEmoji: + - now uses the pack.json format; + existing users must migrate with an out-of-band script (check release notes) + - only steals shortcodes recognised as valid + - URLs of stolen emoji is no longer predictable +- The `Dedupe` upload filter is now always active; + `AnonymizeFilenames` is again opt-in +- received AP data is sanity checked before we attempt to parse it as a user +- Uploads, emoji and media proxy now restrict Content-Type headers to a safe subset +- Akkoma will no longer fetch and parse objects hosted on the same domain + +## Fixed +- Critical security issue allowing Akkoma to be used as a vector for + (depending on configuration) impersonation of other users or creation + of bogus users and posts on the upload domain +- Critical security issue letting Akkoma fall for the above impersonation + payloads due to lack of strict id checking +- Critical security issue allowing domains redirect to to pose as the initial domain + (e.g. with media proxy's fallback redirects) +- refetched objects can no longer attribute themselves to third-party actors + (this had no externally visible effect since actor info is read from the Create activity) +- our litepub JSON-LD schema is now served with the correct content type +- remote APNG attachments are now recognised as images + +## Upgrade Notes + +- As mentioned in "Changed", `Pleroma.Upload, :base_url` **MUST** be configured. Uploads will fail without it. + - Akkoma will refuse to start if this is not set. +- Same with media proxy. + +## 2024.02 + +## Added +- Full compatibility with Erlang OTP26 +- handling of GET /api/v1/preferences +- Akkoma API is now documented +- ability to auto-approve follow requests from users you are already following +- The SimplePolicy MRF can now strip user backgrounds from selected remote hosts + +## Changed +- OTP builds are now built on erlang OTP26 +- The base Phoenix framework is now updated to 1.7 +- An `outbox` field has been added to actor profiles to comply with AP spec +- User profile backgrounds do now federate with other Akkoma instances and Sharkey + +## Fixed +- Documentation issue in which a non-existing nginx file was referenced +- Issue where a bad inbox URL could break federation +- Issue where hashtag rel values would be scrubbed +- Issue where short domains listed in `transparency_obfuscate_domains` were not actually obfuscated + +## 2023.08 + +## Added + +- Added a new configuration option to the MediaProxy feature that allows the blocking of specific domains from using the media proxy or being explicitly allowed by the Content-Security-Policy. + - Please make sure instances you wanted to block media from are not in the MediaProxy `whitelist`, and instead use `blocklist`. +- `OnlyMedia` Upload Filter to simplify restricting uploads to audio, image, and video types +- ARM64 OTP builds + - Ubuntu22 builds are available for develop and stable + - other distributions are stable only +- Support for Elixir 1.15 + - 1.14 is still supported + - OTP26 is currently "unsupported". It will probably work, but due to the way + it handles map ordering, the test suite will not pass for it as yet. + +## Changed + +- Alpine OTP builds are now from alpine 3.18, which is OpenSSLv3 compatible. + If you use alpine OTP builds you will have to update your local system. +- Debian OTP builds are now from a base of bookworm, which is OpenSSLv3 compatible. + If you use debian OTP builds you will have to update your local system to + bookworm (currently: stable). +- Ubuntu and debian builds are compatible again! (for now...) +- Blocks/Mutes now return from max ID to min ID, in line with mastodon. +- The AnonymizeFilename filter is now enabled by default. + +## Fixed + +- Deactivated users can no longer show up in the emoji reaction list +- Embedded posts can no longer bypass `:restrict\_unauthenticated` +- GET/HEAD requests will now work when requesting AWS-based instances. + +## Security + +- Add `no_new_privs` hardening to OpenRC and systemd service files +- XML parsers cannot load any entities (thanks @Mae@is.badat.dev!) +- Reduced permissions of config files and directories, distros requiring greater permissions like group-read need to pre-create the directories + +## Removed + +- Builds for debian oldstable (bullseye) + - If you are on oldstable you should NOT attempt to update OTP builds without + first updating your machine. + +## 2023.05 + +## Added +- Custom options for users to accept/reject private messages + - options: everybody, nobody, people\_i\_follow +- MRF to reject notes from accounts newer than a given age + - this will have the side-effect of rejecting legitimate messages if your + post gets boosted outside of your local bubble and people your instance + does not know about reply to it. + +## Fixed +- Support for `streams` public key URIs +- Bookmarks are cleaned up on DB prune now + +## Security +- Fixed mediaproxy being a bit of a silly billy + +## 2023.04 + +## Added +- Nodeinfo keys for unauthenticated timeline visibility +- Option to disable federated timeline +- Option to make the bubble timeline publicly accessible +- Ability to swap between installed standard frontends + - *mastodon frontends are still not counted as standard frontends due to the complexity in serving them correctly*. + +### Upgrade Notes +- Elixir 1.14 is now required. If your distribution does not package this, you can + use [asdf](https://asdf-vm.com/). At time of writing, elixir 1.14.3 / erlang 25.3 + is confirmed to work. + +## 2023.03 + +## Fixed +- Allowed contentMap to be updated on edit +- Filter creation now accepts expires\_at + +### Changed +- Restoring the database from a dump now goes much faster without need for work-arounds +- Misskey reaction matching uses `content` parameter now + +### Added +- Extend the mix task `prune_objects` with option `--prune-orphaned-activities` to also prune orphaned activities, allowing to reclaim even more database space + +### Removed +- Possibility of using the `style` parameter on `span` elements. This will break certain MFM parameters. +- Option for "default" image description. + +## 2023.02 + +### Added +- Prometheus metrics exporting from `/api/v1/akkoma/metrics` +- Ability to alter http pool size +- Translation of statuses via ArgosTranslate +- Argon2 password hashing +- Ability to "verify" links in profile fields via rel=me +- Mix tasks to dump/load config to/from json for bulk editing +- Followed hashtag list at /api/v1/followed\_tags, API parity with mastodon +- Ability to set posting language in the post form, API parity with mastodon +- Ability to match domains in MRF by a trailing wildcard + - Currently supported formats: + - `example.com` (implicitly matches `*.example.com`) + - `*.example.com` + - `example.*` (implicitly matches `*.example.*`) + +### Removed +- Non-finch HTTP adapters +- Legacy redirect from /api/pleroma/admin to /api/v1/pleroma/admin +- Legacy redirects from /api/pleroma to /api/v1/pleroma +- :crypt dependency + +### Changed +- Return HTTP error 413 when uploading an avatar or banner that's above the configured upload limit instead of a 500. +- Non-admin users now cannot register `admin` scope tokens (not security-critical, they didn't work before, but you _could_ create them) + - Admin scopes will be dropped on create +- Rich media will now backoff for 20 minutes after a failure +- Quote posts are now considered as part of the same thread as the post they are quoting +- Extend the mix task `prune_objects` with options to keep more relevant posts +- Simplified HTTP signature processing +- Rich media will now hard-exit after 5 seconds, to prevent timeline hangs +- HTTP Content Security Policy is now far more strict to prevent any potential XSS/CSS leakages +- Follow requests are now paginated, matches mastodon API spec, so use the Link header to paginate. + +### Fixed +- /api/v1/accounts/lookup will now respect restrict\_unauthenticated +- Unknown atoms in the config DB will no longer crash akkoma on boot + +### Upgrade notes +- Ensure `config :tesla, :adapter` is either unset, or set to `{Tesla.Adapter.Finch, name: MyFinch}` in your .exs config +- Pleroma-FE will need to be updated to handle the new /api/v1/pleroma endpoints for custom emoji + +## 2022.12 + +## Added +- Config: HTTP timeout options, :pool\_timeout and :receive\_timeout +- Added statistic gathering about instances which do/don't have signed fetches when they request from us +- Ability to set a default post expiry time, after which the post will be deleted. If used in concert with ActivityExpiration MRF, the expiry which comes _sooner_ will be applied. +- Regular task to prune local transient activities +- Task to manually run the transient prune job (pleroma.database prune\_task) +- Ability to follow hashtags +- Option to extend `reject` in MRF-Simple to apply to entire threads, where the originating instance is rejected +- Extra information to failed HTTP requests + +## Changed +- MastoAPI: Accept BooleanLike input on `/api/v1/accounts/:id/follow` (fixes follows with mastodon.py) +- Relays from akkoma are now off by default +- NormalizeMarkup MRF is now on by default +- Follow/Block/Mute imports now spin off into *n* tasks to avoid the oban timeout +- Transient activities recieved from remote servers are no longer persisted in the database +- Overhauled static-fe view for logged-out users +- Blocked instances will now not be sent _any_ requests, even fetch ones that would get rejected by MRF anyhow + +## Removed +- FollowBotPolicy +- Passing of undo/block into MRF + +## Upgrade Notes +- If you have an old instance, you will probably want to run `mix pleroma.database prune_task` in the foreground to catch it up with the history of your instance. + +## 2022.11 + +## Added +- Officially supported docker release +- Ability to remove followers unilaterally without a block +- Scraping of nodeinfo from remote instances to display instance info +- `requested_by` in relationships when the user has requested to follow you + +## Changed - Follows no longer override domain blocks, a domain block is final - Deletes are now the lowest priority to publish and will be handled after creates -- Uploadfilter `Pleroma.Upload.Filter.Exiftool` has been renamed to `Pleroma.Upload.Filter.Exiftool.StripLocation` +- Domain blocks are now subdomain-matches by default + +## Fixed +- Registrations via ldap are now compatible with the latest OTP24 + +## Update notes +- If you use LDAP and run from source, please update your elixir/erlang + to the latest. The changes in OTP24.3 are breaking. +- You can now remove the leading `*.` from domain blocks, but you do not have to. ## 2022.10 diff --git a/COPYING b/COPYING index dd25f1d81..3df29344c 100644 --- a/COPYING +++ b/COPYING @@ -1,12 +1,15 @@ -Unless otherwise stated this repository is copyright © 2017-2021 -Pleroma Authors , and is distributed under -The GNU Affero General Public License Version 3, you should have received a -copy of the license file as AGPL-3. +Unless otherwise stated this repository is +Copyright © 2017-2022 Pleroma Authors +Copyright © 2022 Akkoma Authors +and is distributed under The GNU Affero General Public License Version 3, you +should have received a copy of the license file as AGPL-3. --- -Files inside docs directory are copyright © 2021 Pleroma Authors -, and are distributed under the Creative Commons +Files inside docs directory are +Copyright © 2021-2022 Pleroma Authors +Copyright © 2022 Akkoma Authors +and are distributed under the Creative Commons Attribution 4.0 International license, you should have received a copy of the license file as CC-BY-4.0. @@ -16,17 +19,7 @@ The following files are copyright © 2019 shitposter.club, and are distributed under the Creative Commons Attribution-ShareAlike 4.0 International license, you should have received a copy of the license file as CC-BY-SA-4.0. -priv/static/images/pleroma-fox-tan.png priv/static/images/pleroma-fox-tan-smol.png -priv/static/images/pleroma-tan.png - ---- - -The following files are copyright © 2019 shitposter.club, and are distributed -under the Creative Commons Attribution 4.0 International license, you should -have received a copy of the license file as CC-BY-4.0. - -priv/static/images/pleroma-fox-tan-shy.png --- @@ -35,22 +28,4 @@ The following files are copyright © 2017-2020 Pleroma Authors Attribution-ShareAlike 4.0 International license, you should have received a copy of the license file as CC-BY-SA-4.0. -priv/static/images/avi.png -priv/static/images/banner.png priv/static/instance/thumbnail.jpeg - ---- - -All photos published on Unsplash can be used for free. You can use them for -commercial and noncommercial purposes. You do not need to ask permission from -or provide credit to the photographer or Unsplash, although it is appreciated -when possible. - -More precisely, Unsplash grants you an irrevocable, nonexclusive, worldwide -copyright license to download, copy, modify, distribute, perform, and use -photos from Unsplash for free, including for commercial purposes, without -permission from or attributing the photographer or Unsplash. This license -does not include the right to compile photos from Unsplash to replicate -a similar or competing service. - -priv/static/images/city.jpg diff --git a/Dockerfile b/Dockerfile index 6ba7a2269..aadd08f7a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,7 @@ -FROM hexpm/elixir:1.13.4-erlang-24.3.4.5-alpine-3.15.6 +FROM hexpm/elixir:1.15.4-erlang-26.0.2-alpine-3.18.2 ENV MIX_ENV=prod +ENV ERL_EPMD_ADDRESS=127.0.0.1 ARG HOME=/opt/akkoma diff --git a/Makefile b/Makefile deleted file mode 100644 index bc8719e68..000000000 --- a/Makefile +++ /dev/null @@ -1,7 +0,0 @@ -all: install - pipenv run mkdocs build - -install: - pipenv install -clean: - rm -rf docs diff --git a/README.md b/README.md index c3ead7fc1..e4aa25715 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,33 @@ This is a fork of Pleroma, which is a microblogging server software that can federate (= exchange messages with) other servers that support ActivityPub. What that means is that you can host a server for yourself or your friends and stay in control of your online identity, but still exchange messages with people on larger servers. Akkoma will federate with all servers that implement ActivityPub, like Friendica, GNU Social, Hubzilla, Mastodon, Misskey, Peertube, and Pixelfed. -Akkoma is written in Elixir and uses PostgresSQL for data storage. +Akkoma is written in Elixir and uses PostgreSQL for data storage. For clients it supports the [Mastodon client API](https://docs.joinmastodon.org/api/guidelines/) with Pleroma extensions (see the API section on ). - [Client Applications for Akkoma](https://docs.akkoma.dev/stable/clients/) +## Differences with Pleroma + +Akkoma is a faster-paced fork, it has a varied and potentially experimental feature set tailored specifically to the corner of the fediverse inhabited by the project +creator and contributors. + +This should not be considered a one-for-one match with pleroma; it is more opinionated in many ways, and has a smaller community (which is good or +bad depending on your view) + +For example, Akkoma has: +- Custom Emoji reactions (compatible with misskey) +- Misskey-flavoured markdown support +- Elasticsearch and Meilisearch support for search +- Mastodon frontend (Glitch-Soc and Fedibird flavours) support +- Automatic post translation via DeepL or LibreTranslate +- A multitude of heavy modifications to the Pleroma Frontend (Pleroma-FE) +- The "bubble" concept, in which instance administrators can choose closely-related instances to make a "community of communities", so to say + +And takes a more opinionated stance on issues like Domain blocks, which are enforced far more on Akkoma. + +Take a look at the Changelog if you want a full list of recent changes, everything since 3.0 has been Akkoma. + ## Installation ### OTP releases (Recommended) @@ -25,15 +46,16 @@ If your platform is not supported, or you just want to be able to edit the sourc - [Alpine Linux](https://docs.akkoma.dev/stable/installation/alpine_linux_en/) - [Arch Linux](https://docs.akkoma.dev/stable/installation/arch_linux_en/) - [Debian-based](https://docs.akkoma.dev/stable/installation/debian_based_en/) -- [Debian-based (jp)](https://docs.akkoma.dev/stable/installation/debian_based_jp/) - [FreeBSD](https://docs.akkoma.dev/stable/installation/freebsd_en/) - [Gentoo Linux](https://docs.akkoma.dev/stable/installation/gentoo_en/) - [NetBSD](https://docs.akkoma.dev/stable/installation/netbsd_en/) - [OpenBSD](https://docs.akkoma.dev/stable/installation/openbsd_en/) -- [OpenBSD (fi)](https://docs.akkoma.dev/stable/installation/openbsd_fi/) ### Docker -While we don’t provide docker files, other people have written very good ones. Take a look at or . +Docker installation is supported via [this setup](https://docs.akkoma.dev/stable/installation/docker_en/) + +### Packages +Akkoma is packaged for [YunoHost](https://yunohost.org) and can be found and installed from the [YunoHost app catalogue](https://yunohost.org/#/apps). ### Compilation Troubleshooting If you ever encounter compilation issues during the updating of Akkoma, you can try these commands and see if they fix things: @@ -45,3 +67,4 @@ If you ever encounter compilation issues during the updating of Akkoma, you can ## Documentation - https://docs.akkoma.dev/stable +- https://docs.akkoma.dev/develop diff --git a/SECURITY.md b/SECURITY.md index c009d21d9..d37a8c9ca 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,16 +1,21 @@ -# Pleroma backend security policy - -## Supported versions - -Currently, Pleroma offers bugfixes and security patches only for the latest minor release. - -| Version | Support -|---------| -------- -| 2.2 | Bugfixes and security patches +# Akkoma backend security handling ## Reporting a vulnerability -Please use confidential issues (tick the "This issue is confidential and should only be visible to team members with at least Reporter access." box when submitting) at our [bugtracker](https://git.pleroma.social/pleroma/pleroma/-/issues/new) for reporting vulnerabilities. +Please send an email (preferably encrypted) or +a DM via our IRC to one of the following people: + +| Forgejo nick | IRC nick | Email | GPG | +| ------------ | ------------- | ------------- | --------------------------------------- | +| floatinghost | FloatingGhost | *see GPG key* | https://coffee-and-dreams.uk/pubkey.asc | + ## Announcements -New releases are announced at [pleroma.social](https://pleroma.social/announcements/). All security releases are tagged with ["Security"](https://pleroma.social/announcements/tags/security/). You can be notified of them by subscribing to an Atom feed at . +New releases and security issues are announced at +[meta.akkoma.dev](https://meta.akkoma.dev/c/releases) and +[@akkoma@ihatebeinga.live](https://ihatebeinga.live/akkoma). + +Both also offer RSS feeds +([meta](https://meta.akkoma.dev/c/releases/7.rss), +[fedi](https://ihatebeinga.live/users/akkoma.rss)) +so you can keep an eye on it without any accounts. diff --git a/config/benchmark.exs b/config/benchmark.exs index b6a0115c4..940c5f477 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: [] diff --git a/config/config.exs b/config/config.exs index 5eb82cd33..e0a5eccb1 100644 --- a/config/config.exs +++ b/config/config.exs @@ -61,12 +61,12 @@ # Upload configuration config :pleroma, Pleroma.Upload, uploader: Pleroma.Uploaders.Local, - filters: [Pleroma.Upload.Filter.Dedupe], + filters: [], link_name: false, proxy_remote: false, filename_display_max_length: 30, - default_description: nil, - base_url: nil + base_url: nil, + allowed_mime_types: ["image", "audio", "video"] config :pleroma, Pleroma.Uploaders.Local, uploads: "uploads" @@ -111,17 +111,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"], @@ -131,10 +120,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, []}} ]} ] ], @@ -163,29 +149,51 @@ format: "$metadata[$level] $message", metadata: [:request_id] -config :quack, - level: :warn, - meta: [:all], - webhook_url: "https://hooks.slack.com/services/YOUR-KEY-HERE" - +# ——————————————————————————————————————————————————————————————— +# W A R N I N G +# ——————————————————————————————————————————————————————————————— +# +# Whenever adding a privileged new custom type for e.g. +# ActivityPub objects, ALWAYS map their extension back +# to "application/octet-stream". +# Else files served by us can automatically end up with +# those privileged types causing severe security hazards. +# (We need those mappings so Phoenix can assoiate its format +# (the "extension") to incoming requests of those MIME types) +# +# ——————————————————————————————————————————————————————————————— config :mime, :types, %{ "application/xml" => ["xml"], "application/xrd+xml" => ["xrd+xml"], "application/jrd+json" => ["jrd+json"], "application/activity+json" => ["activity+json"], - "application/ld+json" => ["activity+json"] + "application/ld+json" => ["activity+json"], + # Can be removed when bumping MIME past 2.0.5 + # see https://akkoma.dev/AkkomaGang/akkoma/issues/657 + "image/apng" => ["apng"] } +config :mime, :extensions, %{ + "xrd+xml" => "text/plain", + "jrd+json" => "text/plain", + "activity+json" => "text/plain" +} + +# ——————————————————————————————————————————————————————————————— + config :tesla, :adapter, {Tesla.Adapter.Finch, name: MyFinch} # Configures http settings, upstream proxy etc. config :pleroma, :http, + pool_timeout: :timer.seconds(5), + receive_timeout: :timer.seconds(15), proxy_url: nil, user_agent: :default, + pool_size: 50, adapter: [] config :pleroma, :instance, - name: "Pleroma", + name: "Akkoma", email: "example@example.com", notify_email: "noreply@example.com", description: "Akkoma: The cooler fediverse server", @@ -215,7 +223,7 @@ federation_publisher_modules: [ Pleroma.Web.ActivityPub.Publisher ], - allow_relay: true, + allow_relay: false, public: true, static_dir: "instance/static/", allowed_post_formats: [ @@ -262,7 +270,9 @@ profile_directory: true, privileged_staff: false, local_bubble: [], - max_frontend_settings_json_chars: 100_000 + max_frontend_settings_json_chars: 100_000, + export_prometheus_metrics: true, + federated_timeline_available: true config :pleroma, :welcome, direct_message: [ @@ -301,7 +311,6 @@ alwaysShowSubjectInput: true, background: "/images/city.jpg", collapseMessageWithSubject: false, - disableChat: false, greentext: false, hideFilteredStatuses: false, hideMutedPosts: false, @@ -312,19 +321,19 @@ logo: "/static/logo.svg", logoMargin: ".1em", logoMask: true, - minimalScopesMode: false, noAttachmentLinks: false, nsfwCensorImage: "", postContentType: "text/plain", redirectRootLogin: "/main/friends", - redirectRootNoLogin: "/main/all", + redirectRootNoLogin: "/main/public", scopeCopy: true, sidebarRight: false, showFeaturesPanel: true, showInstanceSpecificPanel: false, subjectLineBehavior: "email", theme: "pleroma-dark", - webPushNotifications: false + webPushNotifications: false, + conversationDisplay: "linear" }, masto_fe: %{ showInstanceSpecificPanel: true @@ -355,7 +364,7 @@ config :pleroma, :activitypub, unfollow_blocked: true, - outgoing_blocks: true, + outgoing_blocks: false, blockers_visible: true, follow_handshake_timeout: 500, note_replies_output_limit: 5, @@ -389,7 +398,9 @@ accept: [], avatar_removal: [], banner_removal: [], - reject_deletes: [] + background_removal: [], + reject_deletes: [], + handle_threads: true config :pleroma, :mrf_keyword, reject: [], @@ -416,7 +427,7 @@ threshold: 604_800, actions: [:delist, :strip_followers] -config :pleroma, :mrf_follow_bot, follower_nickname: nil +config :pleroma, :mrf_reject_newly_created_account_notes, age: 86_400 config :pleroma, :rich_media, enabled: true, @@ -426,7 +437,7 @@ Pleroma.Web.RichMedia.Parsers.TwitterCard, Pleroma.Web.RichMedia.Parsers.OEmbed ], - failure_backoff: 60_000, + failure_backoff: :timer.minutes(20), ttl_setters: [Pleroma.Web.RichMedia.Parser.TTL.AwsSignedUrl] config :pleroma, :media_proxy, @@ -441,7 +452,8 @@ # Note: max_read_duration defaults to Pleroma.ReverseProxy.max_read_duration_default/1 max_read_duration: 30_000 ], - whitelist: [] + whitelist: [], + blocklist: [] config :pleroma, Pleroma.Web.MediaProxy.Invalidation.Http, method: :purge, @@ -460,10 +472,6 @@ image_quality: 85, min_content_length: 100 * 1024 -config :pleroma, :shout, - enabled: true, - limit: 5_000 - config :phoenix, :format_encoders, json: Jason, "activity+json": Jason config :phoenix, :json_library, Jason @@ -487,8 +495,7 @@ config :pleroma, :http_security, enabled: true, sts: false, - sts_max_age: 31_536_000, - ct_max_age: 2_592_000, + sts_max_age: 63_072_000, referrer_policy: "same-origin" config :cors_plug, @@ -567,7 +574,9 @@ attachments_cleanup: 1, new_users_digest: 1, mute_expire: 5, - search_indexing: 10 + search_indexing: 10, + nodeinfo_fetcher: 1, + database_prune: 1 ], plugins: [ Oban.Plugins.Pruner, @@ -575,7 +584,8 @@ ], crontab: [ {"0 0 * * 0", Pleroma.Workers.Cron.DigestEmailsWorker}, - {"0 0 * * *", Pleroma.Workers.Cron.NewUsersDigestWorker} + {"0 0 * * *", Pleroma.Workers.Cron.NewUsersDigestWorker}, + {"0 3 * * *", Pleroma.Workers.Cron.PruneDatabaseWorker} ] config :pleroma, :workers, @@ -583,6 +593,28 @@ federator_incoming: 5, federator_outgoing: 5, search_indexing: 2 + ], + timeout: [ + activity_expiration: :timer.seconds(5), + token_expiration: :timer.seconds(5), + filter_expiration: :timer.seconds(5), + backup: :timer.seconds(900), + federator_incoming: :timer.seconds(10), + federator_outgoing: :timer.seconds(10), + ingestion_queue: :timer.seconds(5), + web_push: :timer.seconds(5), + mailer: :timer.seconds(5), + transmogrifier: :timer.seconds(5), + scheduled_activities: :timer.seconds(5), + poll_notifications: :timer.seconds(5), + background: :timer.seconds(5), + remote_fetcher: :timer.seconds(10), + attachments_cleanup: :timer.seconds(900), + new_users_digest: :timer.seconds(10), + mute_expire: :timer.seconds(5), + search_indexing: :timer.seconds(5), + nodeinfo_fetcher: :timer.seconds(10), + database_prune: :timer.minutes(10) ] config :pleroma, Pleroma.Formatter, @@ -628,6 +660,10 @@ config :pleroma, Pleroma.Emails.Mailer, adapter: Swoosh.Adapters.Sendmail, enabled: false +config :swoosh, + api_client: Swoosh.ApiClient.Finch, + finch_name: MyFinch + config :pleroma, Pleroma.Emails.UserEmail, logo: nil, styling: %{ @@ -717,6 +753,9 @@ primary: %{"name" => "pleroma-fe", "ref" => "stable"}, admin: %{"name" => "admin-fe", "ref" => "stable"}, mastodon: %{"name" => "mastodon-fe", "ref" => "akkoma"}, + pickable: [ + "pleroma-fe/stable" + ], swagger: %{ "name" => "swagger-ui", "ref" => "stable", @@ -755,14 +794,6 @@ "https://akkoma-updates.s3-website.fr-par.scw.cloud/frontend/${ref}/admin-fe.zip", "ref" => "stable" }, - "soapbox-fe" => %{ - "name" => "soapbox-fe", - "git" => "https://gitlab.com/soapbox-pub/soapbox", - "build_url" => - "https://gitlab.com/soapbox-pub/soapbox/-/jobs/artifacts/${ref}/download?job=build-production", - "ref" => "v2.0.0", - "build_dir" => "static" - }, # For developers - enables a swagger frontend to view the openapi spec "swagger-ui" => %{ "name" => "swagger-ui", @@ -790,7 +821,7 @@ private_instance? = :if_instance_is_private config :pleroma, :restrict_unauthenticated, - timelines: %{local: private_instance?, federated: private_instance?}, + timelines: %{local: private_instance?, federated: private_instance?, bubble: true}, profiles: %{local: private_instance?, remote: private_instance?}, activities: %{local: private_instance?, remote: private_instance?} @@ -806,7 +837,8 @@ config :web_push_encryption, http_client: Pleroma.HTTP.WebPush -config :pleroma, :instances_favicons, enabled: false +config :pleroma, :instances_favicons, enabled: true +config :pleroma, :instances_nodeinfo, enabled: true config :floki, :html_parser, Floki.HTMLParser.FastHtml @@ -861,6 +893,11 @@ url: "http://127.0.0.1:5000", api_key: nil +config :pleroma, :argos_translate, + command_argos_translate: "argos-translate", + command_argospm: "argospm", + strip_html: true + # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{Mix.env()}.exs" diff --git a/config/custom_emoji.txt b/config/custom_emoji.txt index e69de29bb..7b2e51265 100644 --- a/config/custom_emoji.txt +++ b/config/custom_emoji.txt @@ -0,0 +1,2 @@ +hehe, /emoji/hehe.png, Akkoma +nothehe, /emoji/nothehe.png, Akkoma diff --git a/config/description.exs b/config/description.exs index a17897b98..ec5050be6 100644 --- a/config/description.exs +++ b/config/description.exs @@ -100,9 +100,22 @@ label: "Base URL", type: :string, description: - "Base URL for the uploads. Required if you use a CDN or host attachments under a different domain.", + "Base URL for the uploads. Required if you use a CDN or host attachments under a different domain - it is HIGHLY recommended that you **do not** set this to be the same as the domain akkoma is hosted on.", suggestions: [ - "https://cdn-host.com" + "https://media.akkoma.dev/media/" + ] + }, + %{ + key: :allowed_mime_types, + label: "Allowed MIME types", + type: {:list, :string}, + description: + "List of MIME (main) types uploads are allowed to identify themselves with. Other types may still be uploaded, but will identify as a generic binary to clients. WARNING: Loosening this over the defaults can lead to security issues. Removing types is safe, but only add to the list if you are sure you know what you are doing.", + suggestions: [ + "image", + "audio", + "video", + "font" ] }, %{ @@ -691,8 +704,8 @@ key: :public, type: :boolean, description: - "Makes the client API in authenticated mode-only except for user-profiles." <> - " Useful for disabling the Local Timeline and The Whole Known Network. " <> + "Switching this on will allow unauthenticated users access to all public resources on your instance" <> + " Switching it off is useful for disabling the Local Timeline and The Whole Known Network. " <> " Note: when setting to `false`, please also check `:restrict_unauthenticated` setting." }, %{ @@ -723,7 +736,8 @@ "text/plain", "text/html", "text/markdown", - "text/bbcode" + "text/bbcode", + "text/x.misskeymarkdown" ] }, %{ @@ -789,7 +803,7 @@ %{ key: :healthcheck, type: :boolean, - description: "If enabled, system data will be shown on `/api/pleroma/healthcheck`" + description: "If enabled, system data will be shown on `/api/v1/pleroma/healthcheck`" }, %{ key: :remote_post_retention_days, @@ -963,6 +977,17 @@ type: {:list, :string}, description: "List of instances that make up your local bubble (closely-related instances). Used to populate the 'bubble' timeline (domain only)." + }, + %{ + key: :export_prometheus_metrics, + type: :boolean, + description: "Enable prometheus metrics (at /api/v1/akkoma/metrics)" + }, + %{ + key: :federated_timeline_available, + type: :boolean, + description: + "Let people view the 'firehose' feed of all public statuses from all instances." } ] }, @@ -1069,7 +1094,7 @@ key: :level, type: {:dropdown, :atom}, description: "Log level", - suggestions: [:debug, :info, :warn, :error] + suggestions: [:debug, :info, :warning, :error] }, %{ key: :ident, @@ -1102,7 +1127,7 @@ key: :level, type: {:dropdown, :atom}, description: "Log level", - suggestions: [:debug, :info, :warn, :error] + suggestions: [:debug, :info, :warning, :error] }, %{ key: :format, @@ -1117,45 +1142,6 @@ } ] }, - %{ - group: :quack, - type: :group, - label: "Quack Logger", - description: "Quack-related settings", - children: [ - %{ - key: :level, - type: {:dropdown, :atom}, - description: "Log level", - suggestions: [:debug, :info, :warn, :error] - }, - %{ - key: :meta, - type: {:list, :atom}, - description: "Configure which metadata you want to report on", - suggestions: [ - :application, - :module, - :file, - :function, - :line, - :pid, - :crash_reason, - :initial_call, - :registered_name, - :all, - :none - ] - }, - %{ - key: :webhook_url, - label: "Webhook URL", - type: :string, - description: "Configure the Slack incoming webhook", - suggestions: ["https://hooks.slack.com/services/YOUR-KEY-HERE"] - } - ] - }, %{ group: :pleroma, key: :frontend_configurations, @@ -1226,6 +1212,13 @@ type: :boolean, description: "Enables green text on lines prefixed with the > character" }, + %{ + key: :conversationDisplay, + label: "Conversation display style", + type: :string, + description: "How to display conversations (linear or tree)", + suggestions: ["linear", "tree"] + }, %{ key: :hideFilteredStatuses, label: "Hide Filtered Statuses", @@ -1274,14 +1267,6 @@ "By default it assumes logo used will be monochrome with alpha channel to be compatible with both light and dark themes. " <> "If you want a colorful logo you must disable logoMask." }, - %{ - key: :minimalScopesMode, - label: "Minimal scopes mode", - type: :boolean, - description: - "Limit scope selection to Direct, User default, and Scope of post replying to. " <> - "Also prevents replying to a DM with a public post from PleromaFE." - }, %{ key: :nsfwCensorImage, label: "NSFW Censor Image", @@ -1295,7 +1280,13 @@ label: "Post Content Type", type: {:dropdown, :atom}, description: "Default post formatting option", - suggestions: ["text/plain", "text/html", "text/markdown", "text/bbcode"] + suggestions: [ + "text/plain", + "text/html", + "text/markdown", + "text/bbcode", + "text/x.misskeymarkdown" + ] }, %{ key: :redirectRootNoLogin, @@ -1389,6 +1380,12 @@ label: "Render misskey markdown", type: :boolean, description: "Whether to render Misskey-flavoured markdown" + }, + %{ + key: :stopGifs, + label: "Stop Gifs", + type: :boolean, + description: "Whether to pause animated images until they're hovered on" } ] }, @@ -1574,7 +1571,21 @@ %{ key: :whitelist, type: {:list, :string}, - description: "List of hosts with scheme to bypass the MediaProxy", + description: """ + List of hosts with scheme to bypass the MediaProxy.\n + The media will be fetched by the client, directly from the remote server.\n + To allow this, it will Content-Security-Policy exceptions for each instance listed.\n + This is to be used for instances you trust and do not want to cache media for. + """, + suggestions: ["http://example.com"] + }, + %{ + key: :blocklist, + type: {:list, :string}, + description: """ + List of hosts with scheme which will not go through the MediaProxy, and will not be explicitly allowed by the Content-Security-Policy. + This is to be used for instances where you do not want their media to go through your server or to be accessed by clients. + """, suggestions: ["http://example.com"] } ] @@ -1744,14 +1755,7 @@ label: "STS max age", type: :integer, description: "The maximum age for the Strict-Transport-Security header if sent", - suggestions: [31_536_000] - }, - %{ - key: :ct_max_age, - label: "CT max age", - type: :integer, - description: "The maximum age for the Expect-CT header if sent", - suggestions: [2_592_000] + suggestions: [63_072_000] }, %{ key: :referrer_policy, @@ -1867,7 +1871,7 @@ key: :log, type: {:dropdown, :atom}, description: "Logs verbose mode", - suggestions: [false, :error, :warn, :info, :debug] + suggestions: [false, :error, :warning, :info, :debug] }, %{ key: :queues, @@ -1973,6 +1977,32 @@ federator_incoming: 5, federator_outgoing: 5 ] + }, + %{ + key: :timeout, + type: {:keyword, :integer}, + description: "Timeout for jobs, per `Oban` queue, in ms", + suggestions: [ + activity_expiration: :timer.seconds(5), + token_expiration: :timer.seconds(5), + filter_expiration: :timer.seconds(5), + backup: :timer.seconds(900), + federator_incoming: :timer.seconds(10), + federator_outgoing: :timer.seconds(10), + ingestion_queue: :timer.seconds(5), + web_push: :timer.seconds(5), + mailer: :timer.seconds(5), + transmogrifier: :timer.seconds(5), + scheduled_activities: :timer.seconds(5), + poll_notifications: :timer.seconds(5), + background: :timer.seconds(5), + remote_fetcher: :timer.seconds(10), + attachments_cleanup: :timer.seconds(900), + new_users_digest: :timer.seconds(10), + mute_expire: :timer.seconds(5), + search_indexing: :timer.seconds(5), + nodeinfo_fetcher: :timer.seconds(10) + ] } ] }, @@ -2634,6 +2664,21 @@ type: :group, description: "HTTP settings", children: [ + %{ + key: :pool_timeout, + label: "HTTP Pool Request Timeout", + type: :integer, + description: "Timeout for initiating HTTP requests (in ms, default 5000)", + suggestions: [5000] + }, + %{ + key: :receive_timeout, + label: "HTTP Receive Timeout", + type: :integer, + description: + "Timeout for waiting on remote servers to respond to HTTP requests (in ms, default 15000)", + suggestions: [15000] + }, %{ key: :proxy_url, label: "Proxy URL", @@ -2649,6 +2694,12 @@ "What user agent to use. Must be a string or an atom `:default`. Default value is `:default`.", suggestions: ["Pleroma", :default] }, + %{ + key: :pool_size, + type: :integer, + description: "Number of concurrent outbound HTTP requests to allow. Default 50.", + suggestions: [50] + }, %{ key: :adapter, type: :keyword, @@ -2959,8 +3010,7 @@ key: :restrict_unauthenticated, label: "Restrict Unauthenticated", type: :group, - description: - "Disallow viewing timelines, user profiles and statuses for unauthenticated users.", + description: "Disallow unauthenticated viewing of timelines, user profiles and statuses.", children: [ %{ key: :timelines, @@ -2970,12 +3020,17 @@ %{ key: :local, type: :boolean, - description: "Disallow view public timeline." + description: "Disallow viewing the public timeline." }, %{ key: :federated, type: :boolean, - description: "Disallow view federated timeline." + description: "Disallow viewing the whole known network timeline." + }, + %{ + key: :bubble, + type: :boolean, + description: "Disallow viewing the bubble timeline." } ] }, @@ -2987,29 +3042,29 @@ %{ key: :local, type: :boolean, - description: "Disallow view local user profiles." + description: "Disallow viewing local user profiles." }, %{ key: :remote, type: :boolean, - description: "Disallow view remote user profiles." + description: "Disallow viewing remote user profiles." } ] }, %{ key: :activities, type: :map, - description: "Settings for statuses.", + description: "Settings for posts.", children: [ %{ key: :local, type: :boolean, - description: "Disallow view local statuses." + description: "Disallow viewing local posts." }, %{ key: :remote, type: :boolean, - description: "Disallow view remote statuses." + description: "Disallow viewing remote posts." } ] } @@ -3041,6 +3096,19 @@ } ] }, + %{ + group: :pleroma, + key: :instances_nodeinfo, + type: :group, + description: "Control favicons for instances", + children: [ + %{ + key: :enabled, + type: :boolean, + description: "Allow/disallow getting instance nodeinfo" + } + ] + }, %{ group: :ex_aws, key: :s3, @@ -3118,6 +3186,12 @@ description: "A map containing available frontends and parameters for their installation.", children: frontend_options + }, + %{ + key: :pickable, + type: {:list, :string}, + description: + "A list containing all frontends users can pick as their preference, format is :name/:ref, e.g pleroma-fe/stable." } ] }, @@ -3412,5 +3486,32 @@ suggestion: [nil] } ] + }, + %{ + group: :pleroma, + key: :argos_translate, + type: :group, + description: "ArgosTranslate Settings.", + children: [ + %{ + key: :command_argos_translate, + type: :string, + description: + "command for `argos-translate`. Can be the command if it's in your PATH, or the full path to the file.", + suggestion: ["argos-translate"] + }, + %{ + key: :command_argospm, + type: :string, + description: + "command for `argospm`. Can be the command if it's in your PATH, or the full path to the file.", + suggestion: ["argospm"] + }, + %{ + key: :strip_html, + type: :boolean, + description: "Strip html from the post before translating it." + } + ] } ] diff --git a/config/test.exs b/config/test.exs index a5edb1149..75751e115 100644 --- a/config/test.exs +++ b/config/test.exs @@ -16,15 +16,17 @@ # 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: [] config :pleroma, Pleroma.Upload, + base_url: "http://localhost:4001/media/", filters: [], - link_name: false, - default_description: :filename + link_name: false + +config :pleroma, :media_proxy, base_url: "http://localhost:4001" config :pleroma, Pleroma.Uploaders.Local, uploads: "test/uploads" @@ -82,10 +84,7 @@ "BLH1qVhJItRGCfxgTtONfsOKDc9VRAraXw-3NsmjMngWSh7NxOizN6bkuRA7iLTMPS82PjwJAr3UoK9EC1IFrz4", private_key: "_-XZ0iebPrRfZ_o0-IatTdszYa8VCH1yLN-JauK7HHA" -config :pleroma, Oban, - queues: false, - crontab: false, - plugins: false +config :pleroma, Oban, testing: :manual config :pleroma, Pleroma.ScheduledActivity, daily_user_limit: 2, @@ -139,6 +138,8 @@ # Reduce recompilation time # https://dashbit.co/blog/speeding-up-re-compilation-of-elixir-projects config :phoenix, :plug_init_mode, :runtime +config :pleroma, :instances_favicons, enabled: false +config :pleroma, :instances_nodeinfo, enabled: false if File.exists?("./config/test.secret.exs") do import_config "test.secret.exs" diff --git a/docker-compose.yml b/docker-compose.yml index 0dedbc87e..6c0e2a7e1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,6 +4,7 @@ services: db: image: akkoma-db:latest build: ./docker-resources/database + shm_size: 4gb restart: unless-stopped user: ${DOCKER_USER} environment: { @@ -45,7 +46,7 @@ services: volumes: - .:/opt/akkoma - # Uncomment the following if you want to use a reverse proxy + # Copy this into docker-compose.override.yml and uncomment there if you want to use a reverse proxy #proxy: # image: caddy:2-alpine # restart: unless-stopped diff --git a/docker-resources/build.sh b/docker-resources/build.sh index daa653da6..ce4f30f8d 100755 --- a/docker-resources/build.sh +++ b/docker-resources/build.sh @@ -1,4 +1,4 @@ #!/bin/sh -docker-compose build --build-arg UID=$(id -u) --build-arg GID=$(id -g) akkoma -docker-compose build --build-arg UID=$(id -u) --build-arg GID=$(id -g) db +docker compose build --build-arg UID=$(id -u) --build-arg GID=$(id -g) akkoma +docker compose build --build-arg UID=$(id -u) --build-arg GID=$(id -g) db diff --git a/docker-resources/env.example b/docker-resources/env.example index d6cf0c7b8..23ca15221 100644 --- a/docker-resources/env.example +++ b/docker-resources/env.example @@ -1,4 +1,5 @@ MIX_ENV=prod +ERL_EPMD_ADDRESS=127.0.0.1 DB_NAME=akkoma DB_USER=akkoma DB_PASS=akkoma diff --git a/docker-resources/manage.sh b/docker-resources/manage.sh index 944f5e2e2..acb6618c3 100755 --- a/docker-resources/manage.sh +++ b/docker-resources/manage.sh @@ -1,3 +1,3 @@ #!/bin/sh -docker-compose run --rm akkoma $@ +docker compose run --rm akkoma $@ diff --git a/docs/Makefile b/docs/Makefile index 022459cf0..85b6dee65 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -1,9 +1,14 @@ all: install pipenv run mkdocs build +branch := $(shell git rev-parse --abbrev-ref HEAD) install: pipenv install clean: rm -rf site serve: pipenv run python3 -m http.server -d site +zip: + zip -r docs.zip site/* +deploy: + cd site && rclone copy . scaleway:akkoma-docs/$(branch) diff --git a/docs/Pipfile.lock b/docs/Pipfile.lock index ae39a2776..4cd8c59b9 100644 --- a/docs/Pipfile.lock +++ b/docs/Pipfile.lock @@ -14,6 +14,22 @@ ] }, "default": { + "certifi": { + "hashes": [ + "sha256:0d9c601124e5a6ba9712dbc60d9c53c21e34f5f641fe83002317394311bdce14", + "sha256:90c1a32f1d68f940488354e36370f6cca89f0f106db09518524c88d6ed83f382" + ], + "markers": "python_full_version >= '3.6.0'", + "version": "==2022.9.24" + }, + "charset-normalizer": { + "hashes": [ + "sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845", + "sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f" + ], + "markers": "python_full_version >= '3.6.0'", + "version": "==2.1.1" + }, "click": { "hashes": [ "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e", @@ -29,13 +45,13 @@ ], "version": "==2.1.0" }, - "importlib-metadata": { + "idna": { "hashes": [ - "sha256:637245b8bab2b6502fcbc752cc4b7a6f6243bb02b31c5c26156ad103d3d45670", - "sha256:7401a975809ea1fdc658c3aa4f78cc2195a0e019c5cbc4c06122884e9ae80c23" + "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4", + "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2" ], - "markers": "python_version >= '3.7'", - "version": "==4.12.0" + "markers": "python_version >= '3.5'", + "version": "==3.4" }, "jinja2": { "hashes": [ @@ -50,15 +66,16 @@ "sha256:cbb516f16218e643d8e0a95b309f77eb118cb138d39a4f27851e6a63581db874", "sha256:f5da449a6e1c989a4cea2631aa8ee67caa5a2ef855d551c88f9e309f4634c621" ], - "markers": "python_version >= '3.6'", + "markers": "python_full_version >= '3.6.0'", "version": "==3.3.7" }, "markdown-include": { "hashes": [ - "sha256:6f5d680e36f7780c7f0f61dca53ca581bd50d1b56137ddcd6353efafa0c3e4a2" + "sha256:b8f6b6f4e8b506cbe773d7e26c74a97d1354c35f3a3452d3449140a8f578d665", + "sha256:d12fb51500c46334a53608635035c78b7d8ad7f772566f70b8a6a9b2ef2ddbf5" ], "index": "pypi", - "version": "==0.6.0" + "version": "==0.8.0" }, "markupsafe": { "hashes": [ @@ -111,56 +128,56 @@ "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307" ], - "markers": "python_version >= '3.6'", + "markers": "python_full_version >= '3.6.0'", "version": "==1.3.4" }, "mkdocs": { "hashes": [ - "sha256:26bd2b03d739ac57a3e6eed0b7bcc86168703b719c27b99ad6ca91dc439aacde", - "sha256:b504405b04da38795fec9b2e5e28f6aa3a73bb0960cb6d5d27ead28952bd35ea" + "sha256:8947af423a6d0facf41ea1195b8e1e8c85ad94ac95ae307fe11232e0424b11c5", + "sha256:c8856a832c1e56702577023cd64cc5f84948280c1c0fcc6af4cd39006ea6aa8c" ], - "markers": "python_version >= '3.6'", - "version": "==1.3.0" + "markers": "python_version >= '3.7'", + "version": "==1.4.2" }, "mkdocs-material": { "hashes": [ - "sha256:263f2721f3abe533b61f7c8bed435a0462620912742c919821ac2d698b4bfe67", - "sha256:dc82b667d2a83f0de581b46a6d0949732ab77e7638b87ea35b770b33bc02e75a" + "sha256:b0ea0513fd8cab323e8a825d6692ea07fa83e917bb5db042e523afecc7064ab7", + "sha256:c907b4b052240a5778074a30a78f31a1f8ff82d7012356dc26898b97559f082e" ], "index": "pypi", - "version": "==8.3.9" + "version": "==8.5.11" }, "mkdocs-material-extensions": { "hashes": [ - "sha256:a82b70e533ce060b2a5d9eb2bc2e1be201cf61f901f93704b4acf6e3d5983a44", - "sha256:bfd24dfdef7b41c312ede42648f9eb83476ea168ec163b613f9abd12bbfddba2" + "sha256:9c003da71e2cc2493d910237448c672e00cefc800d3d6ae93d2fc69979e3bd93", + "sha256:e41d9f38e4798b6617ad98ca8f7f1157b1e4385ac1459ca1e4ea219b556df945" ], - "markers": "python_version >= '3.6'", - "version": "==1.0.3" + "markers": "python_version >= '3.7'", + "version": "==1.1.1" }, "packaging": { "hashes": [ "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb", "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522" ], - "markers": "python_version >= '3.6'", + "markers": "python_full_version >= '3.6.0'", "version": "==21.3" }, "pygments": { "hashes": [ - "sha256:5eb116118f9612ff1ee89ac96437bb6b49e8f04d8a13b514ba26f620208e26eb", - "sha256:dc9c10fb40944260f6ed4c688ece0cd2048414940f1cea51b8b226318411c519" + "sha256:56a8508ae95f98e2b9bdf93a6be5ae3f7d8af858b43e02c5a2ff083726be40c1", + "sha256:f643f331ab57ba3c9d89212ee4a2dabc6e94f117cf4eefde99a0574720d14c42" ], - "markers": "python_version >= '3.6'", - "version": "==2.12.0" + "markers": "python_full_version >= '3.6.0'", + "version": "==2.13.0" }, "pymdown-extensions": { "hashes": [ - "sha256:3ef2d998c0d5fa7eb09291926d90d69391283561cf6306f85cd588a5eb5befa0", - "sha256:ec141c0f4983755349f0c8710416348d1a13753976c028186ed14f190c8061c4" + "sha256:0f8fb7b74a37a61cc34e90b2c91865458b713ec774894ffad64353a5fce85cfc", + "sha256:ac698c15265680db5eb13cd4342abfcde2079ac01e5486028f47a1b41547b859" ], "markers": "python_version >= '3.7'", - "version": "==9.5" + "version": "==9.9" }, "pyparsing": { "hashes": [ @@ -180,6 +197,7 @@ }, "pyyaml": { "hashes": [ + "sha256:01b45c0191e6d66c470b6cf1b9531a771a83c1c4208272ead47a3ae4f2f603bf", "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293", "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b", "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57", @@ -191,30 +209,36 @@ "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287", "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513", "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0", + "sha256:432557aa2c09802be39460360ddffd48156e30721f5e8d917f01d31694216782", "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0", "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92", "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f", "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2", "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc", + "sha256:81957921f441d50af23654aa6c5e5eaf9b06aba7f0a19c18a538dc7ef291c5a1", "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c", "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86", "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4", "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c", "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34", "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b", + "sha256:afa17f5bc4d1b10afd4466fd3a44dc0e245382deca5b3c353d8b757f9e3ecb8d", "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c", "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb", + "sha256:bfaef573a63ba8923503d27530362590ff4f576c626d86a9fed95822a8255fd7", "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737", "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3", "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d", + "sha256:d4b0ba9512519522b118090257be113b9468d804b19d63c71dbcf4a48fa32358", "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53", "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78", "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803", "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a", + "sha256:dbad0e9d368bb989f4515da330b88a057617d16b6a8245084f1b05400f24609f", "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174", "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5" ], - "markers": "python_version >= '3.6'", + "markers": "python_full_version >= '3.6.0'", "version": "==6.0" }, "pyyaml-env-tag": { @@ -222,9 +246,17 @@ "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb", "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069" ], - "markers": "python_version >= '3.6'", + "markers": "python_full_version >= '3.6.0'", "version": "==0.1" }, + "requests": { + "hashes": [ + "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983", + "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349" + ], + "markers": "python_version >= '3.7' and python_version < '4'", + "version": "==2.28.1" + }, "six": { "hashes": [ "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", @@ -233,44 +265,47 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.16.0" }, + "urllib3": { + "hashes": [ + "sha256:47cc05d99aaa09c9e72ed5809b60e7ba354e64b59c9c173ac3018642d8bb41fc", + "sha256:c083dd0dce68dbfbe1129d5271cb90f9447dea7d52097c6e0126120c521ddea8" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", + "version": "==1.26.13" + }, "watchdog": { "hashes": [ - "sha256:083171652584e1b8829581f965b9b7723ca5f9a2cd7e20271edf264cfd7c1412", - "sha256:117ffc6ec261639a0209a3252546b12800670d4bf5f84fbd355957a0595fe654", - "sha256:186f6c55abc5e03872ae14c2f294a153ec7292f807af99f57611acc8caa75306", - "sha256:195fc70c6e41237362ba720e9aaf394f8178bfc7fa68207f112d108edef1af33", - "sha256:226b3c6c468ce72051a4c15a4cc2ef317c32590d82ba0b330403cafd98a62cfd", - "sha256:247dcf1df956daa24828bfea5a138d0e7a7c98b1a47cf1fa5b0c3c16241fcbb7", - "sha256:255bb5758f7e89b1a13c05a5bceccec2219f8995a3a4c4d6968fe1de6a3b2892", - "sha256:43ce20ebb36a51f21fa376f76d1d4692452b2527ccd601950d69ed36b9e21609", - "sha256:4f4e1c4aa54fb86316a62a87b3378c025e228178d55481d30d857c6c438897d6", - "sha256:5952135968519e2447a01875a6f5fc8c03190b24d14ee52b0f4b1682259520b1", - "sha256:64a27aed691408a6abd83394b38503e8176f69031ca25d64131d8d640a307591", - "sha256:6b17d302850c8d412784d9246cfe8d7e3af6bcd45f958abb2d08a6f8bedf695d", - "sha256:70af927aa1613ded6a68089a9262a009fbdf819f46d09c1a908d4b36e1ba2b2d", - "sha256:7a833211f49143c3d336729b0020ffd1274078e94b0ae42e22f596999f50279c", - "sha256:8250546a98388cbc00c3ee3cc5cf96799b5a595270dfcfa855491a64b86ef8c3", - "sha256:97f9752208f5154e9e7b76acc8c4f5a58801b338de2af14e7e181ee3b28a5d39", - "sha256:9f05a5f7c12452f6a27203f76779ae3f46fa30f1dd833037ea8cbc2887c60213", - "sha256:a735a990a1095f75ca4f36ea2ef2752c99e6ee997c46b0de507ba40a09bf7330", - "sha256:ad576a565260d8f99d97f2e64b0f97a48228317095908568a9d5c786c829d428", - "sha256:b530ae007a5f5d50b7fbba96634c7ee21abec70dc3e7f0233339c81943848dc1", - "sha256:bfc4d351e6348d6ec51df007432e6fe80adb53fd41183716017026af03427846", - "sha256:d3dda00aca282b26194bdd0adec21e4c21e916956d972369359ba63ade616153", - "sha256:d9820fe47c20c13e3c9dd544d3706a2a26c02b2b43c993b62fcd8011bcc0adb3", - "sha256:ed80a1628cee19f5cfc6bb74e173f1b4189eb532e705e2a13e3250312a62e0c9", - "sha256:ee3e38a6cc050a8830089f79cbec8a3878ec2fe5160cdb2dc8ccb6def8552658" + "sha256:1893d425ef4fb4f129ee8ef72226836619c2950dd0559bba022b0818c63a7b60", + "sha256:1a410dd4d0adcc86b4c71d1317ba2ea2c92babaf5b83321e4bde2514525544d5", + "sha256:1f2b0665c57358ce9786f06f5475bc083fea9d81ecc0efa4733fd0c320940a37", + "sha256:1f8eca9d294a4f194ce9df0d97d19b5598f310950d3ac3dd6e8d25ae456d4c8a", + "sha256:27e49268735b3c27310883012ab3bd86ea0a96dcab90fe3feb682472e30c90f3", + "sha256:28704c71afdb79c3f215c90231e41c52b056ea880b6be6cee035c6149d658ed1", + "sha256:2ac0bd7c206bb6df78ef9e8ad27cc1346f2b41b1fef610395607319cdab89bc1", + "sha256:2af1a29fd14fc0a87fb6ed762d3e1ae5694dcde22372eebba50e9e5be47af03c", + "sha256:3a048865c828389cb06c0bebf8a883cec3ae58ad3e366bcc38c61d8455a3138f", + "sha256:441024df19253bb108d3a8a5de7a186003d68564084576fecf7333a441271ef7", + "sha256:56fb3f40fc3deecf6e518303c7533f5e2a722e377b12507f6de891583f1b48aa", + "sha256:619d63fa5be69f89ff3a93e165e602c08ed8da402ca42b99cd59a8ec115673e1", + "sha256:74535e955359d79d126885e642d3683616e6d9ab3aae0e7dcccd043bd5a3ff4f", + "sha256:76a2743402b794629a955d96ea2e240bd0e903aa26e02e93cd2d57b33900962b", + "sha256:83cf8bc60d9c613b66a4c018051873d6273d9e45d040eed06d6a96241bd8ec01", + "sha256:920a4bda7daa47545c3201a3292e99300ba81ca26b7569575bd086c865889090", + "sha256:9e99c1713e4436d2563f5828c8910e5ff25abd6ce999e75f15c15d81d41980b6", + "sha256:a5bd9e8656d07cae89ac464ee4bcb6f1b9cecbedc3bf1334683bed3d5afd39ba", + "sha256:ad0150536469fa4b693531e497ffe220d5b6cd76ad2eda474a5e641ee204bbb6", + "sha256:af4b5c7ba60206759a1d99811b5938ca666ea9562a1052b410637bb96ff97512", + "sha256:c7bd98813d34bfa9b464cf8122e7d4bec0a5a427399094d2c17dd5f70d59bc61", + "sha256:ceaa9268d81205876bedb1069f9feab3eccddd4b90d9a45d06a0df592a04cae9", + "sha256:cf05e6ff677b9655c6e9511d02e9cc55e730c4e430b7a54af9c28912294605a4", + "sha256:d0fb5f2b513556c2abb578c1066f5f467d729f2eb689bc2db0739daf81c6bb7e", + "sha256:d6ae890798a3560688b441ef086bb66e87af6b400a92749a18b856a134fc0318", + "sha256:e5aed2a700a18c194c39c266900d41f3db0c1ebe6b8a0834b9995c835d2ca66e", + "sha256:e722755d995035dd32177a9c633d158f2ec604f2a358b545bba5bed53ab25bca", + "sha256:ed91c3ccfc23398e7aa9715abf679d5c163394b8cad994f34f156d57a7c163dc" ], - "markers": "python_version >= '3.6'", - "version": "==2.1.9" - }, - "zipp": { - "hashes": [ - "sha256:56bf8aadb83c24db6c4b577e13de374ccfb67da2078beba1d037c17980bf43ad", - "sha256:c4f6e5bbf48e74f7a38e7cc5b0480ff42b0ae5178957d564d18932525d5cf099" - ], - "markers": "python_version >= '3.7'", - "version": "==3.8.0" + "markers": "python_full_version >= '3.6.0'", + "version": "==2.2.0" } }, "develop": {} diff --git a/docs/README.md b/docs/README.md index fcf043f56..3da3d1967 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,33 +2,27 @@ You don't need to build and test the docs as long as you make sure the syntax is correct. But in case you do want to build the docs, feel free to do so. -You'll need to install mkdocs for which you can check the [mkdocs installation guide](https://www.mkdocs.org/#installation). Generally it's best to install it using `pip`. You'll also need to install the correct dependencies. +```sh +# Make sure you're in the same directory as this README +# From the root of the Akkoma repo, you'll need to do +cd docs -### Example using a Debian based distro +# Optionally use a virtual environment +python3 -m venv venv +source venv/bin/activate -#### 1. Install pipenv and dependencies +# Install dependencies +pip install -r requirements.txt -```shell -pip install pipenv -pipenv sync +# Run an http server who rebuilds when files change +# Accessable on http://127.0.0.1:8000 +mkdocs serve + +# Build the docs +# The static html pages will have been created in the folder "site" +# You can serve them from a server by pointing your server software (nginx, apache...) to this location +mkdocs build + +# To get out of the virtual environment, you do +deactivate ``` - -#### 2. (Optional) Activate the virtual environment - -Since dependencies are installed in a virtual environment, you can't use them directly. To use them you should either prefix the command with `pipenv run`, or activate the virtual environment for current shell by executing `pipenv shell` once. - -#### 3. Build the docs using the script - -```shell -[pipenv run] make all -``` - -#### 4. Serve the files - -A folder `site` containing the static html pages will have been created. You can serve them from a server by pointing your server software (nginx, apache...) to this location. During development, you can run locally with - -```shell -[pipenv run] mkdocs serve -``` - -This handles setting up an http server and rebuilding when files change. You can then access the docs on diff --git a/docs/docs/administration/CLI_tasks/config.md b/docs/docs/administration/CLI_tasks/config.md index a0199d06f..31e5af401 100644 --- a/docs/docs/administration/CLI_tasks/config.md +++ b/docs/docs/administration/CLI_tasks/config.md @@ -155,3 +155,51 @@ This forcibly removes all saved values in the database. ```sh mix pleroma.config [--force] reset ``` + +## Dumping specific configuration values to JSON + +If you want to bulk-modify configuration values (for example, for MRF modifications), +it may be easier to dump the values to JSON and then modify them in a text editor. + +=== "OTP" + + ```sh + ./bin/pleroma_ctl config dump_to_file group key path + # For example, to dump the MRF simple configuration: + ./bin/pleroma_ctl config dump_to_file pleroma mrf_simple /tmp/mrf_simple.json + ``` + +=== "From Source" + + ```sh + mix pleroma.config dump_to_file group key path + # For example, to dump the MRF simple configuration: + mix pleroma.config dump_to_file pleroma mrf_simple /tmp/mrf_simple.json + ``` + +## Loading specific configuration values from JSON + +**Note:** This will overwrite any existing value in the database, and can +cause crashes if you do not have exactly the correct formatting. + +Once you have modified the JSON file, you can load it back into the database. + +=== "OTP" + + ```sh + ./bin/pleroma_ctl config load_from_file path + # For example, to load the MRF simple configuration: + ./bin/pleroma_ctl config load_from_file /tmp/mrf_simple.json + ``` + +=== "From Source" + + ```sh + mix pleroma.config load_from_file path + # For example, to load the MRF simple configuration: + mix pleroma.config load_from_file /tmp/mrf_simple.json + ``` + +**NOTE** an instance reboot is needed for many changes to take effect, +you may want to visit `/api/v1/pleroma/admin/restart` on your instance +to soft-restart the instance. diff --git a/docs/docs/administration/CLI_tasks/database.md b/docs/docs/administration/CLI_tasks/database.md index 8b2ab93e6..3d7424d1c 100644 --- a/docs/docs/administration/CLI_tasks/database.md +++ b/docs/docs/administration/CLI_tasks/database.md @@ -21,16 +21,18 @@ Replaces embedded objects with references to them in the `objects` table. Only n mix pleroma.database remove_embedded_objects [option ...] ``` - ### Options - `--vacuum` - run `VACUUM FULL` after the embedded objects are replaced with their references ## Prune old remote posts from the database -This will prune remote posts older than 90 days (configurable with [`config :pleroma, :instance, remote_post_retention_days`](../../configuration/cheatsheet.md#instance)) from the database, they will be refetched from source when accessed. +This will prune remote posts older than 90 days (configurable with [`config :pleroma, :instance, remote_post_retention_days`](../../configuration/cheatsheet.md#instance)) from the database. Pruned posts may be refetched in some cases. + +!!! note + The disk space will only be reclaimed after a proper vacuum. By default Postgresql does this for you on a regular basis, but if your instance has been running for a long time and there are many rows deleted, it may be advantageous to use `VACUUM FULL` (e.g. by using the `--vacuum` option). !!! danger - The disk space will only be reclaimed after `VACUUM FULL`. You may run out of disk space during the execution of the task or vacuuming if you don't have about 1/3rds of the database size free. + You may run out of disk space during the execution of the task or vacuuming if you don't have about 1/3rds of the database size free. Vacuum causes a substantial increase in I/O traffic, and may lead to a degraded experience while it is running. === "OTP" @@ -45,7 +47,11 @@ This will prune remote posts older than 90 days (configurable with [`config :ple ``` ### Options -- `--vacuum` - run `VACUUM FULL` after the objects are pruned + +- `--keep-threads` - Don't prune posts when they are part of a thread where at least one post has seen local interaction (e.g. one of the posts is a local post, or is favourited by a local user, or has been repeated by a local user...). It also wont delete posts when at least one of the posts in that thread is kept (e.g. because one of the posts has seen recent activity). +- `--keep-non-public` - Keep non-public posts like DM's and followers-only, even if they are remote. +- `--prune-orphaned-activities` - Also prune orphaned activities afterwards. Activities are things like Like, Create, Announce, Flag (aka reports)... They can significantly help reduce the database size. +- `--vacuum` - Run `VACUUM FULL` after the objects are pruned. This should not be used on a regular basis, but is useful if your instance has been running for a long time before pruning. ## Create a conversation for all existing DMs @@ -93,6 +99,9 @@ Can be safely re-run ## Vacuum the database +!!! note + By default Postgresql has an autovacuum deamon running. While the tasks described here can help in some cases, they shouldn't be needed on a regular basis. See [the Postgresql docs on vacuuming](https://www.postgresql.org/docs/current/sql-vacuum.html) for more information on this. + ### Analyze Running an `analyze` vacuum job can improve performance by updating statistics used by the query planner. **It is safe to cancel this.** @@ -159,3 +168,23 @@ Change `default_text_search_config` for database and (if necessary) text_search_ ``` See [PostgreSQL documentation](https://www.postgresql.org/docs/current/textsearch-configuration.html) and `docs/configuration/howto_search_cjk.md` for more detail. + +## Pruning old activities + +Over time, transient `Delete` activities and `Tombstone` objects +can accumulate in your database, inflating its size. This is not ideal. +There is a periodic task to prune these transient objects, +but on first run this may take a while on older instances to catch up +to the current day. + +=== "OTP" + + ```sh + ./bin/pleroma_ctl database prune_task + ``` + +=== "From Source" + + ```sh + mix pleroma.database prune_task + ``` diff --git a/docs/docs/administration/CLI_tasks/diagnostics.md b/docs/docs/administration/CLI_tasks/diagnostics.md new file mode 100644 index 000000000..25572da8a --- /dev/null +++ b/docs/docs/administration/CLI_tasks/diagnostics.md @@ -0,0 +1,30 @@ +# Diagnostics + +A few tasks to help with debugging, troubleshooting, and diagnosing problems. + +They mostly relate to common postgres queries. + +## Home timeline query plan + +This task will print a query plan for the home timeline of a given user. + +=== "OTP" + + `./bin/pleroma_ctl diagnostics home_timeline ` + +=== "From Source" + + `mix pleroma.diagnostics home_timeline ` + +## User timeline query plan + +This task will print a query plan for the user timeline of a given user, +from the perspective of another given user. + +=== "OTP" + + `./bin/pleroma_ctl diagnostics user_timeline ` + +=== "From Source" + + `mix pleroma.diagnostics user_timeline ` \ No newline at end of file diff --git a/docs/docs/administration/CLI_tasks/frontend.md b/docs/docs/administration/CLI_tasks/frontend.md index 1fadc921f..5d0c7147a 100644 --- a/docs/docs/administration/CLI_tasks/frontend.md +++ b/docs/docs/administration/CLI_tasks/frontend.md @@ -21,29 +21,28 @@ Currently, known `` values are: - [admin-fe](https://akkoma.dev/AkkomaGang/admin-fe) - [mastodon-fe](https://akkoma.dev/AkkomaGang/masto-fe) - [pleroma-fe](https://akkoma.dev/AkkomaGang/pleroma-fe) -- [soapbox-fe](https://gitlab.com/soapbox-pub/soapbox-fe) You can still install frontends that are not configured, see below. -## Example installations for a known frontend +## Example installations for a known frontend (Stable-Version) For a frontend configured under the `available` key, it's enough to install it by name. === "OTP" ```sh - ./bin/pleroma_ctl frontend install pleroma-fe + ./bin/pleroma_ctl frontend install pleroma-fe --ref stable ``` === "From Source" ```sh - mix pleroma.frontend install pleroma-fe + mix pleroma.frontend install pleroma-fe --ref stable ``` This will download the latest build for the pre-configured `ref` and install it. It can then be configured as the one of the served frontends in the config file (see `primary` or `admin`). -You can override any of the details. To install a Pleroma-FE build from a different URL, you could do this: +You can override any of the details. To install an Akkoma-FE build from a different URL, you could do this: === "OTP" diff --git a/docs/docs/administration/CLI_tasks/robots_txt.md b/docs/docs/administration/CLI_tasks/robots_txt.md index 6cb9fd673..de27e5dca 100644 --- a/docs/docs/administration/CLI_tasks/robots_txt.md +++ b/docs/docs/administration/CLI_tasks/robots_txt.md @@ -11,7 +11,7 @@ If you want to generate a restrictive `robots.txt`, you can run the following mi === "OTP" ```sh - ./bin/pleroma_ctl robots_txt disallow_all + ./bin/pleroma_ctl robotstxt disallow_all ``` === "From Source" diff --git a/docs/docs/administration/CLI_tasks/security.md b/docs/docs/administration/CLI_tasks/security.md new file mode 100644 index 000000000..a0208c4e5 --- /dev/null +++ b/docs/docs/administration/CLI_tasks/security.md @@ -0,0 +1,56 @@ +# Security-related tasks + +{! administration/CLI_tasks/general_cli_task_info.include !} + +!!! danger + Many of these tasks were written in response to a patched exploit. + It is recommended to run those very soon after installing its respective security update. + Over time with db migrations they might become less accurate or be removed altogether. + If you never ran an affected version, there’s no point in running them. + +## Spoofed AcitivityPub objects exploit (2024-03, fixed in 3.11.1) + +### Search for uploaded spoofing payloads + +Scans local uploads for spoofing payloads. +If the instance is not using the local uploader it was not affected. +Attachments wil be scanned anyway in case local uploader was used in the past. + +!!! note + This cannot reliably detect payloads attached to deleted posts. + +=== "OTP" + + ```sh + ./bin/pleroma_ctl security spoof-uploaded + ``` + +=== "From Source" + + ```sh + mix pleroma.security spoof-uploaded + ``` + +### Search for counterfeit posts in database + +Scans all notes in the database for signs of being spoofed. + +!!! note + Spoofs targeting local accounts can be detected rather reliably + (with some restrictions documented in the task’s logs). + Counterfeit posts from remote users cannot. A best-effort attempt is made, but + a thorough attacker can avoid this and it may yield a small amount of false positives. + + Should you find counterfeit posts of local users, let other admins know so they can delete the too. + +=== "OTP" + + ```sh + ./bin/pleroma_ctl security spoof-inserted + ``` + +=== "From Source" + + ```sh + mix pleroma.security spoof-inserted + ``` diff --git a/docs/docs/administration/backup.md b/docs/docs/administration/backup.md index ffc74e1ff..6aa79043c 100644 --- a/docs/docs/administration/backup.md +++ b/docs/docs/administration/backup.md @@ -4,38 +4,57 @@ 1. Stop the Akkoma service. 2. Go to the working directory of Akkoma (default is `/opt/akkoma`) -3. Run `sudo -Hu postgres pg_dump -d --format=custom -f ` (make sure the postgres user has write access to the destination file) -4. Copy `akkoma.pgdump`, `config/prod.secret.exs`, `config/setup_db.psql` (if still available) and the `uploads` folder to your backup destination. If you have other modifications, copy those changes too. +3. Run[¹] `sudo -Hu postgres pg_dump -d akkoma --format=custom -f ` (make sure the postgres user has write access to the destination file) +4. Copy `akkoma.pgdump`, `config/prod.secret.exs`[²], `config/setup_db.psql` (if still available) and the `uploads` folder to your backup destination. If you have other modifications, copy those changes too. 5. Restart the Akkoma service. +[¹]: We assume the database name is "akkoma". If not, you can find the correct name in your config files. +[²]: If you've installed using OTP, you need `config/config.exs` instead of `config/prod.secret.exs`. + ## Restore/Move 1. Optionally reinstall Akkoma (either on the same server or on another server if you want to move servers). 2. Stop the Akkoma service. 3. Go to the working directory of Akkoma (default is `/opt/akkoma`) 4. Copy the above mentioned files back to their original position. -5. Drop the existing database and user if restoring in-place. `sudo -Hu postgres psql -c 'DROP DATABASE ;';` `sudo -Hu postgres psql -c 'DROP USER ;'` -6. Restore the database schema and akkoma postgres role the with the original `setup_db.psql` if you have it: `sudo -Hu postgres psql -f config/setup_db.psql`. - - Alternatively, run the `mix pleroma.instance gen` task again. You can ignore most of the questions, but make the database user, name, and password the same as found in your backup of `config/prod.secret.exs`. Then run the restoration of the akkoma role and schema with of the generated `config/setup_db.psql` as instructed above. You may delete the `config/generated_config.exs` file as it is not needed. - -7. Now restore the Akkoma instance's data into the empty database schema: `sudo -Hu postgres pg_restore -d -v -1 ` -8. If you installed a newer Akkoma version, you should run `mix ecto.migrate`[^1]. This task performs database migrations, if there were any. +5. Drop the existing database and user if restoring in-place[¹]. `sudo -Hu postgres psql -c 'DROP DATABASE akkoma;';` `sudo -Hu postgres psql -c 'DROP USER akkoma;'` +6. Restore the database schema and akkoma role using either of the following options + * You can use the original `setup_db.psql` if you have it[²]: `sudo -Hu postgres psql -f config/setup_db.psql`. + * Or recreate the database and user yourself (replace the password with the one you find in the config file) `sudo -Hu postgres psql -c "CREATE USER akkoma WITH ENCRYPTED PASSWORD ''; CREATE DATABASE akkoma OWNER akkoma;"`. +7. Now restore the Akkoma instance's data into the empty database schema[¹]: `sudo -Hu postgres pg_restore -d akkoma -v -1 ` +8. If you installed a newer Akkoma version, you should run `MIX_ENV=prod mix ecto.migrate`[³]. This task performs database migrations, if there were any. 9. Restart the Akkoma service. 10. Run `sudo -Hu postgres vacuumdb --all --analyze-in-stages`. This will quickly generate the statistics so that postgres can properly plan queries. 11. If setting up on a new server configure Nginx by using the `installation/akkoma.nginx` config sample or reference the Akkoma installation guide for your OS which contains the Nginx configuration instructions. -[^1]: Prefix with `MIX_ENV=prod` to run it using the production config file. +[¹]: We assume the database name and user are both "akkoma". If not, you can find the correct name in your config files. +[²]: You can recreate the `config/setup_db.psql` by running the `mix pleroma.instance gen` task again. You can ignore most of the questions, but make the database user, name, and password the same as found in your backed up config file. This will also create a new `config/generated_config.exs` file which you may delete as it is not needed. +[³]: Prefix with `MIX_ENV=prod` to run it using the production config file. ## Remove 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 Akkoma service `systemctl stop akkoma` -3. Disable akkoma from systemd `systemctl disable akkoma` +3. Disable Akkoma from systemd `systemctl disable akkoma` 4. Remove the files and folders you created during installation (see installation guide). This includes the akkoma, nginx and systemd files and folders. 5. Reload nginx now that the configuration is removed `systemctl reload nginx` -6. Remove the database and database user `sudo -Hu postgres psql -c 'DROP DATABASE ;';` `sudo -Hu postgres psql -c 'DROP USER ;'` +6. Remove the database and database user[¹] `sudo -Hu postgres psql -c 'DROP DATABASE akkoma;';` `sudo -Hu postgres psql -c 'DROP USER akkoma;'` 7. Remove the system user `userdel akkoma` 8. Remove the dependencies that you don't need anymore (see installation guide). Make sure you don't remove packages that are still needed for other software that you have running! + +[¹]: We assume the database name and user are both "akkoma". If not, you can find the correct name in your config files. + +## Docker installations + +If running behind Docker, it is required to run the above commands inside of a running database container. + +### Example +Running `docker compose run --rm db pg_dump <...>` will fail and return: +``` +pg_dump: error: connection to server on socket "/run/postgresql/.s.PGSQL.5432" failed: No such file or directory +Is the server running locally and accepting connections on that socket?" +``` +However, first starting just the database container with `docker compose up db -d`, and then running `docker compose exec db pg_dump -d akkoma --format=custom -f ` will successfully generate a database dump. +Then to make the file accessible on the host system you can run `docker compose cp db: ` to copy if from the container. diff --git a/docs/docs/administration/monitoring.md b/docs/docs/administration/monitoring.md new file mode 100644 index 000000000..b7a748731 --- /dev/null +++ b/docs/docs/administration/monitoring.md @@ -0,0 +1,45 @@ +# Monitoring Akkoma + +If you run akkoma, you may be inclined to collect metrics to ensure your instance is running smoothly, +and that there's nothing quietly failing in the background. + +To facilitate this, akkoma exposes a dashboard and prometheus metrics to be scraped. + +## Prometheus + +See: [export\_prometheus\_metrics](../../configuration/cheatsheet#instance) + +To scrape prometheus metrics, we need an oauth2 token with the `admin:metrics` scope. + +consider using [constanze](https://akkoma.dev/AkkomaGang/constanze) to make this easier - + +```bash +constanze token --client-app --scopes "admin:metrics" --client-name "Prometheus" +``` + +or see `scripts/create_metrics_app.sh` in the source tree for the process to get this token. + +Once you have your token of the form `Bearer $ACCESS_TOKEN`, you can use that in your prometheus config: + +```yaml +- job_name: akkoma + scheme: https + authorization: + credentials: $ACCESS_TOKEN # this should have the bearer prefix removed + metrics_path: /api/v1/akkoma/metrics + static_configs: + - targets: + - example.com +``` + +## Dashboard + +Administrators can access a live dashboard under `/phoenix/live_dashboard` +giving an overview of uptime, software versions, database stats and more. + +The dashboard also includes a variation of the prometheus metrics, however +they do not exactly match due to respective limitations of the dashboard +and the prometheus exporter. +Even more important, the dashboard collects metrics locally in the browser +only while the page is open and cannot give a view on their past history. +For proper monitoring it is recommended to set up prometheus. diff --git a/docs/docs/administration/updating.md b/docs/docs/administration/updating.md index 2d9e77075..94bddfb6c 100644 --- a/docs/docs/administration/updating.md +++ b/docs/docs/administration/updating.md @@ -1,17 +1,36 @@ # Updating your instance -You should **always check the [release notes/changelog](https://akkoma.dev/AkkomaGang/akkoma/src/branch/develop/CHANGELOG.md)** in case there are config deprecations, special update steps, etc. +You should **always check the [release notes/changelog](https://akkoma.dev/AkkomaGang/akkoma/src/branch/stable/CHANGELOG.md)** in case there are config deprecations, special update steps, etc. Besides that, doing the following is generally enough: +## Switch to the akkoma user +```sh +# Using sudo +sudo -su akkoma + +# Using doas +doas -su akkoma + +# Using su +su -s "$SHELL" akkoma +``` ## For OTP installations - ```sh -# Download the new release -su akkoma -s $SHELL -lc "./bin/pleroma_ctl update" +# Download latest stable release +./bin/pleroma_ctl update --branch stable -# Migrate the database, you are advised to stop the instance before doing that -su akkoma -s $SHELL -lc "./bin/pleroma_ctl migrate" +# Stop akkoma +./bin/pleroma stop # or using the system service manager (e.g. systemctl stop akkoma) + +# Run database migrations +./bin/pleroma_ctl migrate + +# Start akkoma +./bin/pleroma daemon # or using the system service manager (e.g. systemctl start akkoma) + +# Update frontend(s). See Frontend Configuration doc for more information. +./bin/pleroma_ctl frontend install pleroma-fe --ref stable ``` If you selected an alternate flavour on installation, @@ -19,13 +38,30 @@ you _may_ need to specify `--flavour`, in the same way as [when installing](../../installation/otp_en#detecting-flavour). ## For from source installations (using git) +Run as the `akkoma` user: -1. Go to the working directory of Akkoma (default is `/opt/akkoma`) -2. Run `git pull` [^1]. This pulls the latest changes from upstream. -3. Run `mix deps.get` [^1]. This pulls in any new dependencies. -4. Stop the Akkoma service. -5. Run `mix ecto.migrate` [^1] [^2]. This task performs database migrations, if there were any. -6. Start the Akkoma service. +```sh +# fetch changes +git fetch +# check out the latest tag +git checkout $(git tag -l | grep -v 'rc[0-9]*$' | sort -V | tail -n 1) -[^1]: Depending on which install guide you followed (for example on Debian/Ubuntu), you want to run `git` and `mix` tasks as `akkoma` user by adding `sudo -Hu akkoma` before the command. -[^2]: Prefix with `MIX_ENV=prod` to run it using the production config file. +# Run with production configuration +export MIX_ENV=prod + +# Download and compile dependencies +mix deps.get +mix compile + +# Stop akkoma (replace with your system service manager's equivalent if different) +sudo systemctl stop akkoma + +# Run database migrations +mix ecto.migrate + +# Start akkoma (replace with your system service manager's equivalent if different) +sudo systemctl start akkoma + +# Update Akkoma-FE frontend to latest stable. For other Frontends see Frontend Configuration doc for more information. +mix pleroma.frontend install pleroma-fe --ref stable +``` diff --git a/docs/docs/clients.md b/docs/docs/clients.md index 5650ea236..1452b9505 100644 --- a/docs/docs/clients.md +++ b/docs/docs/clients.md @@ -1,21 +1,16 @@ -# Pleroma Clients -Note: Additional clients may be working but theses are officially supporting Pleroma. -Feel free to contact us to be added to this list! +# Akkoma Clients +Note: Additional clients may work, but these are known to work with Akkoma. +Apps listed here might not support all of Akkoma's features. + +## Multiplatform +### Kaiteki +- Homepage: +- Source Code: +- Contact: [@kaiteki@fedi.software](https://fedi.software/@Kaiteki) +- Platforms: Web, Windows, Linux, Android +- Features: MastoAPI, Supports multiple backends ## 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) -- Platforms: Linux (GNOME) -- Note(2019-01-28): Not at a pre-alpha stage yet -- Features: MastoAPI - ### Whalebird - Homepage: - Source Code: @@ -30,28 +25,16 @@ Feel free to contact us to be added to this list! - Platforms: Android - Features: MastoAPI, ActivityPub (Client-to-Server) -### Amaroq -- Homepage: -- Source Code: -- Contact: [@eurasierboy@mastodon.social](https://mastodon.social/users/eurasierboy) -- Platforms: iOS -- Features: MastoAPI, No Streaming - ### Fedilab - Homepage: -- Source Code: -- Contact: [@fedilab@framapiaf.org](https://framapiaf.org/users/fedilab) +- Source Code: +- Contact: [@apps@toot.felilab.app](https://toot.fedilab.app/@apps) - Platforms: Android - Features: MastoAPI, Streaming Ready, Moderation, Text Formatting -### Kyclos -- Source Code: -- Platforms: SailfishOS -- Features: MastoAPI, No Streaming - ### Husky -- Source code: -- Contact: [@Husky@enigmatic.observer](https://enigmatic.observer/users/Husky) +- Source code: +- Contact: [@captainepoch@stereophonic.space](https://stereophonic.space/captainepoch) - Platforms: Android - Features: MastoAPI, No Streaming, Emoji Reactions, Text Formatting, FE Stickers @@ -68,37 +51,12 @@ Feel free to contact us to be added to this list! - Platforms: Android - Features: MastoAPI, No Streaming -### Twidere -- Homepage: -- Source Code: -- Contact: -- Platform: Android -- Features: MastoAPI, No Streaming - -### Indigenous -- Homepage: -- Source Code: -- Contact: [@swentel@realize.be](https://realize.be) -- Platforms: Android -- Features: MastoAPI, No Streaming - ## Alternative Web Interfaces -### Brutaldon -- Homepage: -- Source Code: -- Contact: [@gcupc@glitch.social](https://glitch.social/users/gcupc) -- Features: MastoAPI, No Streaming - -### Halcyon -- Source Code: -- Contact: [@halcyon@social.csswg.org](https://social.csswg.org/users/halcyon) -- Features: MastoAPI, Streaming Ready - ### Pinafore +- Note: Pinafore is unmaintained (See [the author's original article](https://nolanlawson.com/2023/01/09/retiring-pinafore/) for details) - Homepage: - Source Code: - Contact: [@pinafore@mastodon.technology](https://mastodon.technology/users/pinafore) -- Note: Pleroma support is a secondary goal - Features: MastoAPI, No Streaming ### Sengi diff --git a/docs/docs/configuration/cheatsheet.md b/docs/docs/configuration/cheatsheet.md index 8c402049b..004f6cf70 100644 --- a/docs/docs/configuration/cheatsheet.md +++ b/docs/docs/configuration/cheatsheet.md @@ -33,7 +33,8 @@ To add configuration to your config file, you can copy it from the base config. * `federation_incoming_replies_max_depth`: Max. depth of reply-to activities fetching on incoming federation, to prevent out-of-memory situations while fetching very long threads. If set to `nil`, threads of any depth will be fetched. Lower this value if you experience out-of-memory crashes. * `federation_reachability_timeout_days`: Timeout (in days) of each external federation target being unreachable prior to pausing federating to it. * `allow_relay`: Permits remote instances to subscribe to all public posts of your instance. This may increase the visibility of your instance. -* `public`: Makes the client API in authenticated mode-only except for user-profiles. Useful for disabling the Local Timeline and The Whole Known Network. Note that there is a dependent setting restricting or allowing unauthenticated access to specific resources, see `restrict_unauthenticated` for more details. +* `public`: Allows unauthenticated access to public resources on your instance. This is essentially used as the default value for `:restrict_unauthenticated`. + See `restrict_unauthenticated` for more details. * `quarantined_instances`: *DEPRECATED* ActivityPub instances where activities will not be sent. They can still reach there via other means, we just won't send them. * `allowed_post_formats`: MIME-type list of formats allowed to be posted (transformed into HTML). * `extended_nickname_format`: Set to `true` to use extended local nicknames format (allows underscores/dashes). This will break federation with @@ -59,7 +60,9 @@ To add configuration to your config file, you can copy it from the base config. * `cleanup_attachments`: Remove attachments along with statuses. Does not affect duplicate files and attachments without status. Enabling this will increase load to database when deleting statuses on larger instances. * `show_reactions`: Let favourites and emoji reactions be viewed through the API (default: `true`). * `password_reset_token_validity`: The time after which reset tokens aren't accepted anymore, in seconds (default: one day). -* `local_bubble`: Array of domains representing instances closely related to yours. Used to populate the `bubble` timeline. e.g `['example.com']`, (default: `[]`) +* `local_bubble`: Array of domains representing instances closely related to yours. Used to populate the `bubble` timeline. e.g `["example.com"]`, (default: `[]`) +* `languages`: List of Language Codes used by the instance. This is used to try and set a default language from the frontend. It will try and find the first match between the languages set here and the user's browser languages. It will default to the first language in this setting if there is no match.. (default `["en"]`) +* `export_prometheus_metrics`: Enable prometheus metrics, served at `/api/v1/akkoma/metrics`, requiring the `admin:metrics` oauth scope. ## :database * `improved_hashtag_timeline`: Setting to force toggle / force disable improved hashtags timeline. `:enabled` forces hashtags to be fetched from `hashtags` table for hashtags timeline. `:disabled` forces object-embedded hashtags to be used (slower). Keep it `:auto` for automatic behaviour (it is auto-set to `:enabled` [unless overridden] when HashtagsTableMigrator completes). @@ -101,29 +104,60 @@ To add configuration to your config file, you can copy it from the base config. ## Message rewrite facility ### :mrf -* `policies`: Message Rewrite Policy, either one or a list. Here are the ones available by default: - * `Pleroma.Web.ActivityPub.MRF.NoOpPolicy`: Doesn’t modify activities (default). - * `Pleroma.Web.ActivityPub.MRF.DropPolicy`: Drops all activities. It generally doesn’t makes sense to use in production. - * `Pleroma.Web.ActivityPub.MRF.SimplePolicy`: Restrict the visibility of activities from certains instances (See [`:mrf_simple`](#mrf_simple)). - * `Pleroma.Web.ActivityPub.MRF.TagPolicy`: Applies policies to individual users based on tags, which can be set using pleroma-fe/admin-fe/any other app that supports Pleroma Admin API. For example it allows marking posts from individual users nsfw (sensitive). - * `Pleroma.Web.ActivityPub.MRF.SubchainPolicy`: Selectively runs other MRF policies when messages match (See [`:mrf_subchain`](#mrf_subchain)). - * `Pleroma.Web.ActivityPub.MRF.RejectNonPublic`: Drops posts with non-public visibility settings (See [`:mrf_rejectnonpublic`](#mrf_rejectnonpublic)). - * `Pleroma.Web.ActivityPub.MRF.EnsureRePrepended`: Rewrites posts to ensure that replies to posts with subjects do not have an identical subject and instead begin with re:. - * `Pleroma.Web.ActivityPub.MRF.AntiLinkSpamPolicy`: Rejects posts from likely spambots by rejecting posts from new users that contain links. - * `Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy`: Crawls attachments using their MediaProxy URLs so that the MediaProxy cache is primed. - * `Pleroma.Web.ActivityPub.MRF.MentionPolicy`: Drops posts mentioning configurable users. (See [`:mrf_mention`](#mrf_mention)). - * `Pleroma.Web.ActivityPub.MRF.VocabularyPolicy`: Restricts activities to a configured set of vocabulary. (See [`:mrf_vocabulary`](#mrf_vocabulary)). - * `Pleroma.Web.ActivityPub.MRF.ObjectAgePolicy`: Rejects or delists posts based on their age when received. (See [`:mrf_object_age`](#mrf_object_age)). - * `Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicy`: Sets a default expiration on all posts made by users of the local instance. Requires `Pleroma.Workers.PurgeExpiredActivity` to be enabled for processing the scheduled delections. - * `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)). * `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. * `transparency_obfuscate_domains`: Show domains with `*` in the middle, to censor them if needed. For example, `ridingho.me` will show as `rid*****.me` +* `policies`: Message Rewrite Policy, either one or a list. Here are the ones available by default: + * `Pleroma.Web.ActivityPub.MRF.NoOpPolicy`: Doesn’t modify activities (default). + * `Pleroma.Web.ActivityPub.MRF.DropPolicy`: Drops all activities. It generally doesn’t makes sense to use in production. + * `Pleroma.Web.ActivityPub.MRF.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. + (See [`:mrf_activity_expiration`](#mrf_activity_expiration)) + * `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.AntiLinkSpamPolicy`: Rejects posts from likely spambots by rejecting posts from new users that contain links. + * `Pleroma.Web.ActivityPub.MRF.EnsureRePrepended`: Rewrites posts to ensure that replies to posts with subjects do not have an identical subject and instead begin with re:. + * `Pleroma.Web.ActivityPub.MRF.ForceBotUnlistedPolicy`: Makes all bot posts to disappear from public timelines. + * `Pleroma.Web.ActivityPub.MRF.HellthreadPolicy`: Blocks messages with too many mentions. + (See [`mrf_hellthread`](#mrf_hellthread)) + * `Pleroma.Web.ActivityPub.MRF.KeywordPolicy`: Rejects or removes from the federated timeline or replaces keywords. (See [`:mrf_keyword`](#mrf_keyword)). + * `Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy`: Crawls attachments using their MediaProxy URLs so that the MediaProxy cache is primed. + * `Pleroma.Web.ActivityPub.MRF.MentionPolicy`: Drops posts mentioning configurable users. (See [`:mrf_mention`](#mrf_mention)). + * `Pleroma.Web.ActivityPub.MRF.NoEmptyPolicy`: Drops local activities which have no actual content. + (e.g. no attachments and only consists of mentions) + * `Pleroma.Web.ActivityPub.MRF.NoPlaceholderTextPolicy`: Strips content placeholders from posts + (such as the dot from mastodon) + * `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.RejectNewlyCreatedAccountNotesPolicy`: Rejects posts of users the server only recently learned about for a while. Great to block spam accounts. (See [`:mrf_reject_newly_created_account_notes`](#mrf_reject_newly_created_account_notes)) + * `Pleroma.Web.ActivityPub.MRF.RejectNonPublic`: Drops posts with non-public visibility settings (See [`:mrf_rejectnonpublic`](#mrf_rejectnonpublic)). + * `Pleroma.Web.ActivityPub.MRF.SimplePolicy`: Restrict the visibility of activities from certains instances (See [`:mrf_simple`](#mrf_simple)). + * `Pleroma.Web.ActivityPub.MRF.StealEmojiPolicy`: Steals all eligible emoji encountered in posts from remote instances + (See [`:mrf_steal_emoji`](#mrf_steal_emoji)) + * `Pleroma.Web.ActivityPub.MRF.SubchainPolicy`: Selectively runs other MRF policies when messages match (See [`:mrf_subchain`](#mrf_subchain)). + * `Pleroma.Web.ActivityPub.MRF.TagPolicy`: Applies policies to individual users based on tags, which can be set using pleroma-fe/admin-fe/any other app that supports Pleroma Admin API. For example it allows marking posts from individual users nsfw (sensitive). + * `Pleroma.Web.ActivityPub.MRF.UserAllowListPolicy`: Drops all posts except from users specified in a list. + (See [`:mrf_user_allowlist`](#mrf_user_allowlist)) + * `Pleroma.Web.ActivityPub.MRF.VocabularyPolicy`: Restricts activities to a configured set of vocabulary. (See [`:mrf_vocabulary`](#mrf_vocabulary)). + +Additionally the following MRFs will *always* be aplied and cannot be disabled: + +* `Pleroma.Web.ActivityPub.MRF.DirectMessageDisabledPolicy`: Strips users limiting who can send them DMs from the recipients of non-eligible DMs +* `Pleroma.Web.ActivityPub.MRF.HashtagPolicy`: Depending on a post’s hashtags it can be rejected, get its sensitive flags force-enabled or removed from the global timeline + (See [`:mrf_hashtag`](#mrf_hashtag)) +* `Pleroma.Web.ActivityPub.MRF.InlineQuotePolicy`: Append a link to a post that quotes another post with the link to the quoted post, to ensure that software that does not understand quotes can have full context. + (See [`:mrf_inline_quote`](#mrf_inline_quote)) +* `Pleroma.Web.ActivityPub.MRF.NormalizeMarkup`: Pass inbound HTML through a scrubber to make sure it doesn't have anything unusual in it. + (See [`:mrf_normalize_markup`](#mrf_normalize_markup)) + ## Federation +### :activitypub +* `unfollow_blocked`: Whether blocks result in people getting unfollowed +* `outgoing_blocks`: Whether to federate blocks to other instances +* `blockers_visible`: Whether a user can see the posts of users who blocked them +* `deny_follow_blocked`: Whether to disallow following an account that has blocked the user in question +* `sign_object_fetches`: Sign object fetches with HTTP signatures +* `authorized_fetch_mode`: Require HTTP signatures for AP fetches +* `max_collection_objects`: The maximum number of objects to fetch from a remote AP collection. + ### MRF policies !!! note @@ -139,6 +173,7 @@ To add configuration to your config file, you can copy it from the base config. * `report_removal`: List of instances to reject reports from and the reason for doing so. * `avatar_removal`: List of instances to strip avatars from and the reason for doing so. * `banner_removal`: List of instances to strip banners from and the reason for doing so. +* `background_removal`: List of instances to strip user backgrounds from and the reason for doing so. * `reject_deletes`: List of instances to reject deletions from and the reason for doing so. #### :mrf_subchain @@ -201,7 +236,9 @@ config :pleroma, :mrf_user_allowlist, %{ #### :mrf_steal_emoji * `hosts`: List of hosts to steal emojis from * `rejected_shortcodes`: Regex-list of shortcodes to reject -* `size_limit`: File size limit (in bytes), checked before an emoji is saved to the disk +* `size_limit`: File size limit (in bytes), checked before download if possible (and remote server honest), + otherwise or again checked before saving emoji to the disk +* `download_unknown_size`: whether to download an emoji when the remote server doesn’t report its size in advance #### :mrf_activity_expiration @@ -217,19 +254,24 @@ Notes: - The hashtags in the configuration do not have a leading `#`. - This MRF Policy is always enabled, if you want to disable it you have to set empty lists -#### :mrf_follow_bot +#### :mrf_reject_newly_created_account_notes +After initially encountering an user, all their posts +will be rejected for the configured time (in seconds). +Only drops posts. Follows, reposts, etc. are not affected. -* `follower_nickname`: The name of the bot account to use for following newly discovered users. Using `followbot` or similar is strongly suggested. +* `age`: Time below which to reject (in seconds) +An example: (86400 seconds = 24 hours) -### :activitypub -* `unfollow_blocked`: Whether blocks result in people getting unfollowed -* `outgoing_blocks`: Whether to federate blocks to other instances -* `blockers_visible`: Whether a user can see the posts of users who blocked them -* `deny_follow_blocked`: Whether to disallow following an account that has blocked the user in question -* `sign_object_fetches`: Sign object fetches with HTTP signatures -* `authorized_fetch_mode`: Require HTTP signatures for AP fetches -* `max_collection_objects`: The maximum number of objects to fetch from a remote AP collection. +```elixir +config :pleroma, :mrf_reject_newly_created_account_notes, age: 86400 +``` + +#### :mrf_inline_quote +* `prefix`: what prefix to prepend to quoted URLs + +#### :mrf_normalize_markup +* `scrub_policy`: the scrubbing module to use (by default a built-in HTML sanitiser) ## Pleroma.User @@ -246,11 +288,11 @@ Notes: ### :frontend_configurations -This can be used to configure a keyword list that keeps the configuration data for any kind of frontend. By default, settings for `pleroma_fe` and `masto_fe` are configured. You can find the documentation for `pleroma_fe` configuration into [Pleroma-FE configuration and customization for instance administrators](https://docs-fe.akkoma.dev/stable/CONFIGURATION/#options). +This can be used to configure a keyword list that keeps the configuration data for any kind of frontend. By default, settings for `pleroma_fe` and `masto_fe` are configured. You can find the documentation for `pleroma_fe` configuration into [Akkoma-FE configuration and customization for instance administrators](https://docs-fe.akkoma.dev/stable/CONFIGURATION/#options). Frontends can access these settings at `/api/v1/pleroma/frontend_configurations` -To add your own configuration for Pleroma-FE, use it like this: +To add your own configuration for Akkoma-FE, use it like this: ```elixir config :pleroma, :frontend_configurations, @@ -356,7 +398,8 @@ This section describe PWA manifest instance-specific values. Currently this opti ## :media_proxy * `enabled`: Enables proxying of remote media to the instance’s proxy -* `base_url`: The base URL to access a user-uploaded file. Useful when you want to proxy the media files via another host/CDN fronts. +* `base_url`: The base URL to access a user-uploaded file. + Using a (sub)domain distinct from the instance endpoint is **strongly** recommended. * `proxy_opts`: All options defined in `Pleroma.ReverseProxy` documentation, defaults to `[max_body_length: (25*1_048_576)]`. * `whitelist`: List of hosts with scheme to bypass the mediaproxy (e.g. `https://example.com`) * `invalidation`: options for remove media from cache after delete object: @@ -452,7 +495,6 @@ This will make Akkoma listen on `127.0.0.1` port `8080` and generate urls starti * ``enabled``: Whether the managed content security policy is enabled. * ``sts``: Whether to additionally send a `Strict-Transport-Security` header. * ``sts_max_age``: The maximum age for the `Strict-Transport-Security` header if sent. -* ``ct_max_age``: The maximum age for the `Expect-CT` header if sent. * ``referrer_policy``: The referrer policy to use, either `"same-origin"` or `"no-referrer"`. * ``report_uri``: Adds the specified url to `report-uri` and `report-to` group in CSP header. @@ -523,59 +565,13 @@ Available caches: ### :http +* `receive_timeout`: the amount of time, in ms, to wait for a remote server to respond to a request. (default: `15000`) +* `pool_timeout`: the amount of time, in ms, to wait to check out an HTTP connection from the pool. This likely does not need changing unless your instance is _very_ busy with outbound requests. (default `5000`) * `proxy_url`: an upstream proxy to fetch posts and/or media with, (default: `nil`); for example `http://127.0.0.1:3192`. Does not support SOCKS5 proxy, only http(s). * `send_user_agent`: should we include a user agent with HTTP requests? (default: `true`) * `user_agent`: what user agent should we use? (default: `:default`), must be string or `:default` * `adapter`: array of adapter options -### :hackney_pools - -Advanced. Tweaks Hackney (http client) connections pools. - -There's three pools used: - -* `:federation` for the federation jobs. - You may want this pool max_connections to be at least equal to the number of federator jobs + retry queue jobs. -* `:media` for rich media, media proxy -* `:upload` for uploaded media (if using a remote uploader and `proxy_remote: true`) - -For each pool, the options are: - -* `max_connections` - how much connections a pool can hold -* `timeout` - retention duration for connections - - -### :connections_pool - -*For `gun` adapter* - -Settings for HTTP connection pool. - -* `:connection_acquisition_wait` - Timeout to acquire a connection from pool.The total max time is this value multiplied by the number of retries. -* `connection_acquisition_retries` - Number of attempts to acquire the connection from the pool if it is overloaded. Each attempt is timed `:connection_acquisition_wait` apart. -* `:max_connections` - Maximum number of connections in the pool. -* `:connect_timeout` - Timeout to connect to the host. -* `:reclaim_multiplier` - Multiplied by `:max_connections` this will be the maximum number of idle connections that will be reclaimed in case the pool is overloaded. - -### :pools - -*For `gun` adapter* - -Settings for request pools. These pools are limited on top of `:connections_pool`. - -There are four pools used: - -* `:federation` for the federation jobs. You may want this pool's max_connections to be at least equal to the number of federator jobs + retry queue jobs. -* `:media` - for rich media, media proxy. -* `:upload` - for proxying media when a remote uploader is used and `proxy_remote: true`. -* `:default` - for other requests. - -For each pool, the options are: - -* `:size` - limit to how much requests can be concurrently executed. -* `:recv_timeout` - timeout while `gun` will wait for response -* `:max_waiting` - limit to how much requests can be waiting for others to finish, after this is reached, subsequent requests will be dropped. - ## Captcha ### Pleroma.Captcha @@ -604,12 +600,12 @@ the source code is here: [kocaptcha](https://github.com/koto-bank/kocaptcha). Th * `uploader`: Which one of the [uploaders](#uploaders) to use. * `filters`: List of [upload filters](#upload-filters) to use. -* `link_name`: When enabled Akkoma will add a `name` parameter to the url of the upload, for example `https://instance.tld/media/corndog.png?name=corndog.png`. This is needed to provide the correct filename in Content-Disposition headers when using filters like `Pleroma.Upload.Filter.Dedupe` -* `base_url`: The base URL to access a user-uploaded file. Useful when you want to host the media files via another domain or are using a 3rd party S3 provider. +* `link_name`: When enabled Akkoma will add a `name` parameter to the url of the upload, for example `https://instance.tld/media/corndog.png?name=corndog.png`. This is needed to provide the correct filename in Content-Disposition headers +* `base_url`: The base URL to access a user-uploaded file; MUST be configured explicitly. + Using a (sub)domain distinct from the instance endpoint is **strongly** recommended. A good value might be `https://media.myakkoma.instance/media/`. * `proxy_remote`: If you're using a remote uploader, Akkoma will proxy media requests instead of redirecting to it. * `proxy_opts`: Proxy options, see `Pleroma.ReverseProxy` documentation. * `filename_display_max_length`: Set max length of a filename to display. 0 = no limit. Default: 30. -* `default_description`: Sets which default description an image has if none is set explicitly. Options: nil (default) - Don't set a default, :filename - use the filename of the file, a string (e.g. "attachment") - Use this string !!! warning `strip_exif` has been replaced by `Pleroma.Upload.Filter.Mogrify`. @@ -646,29 +642,37 @@ config :ex_aws, :s3, ### Upload filters -#### Pleroma.Upload.Filter.AnonymizeFilename - -This filter replaces the filename (not the path) of an upload. For complete obfuscation, add -`Pleroma.Upload.Filter.Dedupe` before AnonymizeFilename. - -* `text`: Text to replace filenames in links. If empty, `{random}.extension` will be used. You can get the original filename extension by using `{extension}`, for example `custom-file-name.{extension}`. - #### Pleroma.Upload.Filter.Dedupe +**Always** active; cannot be turned off. +Renames files to their hash and prevents duplicate files filling up the disk. No specific configuration. +#### Pleroma.Upload.Filter.AnonymizeFilename + +This filter replaces the declared filename (not the path) of an upload. + +* `text`: Text to replace filenames in links. If empty, `{random}.extension` will be used. You can get the original filename extension by using `{extension}`, for example `custom-file-name.{extension}`. + #### Pleroma.Upload.Filter.Exiftool.StripLocation This filter only strips the GPS and location metadata with Exiftool leaving color profiles and attributes intact. No specific configuration. + #### Pleroma.Upload.Filter.Exiftool.ReadDescription This filter reads the ImageDescription and iptc:Caption-Abstract fields with Exiftool so clients can prefill the media description field. No specific configuration. +#### Pleroma.Upload.Filter.OnlyMedia + +This filter rejects uploads that are not identified with Content-Type matching audio/\*, image/\*, or video/\* + +No specific configuration. + #### Pleroma.Upload.Filter.Mogrify * `args`: List of actions for the `mogrify` command like `"strip"` or `["strip", "auto-orient", {"implode", "1"}]`. @@ -839,17 +843,8 @@ config :logger, :ex_syslogger, level: :info, ident: "pleroma", format: "$metadata[$level] $message" - -config :quack, - level: :warn, - meta: [:all], - webhook_url: "https://hooks.slack.com/services/YOUR-API-KEY-HERE" ``` -See the [Quack Github](https://github.com/azohra/quack) for more details - - - ## Database options ### RUM indexing for full text search @@ -1015,6 +1010,15 @@ config :ueberauth, Ueberauth, ] ``` +You may also need to set up your frontend to use oauth logins. For example, for `akkoma-fe`: + +```elixir +config :pleroma, :frontend_configurations, + pleroma_fe: %{ + loginMethod: "token" + } +``` + ## Link parsing ### :uri_schemes @@ -1096,7 +1100,7 @@ config :pleroma, :database_config_whitelist, [ ### :restrict_unauthenticated -Restrict access for unauthenticated users to timelines (public and federated), user profiles and statuses. +Restrict access for unauthenticated users to timelines (public and federated), user profiles and posts. * `timelines`: public and federated timelines * `local`: public timeline @@ -1104,13 +1108,24 @@ Restrict access for unauthenticated users to timelines (public and federated), u * `profiles`: user profiles * `local` * `remote` -* `activities`: statuses +* `activities`: posts * `local` * `remote` -Note: when `:instance, :public` is set to `false`, all `:restrict_unauthenticated` items be effectively set to `true` by default. If you'd like to allow unauthenticated access to specific API endpoints on a private instance, please explicitly set `:restrict_unauthenticated` to non-default value in `config/prod.secret.exs`. +#### When :instance, :public is `true` -Note: setting `restrict_unauthenticated/timelines/local` to `true` has no practical sense if `restrict_unauthenticated/timelines/federated` is set to `false` (since local public activities will still be delivered to unauthenticated users as part of federated timeline). +When your instance is in "public" mode, all public resources (users, posts, timelines) are accessible to unauthenticated users. + +Turning any of the `:restrict_unauthenticated` options to `true` will restrict access to the corresponding resources. + +#### When :instance, :public is `false` + +When `:instance, :public` is set to `false`, all of the `:restrict_unauthenticated` options will effectively be set to `true` by default, +meaning that only authenticated users will be able to access the corresponding resources. + +If you'd like to allow unauthenticated access to specific resources, you can turn these settings to `false`. + +**Note**: setting `restrict_unauthenticated/timelines/local` to `true` has no practical sense if `restrict_unauthenticated/timelines/federated` is set to `false` (since local public activities will still be delivered to unauthenticated users as part of federated timeline). ## Pleroma.Web.ApiSpec.CastAndValidate @@ -1170,7 +1185,7 @@ Each job has these settings: ### Translation Settings Settings to automatically translate statuses for end users. Currently supported -translation services are DeepL and LibreTranslate. +translation services are DeepL and LibreTranslate. The supported command line tool is [Argos Translate](https://github.com/argosopentech/argos-translate). Translations are available at `/api/v1/statuses/:id/translations/:language`, where `language` is the target language code (e.g `en`) @@ -1179,7 +1194,7 @@ Translations are available at `/api/v1/statuses/:id/translations/:language`, whe - `:enabled` - enables translation - `:module` - Sets module to be used - - Either `Pleroma.Akkoma.Translators.DeepL` or `Pleroma.Akkoma.Translators.LibreTranslate` + - Either `Pleroma.Akkoma.Translators.DeepL`, `Pleroma.Akkoma.Translators.LibreTranslate`, or `Pleroma.Akkoma.Translators.ArgosTranslate` ### `:deepl` @@ -1191,3 +1206,9 @@ Translations are available at `/api/v1/statuses/:id/translations/:language`, whe - `:url` - URL of LibreTranslate instance - `:api_key` - API key for LibreTranslate + +### `:argos_translate` + +- `:command_argos_translate` - command for `argos-translate`. Can be the command if it's in your PATH, or the full path to the file (default: `argos-translate`). +- `:command_argospm` - command for `argospm`. Can be the command if it's in your PATH, or the full path to the file (default: `argospm`). +- `:strip_html` - Strip html from the post before translating it (default: `true`). diff --git a/docs/docs/configuration/custom_emoji.md b/docs/docs/configuration/custom_emoji.md index a0a40f294..a883e8bf2 100644 --- a/docs/docs/configuration/custom_emoji.md +++ b/docs/docs/configuration/custom_emoji.md @@ -67,3 +67,29 @@ Priority of tags assigns in emoji.txt and custom.txt: Priority for globs: `special group setting in config.exs > default setting in config.exs` + +## Stealing emoji + +Managing your emoji can be hard work, and you just want to have the cool emoji your friends use? As usual, crime comes to the rescue! + +You can use the `Pleroma.Web.ActivityPub.MRF.StealEmojiPolicy` [Message Rewrite Facility](../configuration/cheatsheet.md#mrf) to automatically add to your instance emoji that messages from specific servers contain. Note that this happens on message processing, so the emoji will be added only after your instance receives some interaction containing emoji _after_ configuring this. + +To activate this you have to [configure](../configuration/cheatsheet.md#mrf_steal_emoji) it in your configuration file. For example if you wanted to steal any emoji that is not related to cinnamon and not larger than about 10K from `coolemoji.space` and `spiceenthusiasts.biz`, you would add the following: +```elixir +config :pleroma, :mrf, + policies: [ + Pleroma.Web.ActivityPub.MRF.StealEmojiPolicy + ] + +config :pleroma, :mrf_steal_emoji, + hosts: [ + "coolemoji.space", + "spiceenthusiasts.biz" + ], + rejected_shortcodes: [ + ".*cinnamon.*" + ], + size_limit: 10000 +``` + +Note that this may not obey emoji licensing restrictions. It's extremely unlikely that anyone will care, but keep this in mind for when Nintendo starts their own instance. diff --git a/docs/docs/configuration/frontend_management.md b/docs/docs/configuration/frontend_management.md index 5e4b9b051..bc5344826 100644 --- a/docs/docs/configuration/frontend_management.md +++ b/docs/docs/configuration/frontend_management.md @@ -26,7 +26,7 @@ config :pleroma, :frontends, } ``` -This would serve the frontend from the the folder at `$instance_static/frontends/pleroma/stable`. You have to copy the frontend into this folder yourself. You can choose the name and ref any way you like, but they will be used by mix tasks to automate installation in the future, the name referring to the project and the ref referring to a commit. +This would serve the frontend from the folder at `$instance_static/frontends/pleroma/stable`. You have to copy the frontend into this folder yourself. You can choose the name and ref any way you like, but they will be used by mix tasks to automate installation in the future, the name referring to the project and the ref referring to a commit. Refer to [the frontend CLI task](../../administration/CLI_tasks/frontend) for how to install the frontend's files diff --git a/docs/docs/configuration/hardening.md b/docs/docs/configuration/hardening.md index 182a54422..f8ba048dd 100644 --- a/docs/docs/configuration/hardening.md +++ b/docs/docs/configuration/hardening.md @@ -17,24 +17,33 @@ This sets the Akkoma application server to only listen to the localhost interfac This sets the `secure` flag on Akkoma’s session cookie. This makes sure, that the cookie is only accepted over encrypted HTTPs connections. This implicitly renames the cookie from `pleroma_key` to `__Host-pleroma-key` which enforces some restrictions. (see [cookie prefixes](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#Cookie_prefixes)) +### `Pleroma.Upload, :uploader, :base_url` + +> Recommended value: *anything on a different domain than the instance endpoint; e.g. https://media.myinstance.net/* + +Uploads are user controlled and (unless you’re running a true single-user +instance) should therefore not be considered trusted. But the domain is used +as a pivilege boundary e.g. by HTTP content security policy and ActivityPub. +Having uploads on the same domain enabled several past vulnerabilities +able to be exploited by malicious users. + ### `:http_security` > Recommended value: `true` This will send additional HTTP security headers to the clients, including: -* `X-XSS-Protection: "1; mode=block"` +* `X-XSS-Protection: "0"` * `X-Permitted-Cross-Domain-Policies: "none"` * `X-Frame-Options: "DENY"` * `X-Content-Type-Options: "nosniff"` -* `X-Download-Options: "noopen"` A content security policy (CSP) will also be set: ```csp content-security-policy: default-src 'none'; - base-uri 'self'; + base-uri 'none'; frame-ancestors 'none'; img-src 'self' data: blob: https:; media-src 'self' https:; @@ -52,19 +61,15 @@ content-security-policy: An additional “Strict transport security” header will be sent with the configured `sts_max_age` parameter. This tells the browser, that the domain should only be accessed over a secure HTTPs connection. -#### `ct_max_age` - -An additional “Expect-CT” header will be sent with the configured `ct_max_age` parameter. This enforces the use of TLS certificates that are published in the certificate transparency log. (see [Expect-CT](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Expect-CT)) - #### `referrer_policy` > Recommended value: `same-origin` -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)) +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)). `no-referrer` can be used if a referrer is not needed for improved privacy. ## systemd -A systemd unit example is provided at `installation/pleroma.service`. +A systemd unit example is provided at `installation/akkoma.service`. ### PrivateTmp diff --git a/docs/docs/configuration/howto_mediaproxy.md b/docs/docs/configuration/howto_mediaproxy.md index 8ad81bdfb..223ad7eed 100644 --- a/docs/docs/configuration/howto_mediaproxy.md +++ b/docs/docs/configuration/howto_mediaproxy.md @@ -6,7 +6,16 @@ With the `mediaproxy` function you can use nginx to cache this content, so users ## Activate it -* Edit your nginx config and add the following location: +* Edit your nginx config and add the following location to your main server block: +``` +location /proxy { + return 404; +} +``` + +* Set up a subdomain for the proxy with its nginx config on the same machine + *(the latter is not strictly required, but for simplicity we’ll assume so)* +* In this subdomain’s server block add ``` location /proxy { proxy_cache akkoma_media_cache; @@ -26,9 +35,9 @@ config :pleroma, :media_proxy, enabled: true, proxy_opts: [ redirect_on_failure: true - ] - #base_url: "https://cache.akkoma.social" + ], + base_url: "https://cache.akkoma.social" ``` -If you want to use a subdomain to serve the files, uncomment `base_url`, change the url and add a comma after `true` in the previous line. +You **really** should use a subdomain to serve proxied files; while we will fix bugs resulting from this, serving arbitrary remote content on your main domain namespace is a significant attack surface. * Restart nginx and Akkoma diff --git a/docs/docs/configuration/howto_theming_your_instance.md b/docs/docs/configuration/howto_theming_your_instance.md index af417aee4..e29143db8 100644 --- a/docs/docs/configuration/howto_theming_your_instance.md +++ b/docs/docs/configuration/howto_theming_your_instance.md @@ -6,7 +6,7 @@ To add a custom theme to your instance, you'll first need to get a custom theme, ### Create your own theme -* You can create your own theme using the Pleroma FE by going to settings (gear on the top right) and choose the Theme tab. Here you have the options to create a personal theme. +* You can create your own theme using the Akkoma FE by going to settings (gear on the top right) and choose the Theme tab. Here you have the options to create a personal theme. * To download your theme, you can do Save preset * If you want to upload a theme to customise it further, you can upload it using Load preset @@ -60,7 +60,7 @@ Example of `my-awesome-theme.json` where we add the name "My Awesome Theme" ### Set as default theme -Now we can set the new theme as default in the [Pleroma FE configuration](https://docs-fe.akkoma.dev/stable/CONFIGURATION). +Now we can set the new theme as default in the [Pleroma FE configuration](https://docs-fe.akkoma.dev/stable/CONFIGURATION/). Example of adding the new theme in the back-end config files ```elixir @@ -71,4 +71,3 @@ config :pleroma, :frontend_configurations, ``` If you added it in the back-end configuration file, you'll need to restart your instance for the changes to take effect. If you don't see the changes, it's probably because the browser has cached the previous theme. In that case you'll want to clear browser caches. Alternatively you can use a private/incognito window just to see the changes. - diff --git a/docs/docs/configuration/i2p.md b/docs/docs/configuration/i2p.md index fecf66a84..ec6266ab7 100644 --- a/docs/docs/configuration/i2p.md +++ b/docs/docs/configuration/i2p.md @@ -155,12 +155,11 @@ server { location / { - add_header X-XSS-Protection "1; mode=block"; + add_header X-XSS-Protection "0"; add_header X-Permitted-Cross-Domain-Policies none; add_header X-Frame-Options DENY; add_header X-Content-Type-Options nosniff; add_header Referrer-Policy same-origin; - add_header X-Download-Options noopen; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; diff --git a/docs/docs/configuration/howto_ejabberd.md b/docs/docs/configuration/integrations/howto_ejabberd.md similarity index 100% rename from docs/docs/configuration/howto_ejabberd.md rename to docs/docs/configuration/integrations/howto_ejabberd.md diff --git a/docs/docs/configuration/howto_mongooseim.md b/docs/docs/configuration/integrations/howto_mongooseim.md similarity index 100% rename from docs/docs/configuration/howto_mongooseim.md rename to docs/docs/configuration/integrations/howto_mongooseim.md diff --git a/docs/docs/configuration/mrf.md b/docs/docs/configuration/mrf.md index f5727290a..0a17b3112 100644 --- a/docs/docs/configuration/mrf.md +++ b/docs/docs/configuration/mrf.md @@ -15,18 +15,6 @@ The MRF provides user-configurable policies. The default policy is `NoOpPolicy`, It is possible to use multiple, active MRF policies at the same time. -## Quarantine Instances - -You have the ability to prevent from private / followers-only messages from federating with specific instances. Which means they will only get the public or unlisted messages from your instance. - -If, for example, you're using `MIX_ENV=prod` aka using production mode, you would open your configuration file located in `config/prod.secret.exs` and edit or add the option under your `:instance` config object. Then you would specify the instance within quotes. - -```elixir -config :pleroma, :instance, - [...] - quarantined_instances: ["instance.example", "other.example"] -``` - ## Using `SimplePolicy` `SimplePolicy` is capable of handling most common admin tasks. @@ -41,12 +29,13 @@ config :pleroma, :mrf, Once `SimplePolicy` is enabled, you can configure various groups in the `:mrf_simple` config object. These groups are: -* `reject`: Servers in this group will have their messages rejected. +* `reject`: Servers in this group will have their messages rejected. Also outbound messages will not be sent to these servers. * `accept`: If not empty, only messages from these instances will be accepted (whitelist federation). * `media_nsfw`: Servers in this group will have the #nsfw tag and sensitive setting injected into incoming messages which contain media. * `media_removal`: Servers in this group will have media stripped from incoming messages. * `avatar_removal`: Avatars from these servers will be stripped from incoming messages. * `banner_removal`: Banner images from these servers will be stripped from incoming messages. +* `background_removal`: User background images from these servers will be stripped from incoming messages. * `report_removal`: Servers in this group will have their reports (flags) rejected. * `federated_timeline_removal`: Servers in this group will have their messages unlisted from the public timelines by flipping the `to` and `cc` fields. * `reject_deletes`: Deletion requests will be rejected from these servers. @@ -73,6 +62,32 @@ config :pleroma, :mrf_simple, The effects of MRF policies can be very drastic. It is important to use this functionality carefully. Always try to talk to an admin before writing an MRF policy concerning their instance. +## Hiding or Obfuscating Policies + +You can opt out of publicly displaying all MRF policies or only hide or obfuscate selected domains. + +To just hide everything set: + +```elixir +config :pleroma, :mrf, + ... + transparency: false, +``` + +To hide or obfuscate only select entries, use: + +```elixir +config :pleroma, :mrf, + ... + transparency_obfuscate_domains: ["handholdi.ng", "badword.com"], + transparency_exclusions: [{"ghost.club", "even a fragment is too spoopy for humans"}] +``` + +## More MRF Policies + +See the [documentation cheatsheet](cheatsheet.md) +for all available MRF policies and their options. + ## Writing your own MRF Policy As discussed above, the MRF system is a modular system that supports pluggable policies. This means that an admin may write a custom MRF policy in Elixir or any other language that runs on the Erlang VM, by specifying the module name in the `policies` config setting. diff --git a/docs/docs/configuration/onion_federation.md b/docs/docs/configuration/onion_federation.md index 499b4a693..e4ae15fd2 100644 --- a/docs/docs/configuration/onion_federation.md +++ b/docs/docs/configuration/onion_federation.md @@ -99,12 +99,11 @@ server { location / { - add_header X-XSS-Protection "1; mode=block"; + add_header X-XSS-Protection "0"; add_header X-Permitted-Cross-Domain-Policies none; add_header X-Frame-Options DENY; add_header X-Content-Type-Options nosniff; add_header Referrer-Policy same-origin; - add_header X-Download-Options noopen; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; diff --git a/docs/docs/configuration/optimizing_beam.md b/docs/docs/configuration/optimisation/optimizing_beam.md similarity index 91% rename from docs/docs/configuration/optimizing_beam.md rename to docs/docs/configuration/optimisation/optimizing_beam.md index bbdc49725..747773f3e 100644 --- a/docs/docs/configuration/optimizing_beam.md +++ b/docs/docs/configuration/optimisation/optimizing_beam.md @@ -25,11 +25,14 @@ Tuning the BEAM requires you provide a config file normally called [vm.args](htt `ExecStart=/usr/bin/elixir --erl '-args_file /opt/akkoma/config/vm.args' -S /usr/bin/mix phx.server` +If using an OTP release, set the `RELEASE_VM_ARGS` environment variable to the path to the vm.args file. + Check your OS documentation to adopt a similar strategy on other platforms. ### Virtual Machine and/or few CPU cores -Disable the busy-waiting. This should generally only be done if you're on a platform that does burst scheduling, like AWS. +Disable the busy-waiting. This should generally be done if you're on a platform that does burst scheduling, like AWS, or if you're running other +services on the same machine. **vm.args:** @@ -39,6 +42,8 @@ Disable the busy-waiting. This should generally only be done if you're on a plat +sbwtdio none ``` +These settings are enabled by default for OTP releases + ### Dedicated Hardware Enable more busy waiting, increase the internal maximum limit of BEAM processes and ports. You can use this if you run on dedicated hardware, but it is not necessary. diff --git a/docs/docs/configuration/optimisation/varnish_cache.md b/docs/docs/configuration/optimisation/varnish_cache.md new file mode 100644 index 000000000..1598354f5 --- /dev/null +++ b/docs/docs/configuration/optimisation/varnish_cache.md @@ -0,0 +1,54 @@ +# Using a Varnish Cache + +Varnish is a layer that sits between your web server and your backend application - +it does something similar to nginx caching, but tends to be optimised for speed over +all else. + +To set up a varnish cache, first you'll need to install varnish. + +This will vary by distribution, and since this is a rather advanced guide, +no copy-paste instructions are provided. It's probably in your distribution's +package manager, though. `apt-get install varnish` and so on. + +Once you have varnish installed, you'll need to configure it to work with akkoma. + +Copy the configuration file to the varnish configuration directory: + + cp installation/akkoma.vcl /etc/varnish/akkoma.vcl + +You may want to check if varnish added a `default.vcl` file to the same directory, +if so you can just remove it without issue. + +Then boot up varnish, probably `systemctl start varnish` or `service varnish start`. + +Now you should be able to `curl -D- localhost:6081` and see a bunch of +akkoma javascript. + +Once that's out of the way, we can point our webserver at varnish. This + +=== "Nginx" + + upstream phoenix { + server 127.0.0.1:6081 max_fails=5 fail_timeout=60s; + } + + +=== "Caddy" + + reverse_proxy 127.0.0.1:6081 + +Now hopefully it all works + +If you get a HTTPS redirect loop, you may need to remove this part of the VCL + +```vcl +if (std.port(server.ip) != 443) { + set req.http.X-Forwarded-Proto = "http"; + set req.http.x-redir = "https://" + req.http.host + req.url; + return (synth(750, "")); +} else { + set req.http.X-Forwarded-Proto = "https"; +} +``` + +This will allow your webserver alone to handle redirects. \ No newline at end of file diff --git a/docs/docs/configuration/postgresql.md b/docs/docs/configuration/postgresql.md index 32ea97fe3..3d5b78c0d 100644 --- a/docs/docs/configuration/postgresql.md +++ b/docs/docs/configuration/postgresql.md @@ -6,6 +6,31 @@ Akkoma performance is largely dependent on performance of the underlying databas [PgTune](https://pgtune.leopard.in.ua) can be used to get recommended settings. Be sure to set "Number of Connections" to 20, otherwise it might produce settings hurtful to database performance. It is also recommended to not use "Network Storage" option. +If your server runs other services, you may want to take that into account. E.g. if you have 4G ram, but 1G of it is already used for other services, it may be better to tell PGTune you only have 3G. In the end, PGTune only provides recomended settings, you can always try to finetune further. + +### Example configurations + +Here are some configuration suggestions for PostgreSQL 10+. + +#### 1GB RAM, 1 CPU +``` +shared_buffers = 256MB +effective_cache_size = 768MB +maintenance_work_mem = 64MB +work_mem = 13107kB +``` + +#### 2GB RAM, 2 CPU +``` +shared_buffers = 512MB +effective_cache_size = 1536MB +maintenance_work_mem = 128MB +work_mem = 26214kB +max_worker_processes = 2 +max_parallel_workers_per_gather = 1 +max_parallel_workers = 2 +``` + ## Disable generic query plans When PostgreSQL receives a query, it decides on a strategy for searching the requested data, this is called a query plan. The query planner has two modes: generic and custom. Generic makes a plan for all queries of the same shape, ignoring the parameters, which is then cached and reused. Custom, on the contrary, generates a unique query plan based on query parameters. @@ -23,26 +48,3 @@ config :pleroma, Pleroma.Repo, ``` A more detailed explaination of the issue can be found at . - -## Example configurations - -Here are some configuration suggestions for PostgreSQL 10+. - -### 1GB RAM, 1 CPU -``` -shared_buffers = 256MB -effective_cache_size = 768MB -maintenance_work_mem = 64MB -work_mem = 13107kB -``` - -### 2GB RAM, 2 CPU -``` -shared_buffers = 512MB -effective_cache_size = 1536MB -maintenance_work_mem = 128MB -work_mem = 26214kB -max_worker_processes = 2 -max_parallel_workers_per_gather = 1 -max_parallel_workers = 2 -``` diff --git a/docs/docs/configuration/static_dir.md b/docs/docs/configuration/static_dir.md index 37e07eb0d..30d7ced40 100644 --- a/docs/docs/configuration/static_dir.md +++ b/docs/docs/configuration/static_dir.md @@ -89,7 +89,23 @@ config :pleroma, :frontend_configurations, Terms of Service will be shown to all users on the registration page. It's the best place where to write down the rules for your instance. You can modify the rules by adding and changing `$static_dir/static/terms-of-service.html`. +## Favicon + +The favicon will display on the frontend, and in the browser tab. + +Place a PNG file at `$static_dir/favicon.png` to change the favicon. Not that this +is _one level above_ where the logo is placed, it should be on the same level as +the `frontends` directory. ## Styling rendered pages To overwrite the CSS stylesheet of the OAuth form and other static pages, you can upload your own CSS file to `instance/static/static.css`. This will completely replace the CSS used by those pages, so it might be a good idea to copy the one from `priv/static/instance/static.css` and make your changes. + +## Overriding pleroma-fe styles + +To overwrite the CSS stylesheet of pleroma-fe, you can put a file at +`$static_dir/static/custom.css` containing your styles. These will be loaded +with the rest of the CSS. + +You will probably have to put `!important` on most/all your styles to override the +default ones, due to the specificity precedence of CSS. \ No newline at end of file diff --git a/docs/docs/configuration/storing_remote_media.md b/docs/docs/configuration/storing_remote_media.md index ebea01339..deb1651b8 100644 --- a/docs/docs/configuration/storing_remote_media.md +++ b/docs/docs/configuration/storing_remote_media.md @@ -6,33 +6,46 @@ as soon as the post is received by your instance. ## Nginx -``` - proxy_cache_path /long/term/storage/path/akkoma-media-cache levels=1:2 - keys_zone=akkoma_media_cache:10m inactive=1y use_temp_path=off; +The following are excerpts from the [suggested nginx config](../../../installation/nginx/akkoma.nginx) that demonstrates the necessary config for the media proxy to work. +A `proxy_cache_path` must be defined, for example: + +``` +proxy_cache_path /long/term/storage/path/akkoma-media-cache levels=1:2 + keys_zone=akkoma_media_cache:10m inactive=1y use_temp_path=off; +``` + +The `proxy_cache_path` must then be configured for use with media proxy paths: + +``` location ~ ^/(media|proxy) { proxy_cache akkoma_media_cache; slice 1m; proxy_cache_key $host$uri$is_args$args$slice_range; proxy_set_header Range $slice_range; - proxy_http_version 1.1; - proxy_cache_valid 206 301 302 304 1h; - proxy_cache_valid 200 1y; - proxy_cache_use_stale error timeout invalid_header updating; + 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_ignore_headers Cache-Control Expires; - proxy_hide_header Cache-Control Expires; - proxy_pass http://127.0.0.1:4000; + proxy_pass http://phoenix; } +} ``` +Ensure that `proxy_http_version 1.1;` is set for the above `location` block. In the suggested config, this is already the case. + ## Akkoma -Add to your `prod.secret.exs`: +### File-based Configuration + +If you're using static file configuration, add the `MediaProxyWarmingPolicy` to your MRF policies. For example: ``` config :pleroma, :mrf, policies: [Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy] ``` + +### Database Configuration + +In the admin interface, add `MediaProxyWarmingPolicy` to the `Policies` option under `Settings` → `MRF`. diff --git a/docs/docs/development/API/admin_api.md b/docs/docs/development/API/admin_api.md index 241e0b95c..958388879 100644 --- a/docs/docs/development/API/admin_api.md +++ b/docs/docs/development/API/admin_api.md @@ -2,7 +2,7 @@ Authentication is required and the user must be an admin. -The `/api/v1/pleroma/admin/*` path is backwards compatible with `/api/pleroma/admin/*` (`/api/pleroma/admin/*` will be deprecated in the future). +Backwards-compatibility for admin API endpoints without version prefixes (`/api/pleroma/admin/*`) has been removed as of Akkoma 3.6.0. Please use `/api/v1/pleroma/admin/*` instead. ## `GET /api/v1/pleroma/admin/users` @@ -1056,14 +1056,13 @@ Most of the settings will be applied in `runtime`, this means that you don't nee Example of setting without keyword in value: ```elixir -config :tesla, :adapter, Tesla.Adapter.Hackney +config :tesla, :adapter, {Tesla.Adapter.Finch, name: MyFinch} ``` List of settings which support only full update by key: ```elixir @full_key_update [ {:pleroma, :ecto_repos}, - {:quack, :meta}, {:mime, :types}, {:cors_plug, [:max_age, :methods, :expose, :headers]}, {:auto_linker, :opts}, @@ -1083,22 +1082,6 @@ List of settings which support only full update by subkey: ] ``` -*Settings without explicit key must be sended in separate config object params.* -```elixir -config :quack, - level: :debug, - meta: [:all], - ... -``` -```json -{ - "configs": [ - {"group": ":quack", "key": ":level", "value": ":debug"}, - {"group": ":quack", "key": ":meta", "value": [":all"]}, - ... - ] -} -``` - Request: ```json diff --git a/docs/docs/development/API/akkoma_api.md b/docs/docs/development/API/akkoma_api.md new file mode 100644 index 000000000..cc3f7ddec --- /dev/null +++ b/docs/docs/development/API/akkoma_api.md @@ -0,0 +1,146 @@ +# Akkoma API + +Request authentication (if required) and parameters work the same as for [Pleroma API](pleroma_api.md). + +## `/api/v1/akkoma/preferred_frontend/available` +### Returns the available frontends which can be picked as the preferred choice +* Method: `GET` +* Authentication: not required +* Params: none +* Response: JSON +* Example response: +```json +["pleroma-fe/stable"] +``` + +!!! note + There’s also a browser UI under `/akkoma/frontend` + for interactively querying and changing this. + +## `/api/v1/akkoma/preferred_frontend` +### Configures the preferred frontend of this session +* Method: `PUT` +* Authentication: not required +* Params: + * `frontend_name`: STRING containing one of the available frontends +* Response: JSON +* Example response: +```json +{"frontend_name":"pleroma-fe/stable"} +``` + +!!! note + There’s also a browser UI under `/akkoma/frontend` + for interactively querying and changing this. + +## `/api/v1/akkoma/metrics` +### Provides metrics for Prometheus to scrape +* Method: `GET` +* Authentication: required (admin:metrics) +* Params: none +* Response: text +* Example response: +``` +# HELP pleroma_remote_users_total +# TYPE pleroma_remote_users_total gauge +pleroma_remote_users_total 25 +# HELP pleroma_local_statuses_total +# TYPE pleroma_local_statuses_total gauge +pleroma_local_statuses_total 17 +# HELP pleroma_domains_total +# TYPE pleroma_domains_total gauge +pleroma_domains_total 4 +# HELP pleroma_local_users_total +# TYPE pleroma_local_users_total gauge +pleroma_local_users_total 3 +... +``` + +## `/api/v1/akkoma/translation/languages` +### Returns available source and target languages for automated text translation +* Method: `GET` +* Authentication: required +* Params: none +* Response: JSON +* Example response: +```json +{ + "source": [ + {"code":"LV", "name":"Latvian"}, + {"code":"ZH", "name":"Chinese (traditional)"}, + {"code":"EN-US", "name":"English (American)"} + ], + "target": [ + {"code":"EN-GB", "name":"English (British)"}, + {"code":"JP", "name":"Japanese"} + ] +} +``` + +## `/api/v1/akkoma/frontend_settings/:frontend_name` +### Lists all configuration profiles of the selected frontend for the current user +* Method: `GET` +* Authentication: required +* Params: none +* Response: JSON +* Example response: +```json +[ + {"name":"default","version":31} +] +``` + +## `/api/v1/akkoma/frontend_settings/:frontend_name/:profile_name` +### Returns the full selected frontend settings profile of the current user +* Method: `GET` +* Authentication: required +* Params: none +* Response: JSON +* Example response: +```json +{ + "version": 31, + "settings": { + "streaming": true, + "conversationDisplay": "tree", + ... + } +} +``` + +## `/api/v1/akkoma/frontend_settings/:frontend_name/:profile_name` +### Updates the frontend settings profile +* Method: `PUT` +* Authentication: required +* Params: + * `version`: INTEGER + * `settings`: JSON object containing the entire new settings +* Response: JSON +* Example response: +```json +{ + "streaming": false, + "conversationDisplay": "tree", + ... +} +``` + +!!! note + The `version` field must be increased by exactly one on each update + +## `/api/v1/akkoma/frontend_settings/:frontend_name/:profile_name` +### Drops the specified frontend settings profile +* Method: `DELETE` +* Authentication: required +* Params: none +* Response: JSON +* Example response: +```json +{"deleted":"ok"} +``` + + +## `/api/v1/timelines/bubble` +### Returns a timeline for the local and closely related instances +Works like all other Mastodon-API timeline queries with the documented +[Akkoma-specific additions and tweaks](./differences_in_mastoapi_responses.md#timelines). diff --git a/docs/docs/development/API/differences_in_mastoapi_responses.md b/docs/docs/development/API/differences_in_mastoapi_responses.md index 752be1762..990806cea 100644 --- a/docs/docs/development/API/differences_in_mastoapi_responses.md +++ b/docs/docs/development/API/differences_in_mastoapi_responses.md @@ -1,6 +1,6 @@ # Differences in Mastodon API responses from vanilla Mastodon -A Akkoma instance can be identified by " (compatible; Pleroma )" present in `version` field in response from `/api/v1/instance` +A Akkoma instance can be identified by " (compatible; Akkoma )" present in `version` field in response from `/api/v1/instance` ## Flake IDs @@ -8,23 +8,32 @@ Akkoma uses 128-bit ids as opposed to Mastodon's 64 bits. However, just like Mas ## Timelines +In addition to Mastodon’s timelines, there is also a “bubble timeline” showing +posts from the local instance and a set of closely related instances as chosen +by the administrator. It is available under `/api/v1/timelines/bubble`. + Adding the parameter `with_muted=true` to the timeline queries will also return activities by muted (not by blocked!) users. Adding the parameter `exclude_visibilities` to the timeline queries will exclude the statuses with the given visibilities. The parameter accepts an array of visibility types (`public`, `unlisted`, `private`, `direct`), e.g., `exclude_visibilities[]=direct&exclude_visibilities[]=private`. -Adding the parameter `reply_visibility` to the public and home timelines queries will filter replies. Possible values: without parameter (default) shows all replies, `following` - replies directed to you or users you follow, `self` - replies directed to you. +Adding the parameter `reply_visibility` to the public, bubble or home timelines queries will filter replies. Possible values: without parameter (default) shows all replies, `following` - replies directed to you or users you follow, `self` - replies directed to you. Adding the parameter `instance=lain.com` to the public timeline will show only statuses originating from `lain.com` (or any remote instance). -Home, public, hashtag & list timelines accept these parameters: +All but the direct timeline accept these parameters: - `only_media`: show only statuses with media attached -- `local`: show only local statuses - `remote`: show only remote statuses +Home, public, hashtag & list timelines further accept: + +- `local`: show only local statuses + + ## Statuses - `visibility`: has additional possible values `list` and `local` (for local-only statuses) +- `emoji_reactions`: additional field since Akkoma 3.2.0; identical to `pleroma/emoji_reactions` Has these additional fields under the `pleroma` object: @@ -36,7 +45,9 @@ Has these additional fields under the `pleroma` object: - `spoiler_text`: a map consisting of alternate representations of the `spoiler_text` property with the key being its mimetype. Currently, the only alternate representation supported is `text/plain` - `expires_at`: a datetime (iso8601) that states when the post will expire (be deleted automatically), or empty if the post won't expire - `thread_muted`: true if the thread the post belongs to is muted -- `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. +- `emoji_reactions`: A list with emoji / reaction maps. The format is `{name: "☕", count: 2, me: true, account_ids: ["UserID1", "UserID2"]}`. + The `account_ids` property was added in Akkoma 3.2.0. + Further info about all reacting users at once, can be found using 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. @@ -110,6 +121,12 @@ Has these additional fields under the `pleroma` object: - `notification_settings`: object, can be absent. See `/api/v1/pleroma/notification_settings` for the parameters/keys returned. - `favicon`: nullable URL string, Favicon image of the user's instance +Has these additional fields under the `akkoma` object: + +- `instance`: nullable object with metadata about the user’s instance +- `status_ttl_days`: nullable int, default time after which statuses are deleted +- `permit_followback`: boolean, whether follows from followed accounts are auto-approved + ### Source Has these additional fields under the `pleroma` object: @@ -195,7 +212,7 @@ Additional parameters can be added to the JSON body/Form data: - `preview`: boolean, 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`: string, contain the MIME type of the status, it is transformed into HTML by the backend. You can get the list of the supported MIME types with the nodeinfo endpoint. -- `to`: A list of nicknames (like `lain@soykaf.club` or `lain` on the local server) that will be used to determine who is going to be addressed by this post. Using this will disable the implicit addressing by mentioned names in the `status` body, only the people in the `to` list will be addressed. The normal rules for post visibility are not affected by this and will still apply. +- `to`: A list of nicknames (like `admin@otp.akkoma.dev` or `admin` on the local server) that will be used to determine who is going to be addressed by this post. Using this will disable the implicit addressing by mentioned names in the `status` body, only the people in the `to` list will be addressed. The normal rules for post visibility are not affected by this and will still apply. - `visibility`: string, besides standard MastoAPI values (`direct`, `private`, `unlisted`, `local` or `public`) it can be used to address a List by setting it to `list:LIST_ID`. - `expires_in`: The number of seconds the posted activity should expire in. When a posted activity expires it will be deleted from the server, and a delete request for it will be federated. This needs to be longer than an hour. - `in_reply_to_conversation_id`: 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`. @@ -214,6 +231,11 @@ Returns: array of Status. The maximum number of statuses is limited to 100 per request. +## PUT `/api/v1/statuses/:id/emoji_reactions/:emoji` + +This endpoint is an extension of the Fedibird Mastodon fork. +It behaves identical to PUT `/api/v1/pleroma/statuses/:id/reactions/:emoji`. + ## PATCH `/api/v1/accounts/update_credentials` Additional parameters can be added to the JSON body/Form data: diff --git a/docs/docs/development/API/prometheus.md b/docs/docs/development/API/prometheus.md index 0c23a404d..b990593f0 100644 --- a/docs/docs/development/API/prometheus.md +++ b/docs/docs/development/API/prometheus.md @@ -5,27 +5,16 @@ Akkoma includes support for exporting metrics via the [prometheus_ex](https://gi Config example: ``` -config :prometheus, Pleroma.Web.Endpoint.MetricsExporter, - enabled: true, - auth: {:basic, "myusername", "mypassword"}, - ip_whitelist: ["127.0.0.1"], - path: "/api/pleroma/app_metrics", - format: :text +config :pleroma, :instance, + export_prometheus_metrics: true ``` -* `enabled` (Akkoma extension) enables the endpoint -* `ip_whitelist` (Akkoma extension) could be used to restrict access only to specified IPs -* `auth` sets the authentication (`false` for no auth; configurable to HTTP Basic Auth, see [prometheus-plugs](https://github.com/deadtrickster/prometheus-plugs#exporting) documentation) -* `format` sets the output format (`:text` or `:protobuf`) -* `path` sets the path to app metrics page - - -## `/api/pleroma/app_metrics` +## `/api/v1/akkoma/metrics` ### Exports Prometheus application metrics * Method: `GET` -* Authentication: not required by default (see configuration options above) +* Authentication: required * Params: none * Response: text @@ -37,8 +26,8 @@ The following is a config example to use with [Grafana](https://grafana.com) ``` - job_name: 'beam' - metrics_path: /api/pleroma/app_metrics + metrics_path: /api/v1/akkoma/metrics scheme: https static_configs: - - targets: ['pleroma.soykaf.com'] + - targets: ['otp.akkoma.dev'] ``` diff --git a/docs/docs/development/index.md b/docs/docs/development/index.md index 01a617596..8f2dd52d0 100644 --- a/docs/docs/development/index.md +++ b/docs/docs/development/index.md @@ -1 +1,48 @@ -This section contains notes and guidelines for developers. +# Contributing to Akkoma + +You wish to add a new feature in Akkoma, but don't know how to proceed? This guide takes you through the various steps of the development and contribution process. + +If you're looking for stuff to implement or fix, check the [bug-tracker](https://akkoma.dev/AkkomaGang/akkoma/issues) or [forum](https://meta.akkoma.dev/c/requests/5). + +Come say hi to us in the [#akkoma-dev chat room](./../#irc)! + +## Akkoma Clients + +Akkoma is the back-end. Clients have their own repositories and often separate projects. You can check what clients work with Akkoma [on the clients page](../clients/). If you maintain a working client not listed yet, feel free to make a PR [to these docs](./#docs)! + +For resources on APIs and such, check the sidebar of this page. + +## Docs + +The docs are written in Markdown, including certain extensions, and can be found [in the docs folder of the Akkoma repo](https://akkoma.dev/AkkomaGang/akkoma/src/branch/develop/docs/). The content itself is stored in the `docs` subdirectory. + +## Technology + +Akkoma is written in [Elixir](https://elixir-lang.org/) and uses [Postgresql](https://www.postgresql.org/) for database. We use [Git](https://git-scm.com/) for collaboration and tracking code changes. Furthermore it can typically run on [Unix and Unix-like OS'es](https://en.wikipedia.org/wiki/Unix-like). For development, you should use an OS which [can run Akkoma](../installation/debian_based_en/). + +It's good to have at least some basic understanding of at least Git and Elixir. If this is completely new for you, there's some [videos explaining Git](https://git-scm.com/doc) and Codeberg has a nice article explaining the typical [pull requests Git flow](https://docs.codeberg.org/collaborating/pull-requests-and-git-flow/). For Elixir, you can follow Elixir's own [Getting Started guide](https://elixir-lang.org/getting-started/introduction.html). + +## Setting up a development environment + +The best way to start is getting the software to run from source so you can start poking on it. Check out the [guides for setting up an Akkoma instance for development](setting_up_akkoma_dev/#setting-up-a-akkoma-development-environment). + +## General overview +### Modules + +Akkoma has several modules. There are modules for [uploading](https://akkoma.dev/AkkomaGang/akkoma/src/branch/develop/lib/pleroma/uploaders), [upload filters](https://akkoma.dev/AkkomaGang/akkoma/src/branch/develop/lib/pleroma/upload/filter), [translators](https://akkoma.dev/AkkomaGang/akkoma/src/branch/develop/lib/pleroma/akkoma/translators)... The most famous ones are without a doubt the [MRF policies](https://akkoma.dev/AkkomaGang/akkoma/src/branch/develop/lib/pleroma/web/activity_pub/mrf). Modules are often self contained and a good way to start with development because you don't have to think about much more than just the module itself. We even have an example on [writing your own MRF policy](/configuration/mrf/#writing-your-own-mrf-policy)! + +Another easy entry point is the [mix tasks](https://akkoma.dev/AkkomaGang/akkoma/src/branch/develop/lib/mix/tasks/pleroma). They too are often self contained and don't need you to go through much of the code. + +### Activity Streams/Activity Pub + +Akkoma uses Activity Streams for both federation, as well as internal representation. It may be interesting to at least go over the specifications of [Activity Pub](https://www.w3.org/TR/activitypub/), [Activity Streams 2.0](https://www.w3.org/TR/activitystreams-core/), and [Activity Streams Vocabulary](https://www.w3.org/TR/activitystreams-vocabulary/). Note that these are not enough to have a full grasp of how everything works, but should at least give you the basics to understand how messages are passed between and inside Akkoma instances. + +## Don't forget + +When you make changes, you're expected to create [a Pull Request](https://akkoma.dev/AkkomaGang/akkoma/pulls). You don't have to wait until you finish to create the PR, but please do prefix the title of the PR with "WIP: " for as long as you're still working on it. The sooner you create your PR, the sooner people know what you are working on and the sooner you can get feedback and, if needed, help. You can then simply keep working on it until you are finished. + +When doing changes, don't forget to add it to the relevant parts of the [CHANGELOG.md](https://akkoma.dev/AkkomaGang/akkoma/src/branch/develop/CHANGELOG.md). + +You're expected to write [tests](https://elixirschool.com/en/lessons/testing/basics). While code is generally stored in the `lib` directory, tests are stored in the `test` directory using a similar folder structure. Feel free to peak at other tests to see how they are done. Obviously tests are expected to pass and properly test the functionality you added. If you feel really confident, you could even try to [write a test first and then write the code needed to make it pass](https://en.wikipedia.org/wiki/Test-driven_development)! + +Code is formatted using the default formatter that comes with Elixir. You can format a file with e.g. `mix format /path/to/file.ex`. To check if everything is properly formatted, you can run `mix format --check-formatted`. diff --git a/docs/docs/development/setting_up_akkoma_dev.md b/docs/docs/development/setting_up_akkoma_dev.md index 7184be485..feded9904 100644 --- a/docs/docs/development/setting_up_akkoma_dev.md +++ b/docs/docs/development/setting_up_akkoma_dev.md @@ -5,22 +5,37 @@ Akkoma requires some adjustments from the defaults for running the instance loca ## Installing 1. Install Akkoma as explained in [the docs](../installation/debian_based_en.md), with some exceptions: - * You can use your own fork of the repository and add akkoma as a remote `git remote add akkoma 'https://akkoma.dev/AkkomaGang/akkoma.git'` - * You can skip systemd and nginx and all that stuff * No need to create a dedicated akkoma user, it's easier to just use your own user - * For the DB you can still choose a dedicated user, the mix tasks set it up for you so it's no extra work for you + * You can use your own fork of the repository and add akkoma as a remote `git remote add akkoma 'https://akkoma.dev/AkkomaGang/akkoma.git'` * For domain you can use `localhost` + * For the DB you can still choose a dedicated user. The mix tasks sets it up, so it's no extra work for you * instead of creating a `prod.secret.exs`, create `dev.secret.exs` * No need to prefix with `MIX_ENV=prod`. We're using dev and that's the default MIX_ENV + * You can skip nginx and systemd + * For front-end, you'll probably want to install and use the develop branch instead of the stable branch. There's no guarantee that the stable branch of the FE will always work on the develop branch of the BE. 2. Change the dev.secret.exs + * Change the FE settings to use the installed branch (see also [Frontend Management](/configuration/frontend_management/)) * 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 with `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 on how to install pleroma-fe and admin-fe using it's develop branch +```sh +mix pleroma.frontend install pleroma-fe --ref develop +mix pleroma.frontend install admin-fe --ref develop +``` + +Example config to use the pleroma-fe and admin-fe installed from the develop branch +```elixir +config :pleroma, :frontends, + primary: %{"name" => "pleroma-fe", "ref" => "develop"}, + admin: %{"name" => "admin-fe", "ref" => "develop"} +``` Example config to change the scheme to http. Change the port if you want to run on another port. ```elixir - config :pleroma, Pleroma.Web.Endpoint, - url: [host: "localhost", scheme: "http", port: 4000], +config :pleroma, Pleroma.Web.Endpoint, + url: [host: "localhost", scheme: "http", port: 4000], ``` Example config to disable captcha. This makes it a bit easier to create test-users. @@ -94,4 +109,4 @@ Update Akkoma as explained in [the docs](../administration/updating.md). Just ma ## Working on multiple branches -If you develop on a separate branch, it's possible you did migrations that aren't merged into another branch you're working on. If you have multiple things you're working on, it's probably best to set up multiple Akkoma instances each with their own database. If you finished with a branch and want to switch back to develop to start a new branch from there, you can drop the database and recreate the database (e.g. by using `config/setup_db.psql`). The commands to drop and recreate the database can be found in [the docs](../administration/backup.md). +If you develop on a separate branch, it's possible you did migrations that aren't merged into another branch you're working on. In that case, it's probably best to set up multiple Akkoma instances each with their own database. If you finished with a branch and want to switch back to develop to start a new branch from there, you can drop the database and recreate the database (e.g. by using `config/setup_db.psql`). The commands to drop and recreate the database can be found in [the docs](../administration/backup.md). diff --git a/docs/docs/images/akko_badday.png b/docs/docs/images/akko_badday.png deleted file mode 100644 index b79c67dae..000000000 Binary files a/docs/docs/images/akko_badday.png and /dev/null differ diff --git a/docs/docs/images/favicon.ico b/docs/docs/images/favicon.ico new file mode 100644 index 000000000..a0dd726ad Binary files /dev/null and b/docs/docs/images/favicon.ico differ diff --git a/docs/docs/images/favicon.png b/docs/docs/images/favicon.png new file mode 100755 index 000000000..1a12b4ad8 Binary files /dev/null and b/docs/docs/images/favicon.png differ diff --git a/docs/docs/images/logo.png b/docs/docs/images/logo.png new file mode 100755 index 000000000..d82112c9d Binary files /dev/null and b/docs/docs/images/logo.png differ diff --git a/docs/docs/index.md b/docs/docs/index.md index 1018e9c2b..8608f8196 100644 --- a/docs/docs/index.md +++ b/docs/docs/index.md @@ -3,7 +3,7 @@ # Introduction to Akkoma ## What is Akkoma? Akkoma is a federated social networking platform, compatible with Mastodon and other ActivityPub implementations. It is free software licensed under the AGPLv3. -It actually consists of two components: a backend, named simply Akkoma, and a user-facing frontend, named Pleroma-FE. It also includes the Mastodon frontend, if that's your thing. +It actually consists of two components: a backend, named simply Akkoma, and a user-facing frontend, named Akkoma-FE. It also includes the Mastodon frontend, if that's your thing. It's part of what we call the fediverse, a federated network of instances which speak common protocols and can communicate with each other. One account on an instance is enough to talk to the entire fediverse! @@ -23,20 +23,20 @@ will be posted via [@akkoma@ihba](https://ihatebeinga.live/users/akkoma) ## How can I use it? -Akkoma instances are already widely deployed, a list can be found at and . +Akkoma instances are already widely deployed, a list can be found at and . If you don't feel like joining an existing instance, but instead prefer to deploy your own instance, that's easy too! Installation instructions can be found in the installation section of these docs. ## I got an account, now what? -Great! Now you can explore the fediverse! Open the login page for your Akkoma instance (e.g. ) and login with your username and password. (If you don't have an account yet, click on Register) +Great! Now you can explore the fediverse! Open the login page for your Akkoma instance (e.g. ) and login with your username and password. (If you don't have an account yet, click on Register) -### Pleroma-FE -The default front-end used by Akkoma is Pleroma-FE. You can find more information on what it is and how to use it in the [Introduction to Pleroma-FE](https://docs-fe.akkoma.dev/stable/). +### Akkoma-FE +The default front-end used by Akkoma is Akkoma-FE. You can find more information on what it is and how to use it in the [Introduction to Akkoma-FE](https://docs-fe.akkoma.dev/stable/). ### Mastodon interface -If the Pleroma-FE interface isn't your thing, or you're just trying something new but you want to keep using the familiar Mastodon interface, we got that too! -Just add a "/web" after your instance url (e.g. ) and you'll end on the Mastodon web interface, but with a Akkoma backend! MAGIC! +If the Akkoma-FE interface isn't your thing, or you're just trying something new but you want to keep using the familiar Mastodon interface, we got that too! +Just add a "/web" after your instance url (e.g. ) and you'll end on the Mastodon web interface, but with a Akkoma backend! MAGIC! The Mastodon interface is from the Glitch-soc fork. For more information on the Mastodon interface you can check the [Mastodon](https://docs.joinmastodon.org/) and [Glitch-soc](https://glitch-soc.github.io/docs/) documentation. Remember, what you see is only the frontend part of Mastodon, the backend is still Akkoma. diff --git a/docs/docs/installation/alpine_linux_en.md b/docs/docs/installation/alpine_linux_en.md index aae8f9626..bdfb96d77 100644 --- a/docs/docs/installation/alpine_linux_en.md +++ b/docs/docs/installation/alpine_linux_en.md @@ -84,12 +84,12 @@ doas adduser -S -s /bin/false -h /opt/akkoma -H -G akkoma akkoma **Note**: To execute a single command as the Akkoma system user, use `doas -u akkoma command`. You can also switch to a shell by using `doas -su akkoma`. If you don’t have and want `doas` on your system, you can use `su` as root user (UID 0) for a single command by using `su -l akkoma -s $SHELL -c 'command'` and `su -l akkoma -s $SHELL` for starting a shell. -* Git clone the AkkomaBE repository and make the Akkoma user the owner of the directory: +* Git clone the AkkomaBE repository from stable-branch and make the Akkoma user the owner of the directory: ```shell doas mkdir -p /opt/akkoma doas chown -R akkoma:akkoma /opt/akkoma -doas -u akkoma git clone https://akkoma.dev/AkkomaGang/akkoma.git /opt/akkoma +doas -u akkoma git clone https://akkoma.dev/AkkomaGang/akkoma.git -b stable /opt/akkoma ``` * Change to the new directory: @@ -109,7 +109,7 @@ doas -u akkoma mix deps.get * This may take some time, because parts of akkoma get compiled first. * After that it will ask you a few questions about your instance and generates a configuration file in `config/generated_config.exs`. -* Check the configuration and if all looks right, rename it, so Akkoma will load it (`prod.secret.exs` for productive instance, `dev.secret.exs` for development instances): +* Check the configuration and if all looks right, rename it, so Akkoma will load it (`prod.secret.exs` for productive instances): ```shell doas -u akkoma mv config/{generated_config.exs,prod.secret.exs} diff --git a/docs/docs/installation/arch_linux_en.md b/docs/docs/installation/arch_linux_en.md index 639c9c798..300a5d80f 100644 --- a/docs/docs/installation/arch_linux_en.md +++ b/docs/docs/installation/arch_linux_en.md @@ -75,12 +75,12 @@ sudo useradd -r -s /bin/false -m -d /var/lib/akkoma -U akkoma **Note**: To execute a single command as the Akkoma system user, use `sudo -Hu akkoma command`. You can also switch to a shell by using `sudo -Hu akkoma $SHELL`. If you don’t have and want `sudo` on your system, you can use `su` as root user (UID 0) for a single command by using `su -l akkoma -s $SHELL -c 'command'` and `su -l akkoma -s $SHELL` for starting a shell. -* Git clone the AkkomaBE repository and make the Akkoma user the owner of the directory: +* Git clone the AkkomaBE repository from stable-branch and make the Akkoma user the owner of the directory: ```shell sudo mkdir -p /opt/akkoma sudo chown -R akkoma:akkoma /opt/akkoma -sudo -Hu akkoma git clone https://akkoma.dev/AkkomaGang/akkoma.git /opt/akkoma +sudo -Hu akkoma git clone https://akkoma.dev/AkkomaGang/akkoma.git -b stable /opt/akkoma ``` * Change to the new directory: @@ -100,7 +100,7 @@ sudo -Hu akkoma mix deps.get * This may take some time, because parts of akkoma get compiled first. * After that it will ask you a few questions about your instance and generates a configuration file in `config/generated_config.exs`. -* Check the configuration and if all looks right, rename it, so Akkoma will load it (`prod.secret.exs` for productive instance, `dev.secret.exs` for development instances): +* Check the configuration and if all looks right, rename it, so Akkoma will load it (`prod.secret.exs` for productive instances): ```shell sudo -Hu akkoma mv config/{generated_config.exs,prod.secret.exs} diff --git a/docs/docs/installation/debian_based_en.md b/docs/docs/installation/debian_based_en.md index 139c789bc..6a0f332ec 100644 --- a/docs/docs/installation/debian_based_en.md +++ b/docs/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 akkoma`; 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 Jellyfish”) 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 akkoma`; in this case, use `su -s $SHELL -c 'command'` instead. {! installation/generic_dependencies.include !} @@ -23,23 +23,7 @@ sudo apt full-upgrade sudo apt install git build-essential postgresql postgresql-contrib cmake libmagic-dev ``` -### Install Elixir and Erlang - -* Install Elixir and Erlang (you might need to use backports or [asdf](https://github.com/asdf-vm/asdf) on old systems): - -```shell -sudo apt update -sudo apt install elixir erlang-dev erlang-nox -``` - - -### Optional packages: [`docs/installation/optional/media_graphics_packages.md`](../installation/optional/media_graphics_packages.md) - -```shell -sudo apt install imagemagick ffmpeg libimage-exiftool-perl -``` - -### Install AkkomaBE +### Create the akkoma user * Add a new system user for the Akkoma service: @@ -49,12 +33,72 @@ sudo useradd -r -s /bin/false -m -d /var/lib/akkoma -U akkoma **Note**: To execute a single command as the Akkoma system user, use `sudo -Hu akkoma command`. You can also switch to a shell by using `sudo -Hu akkoma $SHELL`. If you don’t have and want `sudo` on your system, you can use `su` as root user (UID 0) for a single command by using `su -l akkoma -s $SHELL -c 'command'` and `su -l akkoma -s $SHELL` for starting a shell. -* Git clone the AkkomaBE repository and make the Akkoma user the owner of the directory: +### Install Elixir and Erlang + +If your distribution packages a recent enough version of Elixir, you can install it directly from the distro repositories and skip to the next section of the guide: + +```shell +sudo apt install elixir erlang-dev erlang-nox +``` + +Otherwise use [asdf](https://github.com/asdf-vm/asdf) to install the latest versions of Elixir and Erlang. + +First, install some dependencies needed to build Elixir and Erlang: +```shell +sudo apt install curl unzip build-essential autoconf m4 libncurses5-dev libssh-dev unixodbc-dev xsltproc libxml2-utils libncurses-dev +``` + +Then login to the `akkoma` user and install asdf: +```shell +git clone https://github.com/asdf-vm/asdf.git ~/.asdf --branch v0.11.3 +``` + +Add the following lines to `~/.bashrc`: +```shell +. "$HOME/.asdf/asdf.sh" +# asdf completions +. "$HOME/.asdf/completions/asdf.bash" +``` + +Restart the shell: +```shell +exec $SHELL +``` + +Next install Erlang: +```shell +asdf plugin add erlang https://github.com/asdf-vm/asdf-erlang.git +export KERL_CONFIGURE_OPTIONS="--disable-debug --without-javac" +asdf install erlang 25.3.2.5 +asdf global erlang 25.3.2.5 +``` + +Now install Elixir: +```shell +asdf plugin-add elixir https://github.com/asdf-vm/asdf-elixir.git +asdf install elixir 1.15.4-otp-25 +asdf global elixir 1.15.4-otp-25 +``` + +Confirm that Elixir is installed correctly by checking the version: +```shell +elixir --version +``` + +### Optional packages: [`docs/installation/optional/media_graphics_packages.md`](../installation/optional/media_graphics_packages.md) + +```shell +sudo apt install imagemagick ffmpeg libimage-exiftool-perl +``` + +### Install AkkomaBE + +* Log into the `akkoma` user and clone the AkkomaBE repository from the stable branch and make the Akkoma user the owner of the directory: ```shell sudo mkdir -p /opt/akkoma sudo chown -R akkoma:akkoma /opt/akkoma -sudo -Hu akkoma git clone https://akkoma.dev/AkkomaGang/akkoma.git /opt/akkoma +sudo -Hu akkoma git clone https://akkoma.dev/AkkomaGang/akkoma.git -b stable /opt/akkoma ``` * Change to the new directory: @@ -74,7 +118,7 @@ sudo -Hu akkoma mix deps.get * This may take some time, because parts of akkoma get compiled first. * After that it will ask you a few questions about your instance and generates a configuration file in `config/generated_config.exs`. -* Check the configuration and if all looks right, rename it, so Akkoma will load it (`prod.secret.exs` for productive instance, `dev.secret.exs` for development instances): +* Check the configuration and if all looks right, rename it, so Akkoma will load it (`prod.secret.exs` for productive instances): ```shell sudo -Hu akkoma mv config/{generated_config.exs,prod.secret.exs} diff --git a/docs/docs/installation/docker_en.md b/docs/docs/installation/docker_en.md index 64169852f..9551b034a 100644 --- a/docs/docs/installation/docker_en.md +++ b/docs/docs/installation/docker_en.md @@ -10,7 +10,7 @@ If you want to migrate from or OTP to docker, check out [the migration guide](./ ### Prepare the system -* Install docker and docker-compose +* Install docker and docker compose * [Docker](https://docs.docker.com/engine/install/) * [Docker-compose](https://docs.docker.com/compose/install/) * This will usually just be a repository installation and a package manager invocation. @@ -26,7 +26,7 @@ echo "DOCKER_USER=$(id -u):$(id -g)" >> .env ``` This probably won't need to be changed, it's only there to set basic environment -variables for the docker-compose file. +variables for the docker compose file. ### Building the container @@ -51,7 +51,8 @@ mkdir pgdata ``` This will ask you a few questions - the defaults are fine for most things, -the database hostname is `db`, and you will want to set the ip to `0.0.0.0`. +the database hostname is `db`, the database password is `akkoma` +(not auto generated), and you will want to set the ip to `0.0.0.0`. Now we'll want to copy over the config it just created @@ -64,9 +65,9 @@ cp config/generated_config.exs config/prod.secret.exs We need to run a few commands on the database container, this isn't too bad ```bash -docker-compose run --rm --user akkoma -d db +docker compose run --rm --user akkoma -d db # Note down the name it gives here, it will be something like akkoma_db_run -docker-compose run --rm akkoma psql -h db -U akkoma -f config/setup_db.psql +docker compose run --rm akkoma psql -h db -U akkoma -f config/setup_db.psql docker stop akkoma_db_run # Replace with the name you noted down ``` @@ -83,17 +84,17 @@ We're going to run it in the foreground on the first run, just to make sure everything start up. ```bash -docker-compose up +docker compose up ``` If everything went well, you should be able to access your instance at http://localhost:4000 -You can `ctrl-c` out of the docker-compose now to shutdown the server. +You can `ctrl-c` out of the docker compose now to shutdown the server. ### Running in the background ```bash -docker-compose up -d +docker compose up -d ``` ### Create your first user @@ -124,8 +125,27 @@ cp docker-resources/Caddyfile.example docker-resources/Caddyfile Then edit the TLD in your caddyfile to the domain you're serving on. -Uncomment the `caddy` section in the docker-compose file, -then run `docker-compose up -d` again. +Copy the commented out `caddy` section in `docker-compose.yml` into a new file called `docker-compose.override.yml` like so: +```yaml +version: "3.7" + +services: + proxy: + image: caddy:2-alpine + restart: unless-stopped + links: + - akkoma + ports: [ + "443:443", + "80:80" + ] + volumes: + - ./docker-resources/Caddyfile:/etc/caddy/Caddyfile + - ./caddy-data:/data + - ./caddy-config:/config +``` + +then run `docker compose up -d` again. #### Running a reverse proxy on the host @@ -151,9 +171,15 @@ git pull ./docker-resources/manage.sh mix deps.get ./docker-resources/manage.sh mix compile ./docker-resources/manage.sh mix ecto.migrate -docker-compose restart akkoma db +docker compose restart akkoma db ``` +### Modifying the Docker services +If you want to modify the services defined in the docker compose file, you can +create a new file called `docker-compose.override.yml`. There you can add any +overrides or additional services without worrying about git conflicts when a +new release comes out. + #### Further reading {! installation/further_reading.include !} diff --git a/docs/docs/installation/fedora_based_en.md b/docs/docs/installation/fedora_based_en.md index d8c7b3e74..3e09f6996 100644 --- a/docs/docs/installation/fedora_based_en.md +++ b/docs/docs/installation/fedora_based_en.md @@ -30,11 +30,10 @@ sudo dnf install git gcc g++ make cmake file-devel postgresql-server postgresql- * Enable and initialize Postgres: ```shell -sudo systemctl enable postgresql.service sudo postgresql-setup --initdb --unit postgresql # Allow password auth for postgres sudo sed -E -i 's|(host +all +all +127.0.0.1/32 +)ident|\1md5|' /var/lib/pgsql/data/pg_hba.conf -sudo systemctl start postgresql.service +sudo systemctl enable --now postgresql.service ``` ### Install Elixir and Erlang @@ -59,7 +58,7 @@ sudo dnf install ffmpeg * Install ImageMagick and ExifTool for image manipulation: ```shell -sudo dnf install Imagemagick perl-Image-ExifTool +sudo dnf install ImageMagick perl-Image-ExifTool ``` @@ -74,12 +73,12 @@ sudo useradd -r -s /bin/false -m -d /var/lib/akkoma -U akkoma **Note**: To execute a single command as the Akkoma system user, use `sudo -Hu akkoma command`. You can also switch to a shell by using `sudo -Hu akkoma $SHELL`. If you don’t have and want `sudo` on your system, you can use `su` as root user (UID 0) for a single command by using `su -l akkoma -s $SHELL -c 'command'` and `su -l akkoma -s $SHELL` for starting a shell. -* Git clone the AkkomaBE repository and make the Akkoma user the owner of the directory: +* Git clone the AkkomaBE repository from stable-branch and make the Akkoma user the owner of the directory: ```shell sudo mkdir -p /opt/akkoma sudo chown -R akkoma:akkoma /opt/akkoma -sudo -Hu akkoma git clone https://akkoma.dev/AkkomaGang/akkoma.git /opt/akkoma +sudo -Hu akkoma git clone https://akkoma.dev/AkkomaGang/akkoma.git -b stable /opt/akkoma ``` * Change to the new directory: @@ -99,7 +98,7 @@ sudo -Hu akkoma mix deps.get * This may take some time, because parts of akkoma get compiled first. * After that it will ask you a few questions about your instance and generates a configuration file in `config/generated_config.exs`. -* Check the configuration and if all looks right, rename it, so Akkoma will load it (`prod.secret.exs` for productive instance, `dev.secret.exs` for development instances): +* Check the configuration and if all looks right, rename it, so Akkoma will load it (`prod.secret.exs` for productive instances): ```shell sudo -Hu akkoma mv config/{generated_config.exs,prod.secret.exs} diff --git a/docs/docs/installation/generic_dependencies.include b/docs/docs/installation/generic_dependencies.include index 68c61129a..d7701a28f 100644 --- a/docs/docs/installation/generic_dependencies.include +++ b/docs/docs/installation/generic_dependencies.include @@ -1,8 +1,8 @@ ## Required dependencies * PostgreSQL 9.6+ -* Elixir 1.12+ (1.13+ recommended) -* Erlang OTP 22.2+ +* Elixir 1.14+ (currently tested up to 1.16) +* Erlang OTP 25+ (currently tested up to OTP26) * git * file / libmagic * gcc (clang might also work) diff --git a/docs/docs/installation/gentoo_en.md b/docs/docs/installation/gentoo_en.md index 9450c9b38..76069f645 100644 --- a/docs/docs/installation/gentoo_en.md +++ b/docs/docs/installation/gentoo_en.md @@ -18,6 +18,12 @@ dev-db/postgresql uuid You could opt to add `USE="uuid"` to `/etc/portage/make.conf` if you'd rather set this as a global USE flags, but this flags does unrelated things in other packages, so keep that in mind if you elect to do so. +If you are planning to use `nginx`, as this guide suggests, you should also add the following flag to the same file. + +```text +www-servers/nginx NGINX_MODULES_HTTP: slice +``` + Double check your compiler flags in `/etc/portage/make.conf`. If you require any special compilation flags or would like to set up remote builds, now is the time to do so. Be sure that your CFLAGS and MAKEOPTS make sense for the platform you are using. It is not recommended to use above `-O2` or risky optimization flags for a production server. ### Installing a cron daemon @@ -262,7 +268,7 @@ Even if you are using S3, Akkoma needs someplace to store media posted on your i ```shell akkoma$ mkdir -p ~/akkoma/uploads - ``` +``` #### init.d service @@ -272,7 +278,9 @@ Even if you are using S3, Akkoma needs someplace to store media posted on your i # cp /home/akkoma/akkoma/installation/init.d/akkoma /etc/init.d/ ``` -* Be sure to take a look at this service file and make sure that all paths fit your installation +* Change the `/opt/akkoma` path in this file to `/home/akkoma/akkoma` + +* Be sure to take a look at this service file and make sure that all other paths fit your installation * Enable and start `akkoma`: diff --git a/docs/docs/installation/migrating_from_source_otp_en.md b/docs/docs/installation/migrating_from_source_otp_en.md index 148564d9a..505f3cd6d 100644 --- a/docs/docs/installation/migrating_from_source_otp_en.md +++ b/docs/docs/installation/migrating_from_source_otp_en.md @@ -87,7 +87,7 @@ 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 akkoma -s $SHELL -lc " -curl 'https://akkoma-updates.s3-website.fr-par.scw.cloud/develop/akkoma-$FLAVOUR.zip' -o /tmp/akkoma.zip +curl 'https://akkoma-updates.s3-website.fr-par.scw.cloud/stable/akkoma-$FLAVOUR.zip' -o /tmp/akkoma.zip unzip /tmp/akkoma.zip -d /tmp/ " diff --git a/docs/docs/installation/migrating_to_akkoma.md b/docs/docs/installation/migrating_to_akkoma.md index d8ea0ea25..bf6eeebc7 100644 --- a/docs/docs/installation/migrating_to_akkoma.md +++ b/docs/docs/installation/migrating_to_akkoma.md @@ -21,6 +21,33 @@ fork of Akkoma - luckily this isn't very hard. You'll need to update the backend, then possibly the frontend, depending on your setup. +## Backup diverging features + +As time goes on Akkoma and Pleroma added or removed different features +and reorganised the database in a different way. If you want to be able to +migrate back to Pleroma without losing any affected data, you’ll want to +make a backup before starting the migration. +If you're not interested in migrating back, skip this section +*(although it might be a good idea to temporarily keep a full DB backup +just in case something unexpected happens during migration)* + +As of 2024-02 you will want to keep a backup of: + +- the entire `chats` and `chat_message_references` tables + +The following columns are not deleted by a migration to Akkoma, but a migration +back to Pleroma or future Akkoma upgrades might affect them, so perhaps back them up as well: + +- the `birthday` of users and their `show_birthday` setting +- the `expires_at` key of in the `user_relationships` table + *(used by temporary mutes)* + +The way cached instance metadata is stored differs, but since those +will be refetched and updated anyway, there’s no need for a backup. + +Best check all newer migrations unique to Akkoma/Pleroma +to get an up-to-date picture of what needs to be kept. + ## From Source If you're running the source Akkoma install, you'll need to set the @@ -34,7 +61,7 @@ git pull -r # to run "git merge stable" instead (or develop if you want) ``` -Then compile, migrate and restart as usual. +And compile as usual. ## From OTP @@ -44,15 +71,44 @@ This will just be setting the update URL - find your flavour from the [mapping o export FLAVOUR=[the flavour you found above] ./bin/pleroma_ctl update --zip-url https://akkoma-updates.s3-website.fr-par.scw.cloud/stable/akkoma-$FLAVOUR.zip -./bin/pleroma_ctl migrate ``` -Then restart. When updating in the future, you canjust use +When updating in the future, you can just use ```bash ./bin/pleroma_ctl update --branch stable ``` + +## Database Migrations +### WARNING - Migrating from Pleroma past 2022-08 +If you are on Pleroma stable >= 2.5.0 or Pleroma develop, and +have updated since 2022-08, you may have issues with database migrations. + +Please first roll back the given migrations: + +=== "OTP" + ```bash + ./bin/pleroma_ctl rollback --migrations-path priv/repo/optional_migrations/pleroma_develop_rollbacks -n5 + ``` +=== "From Source" + ```bash + MIX_ENV=prod mix ecto.rollback --migrations-path priv/repo/optional_migrations/pleroma_develop_rollbacks -n5 + ``` + +### Applying Akkoma Database Migrations + +Just run + +=== "OTP" + ```bash + ./bin/pleroma_ctl migrate + ``` +=== "From Source" + ```bash + MIX_ENV=prod mix ecto.migrate + ``` + ## Frontend changes Akkoma comes with a few frontend changes as well as backend ones, @@ -86,3 +142,39 @@ Your situation will likely be unique - you'll need the changes in the [forked pleroma-fe repository](https://akkoma.dev/AkkomaGang/pleroma-fe), and either merge or cherry-pick from there depending on how you've got things. + +## Common issues + +### The frontend doesn't show after installing it + +This may occur if you are using database configuration. + +Sometimes the config in your database will cause akkoma to still report +that there's no frontend, even when you've run the install. + +To fix this, run: + +=== "OTP" + ```sh + ./bin/pleroma_ctl config delete pleroma frontends + ``` + +=== "From Source" + ```sh + mix pleroma.config delete pleroma frontends + ``` + +which will remove the config from the database. Things should work now. + +## Migrating back to Pleroma + +Akkoma is a hard fork of Pleroma. As such, migrating back is not guaranteed to always work. But if you want to migrate back to Pleroma, you can always try. Just note that you may run into unexpected issues and you're basically on your own. The following are some tips that may help, but note that these are barely tested, so proceed at your own risk. + +First you will need to roll back the database migrations. The latest migration both Akkoma and Pleroma still have in common should be 20210416051708, so roll back to that. If you run from source, that should be + +```sh +MIX_ENV=prod mix ecto.rollback --to 20210416051708 +``` + +Then switch back to Pleroma for updates (similar to how was done to migrate to Akkoma), and remove the front-ends. The front-ends are installed in the `frontends` folder in the [static directory](../configuration/static_dir.md). Once you are back to Pleroma, you will need to run the database migrations again. See the Pleroma documentation for this. +After this use your previous backups to restore data from diverging features. diff --git a/docs/docs/installation/migrating_to_docker_en.md b/docs/docs/installation/migrating_to_docker_en.md index 945f43090..6a67f1eee 100644 --- a/docs/docs/installation/migrating_to_docker_en.md +++ b/docs/docs/installation/migrating_to_docker_en.md @@ -10,7 +10,7 @@ You probably should, in the first instance. ### Prepare the system -* Install docker and docker-compose +* Install docker and docker compose * [Docker](https://docs.docker.com/engine/install/) * [Docker-compose](https://docs.docker.com/compose/install/) * This will usually just be a repository installation and a package manager invocation. @@ -46,7 +46,7 @@ For *most* from-source installs it'll already be there. And the same with `uploads`, make sure your uploads (if you have them on disk) are located at `uploads/` in the akkoma source directory. -If you have them on a different disk, you will need to mount that disk into the docker-compose file, +If you have them on a different disk, you will need to mount that disk into the docker compose file, with an entry that looks like this: ```yaml @@ -66,7 +66,7 @@ echo "DOCKER_USER=$(id -u):$(id -g)" >> .env ``` This probably won't need to be changed, it's only there to set basic environment -variables for the docker-compose file. +variables for the docker compose file. === "From source" @@ -126,21 +126,21 @@ mkdir pgdata Now we can import our database to the container. ```bash -docker-compose run --rm --user akkoma -d db -docker-compose run --rm akkoma pg_restore -v -U akkoma -j $(grep -c ^processor /proc/cpuinfo) -d akkoma -h db akkoma_backup.sql +docker compose run --rm --user akkoma -d db +docker compose run --rm akkoma pg_restore -v -U akkoma -j $(grep -c ^processor /proc/cpuinfo) -d akkoma -h db akkoma_backup.sql ``` ### Reverse proxies If you're just reusing your old proxy, you may have to uncomment the line in -the docker-compose file under `ports`. You'll find it. +the docker compose file under `ports`. You'll find it. Otherwise, you can use the same setup as the [docker installation guide](./docker_en.md#reverse-proxies). ### Let's go ```bash -docker-compose up -d +docker compose up -d ``` You should now be at the same point as you were before, but with a docker install. diff --git a/docs/docs/installation/openbsd_en.md b/docs/docs/installation/openbsd_en.md index 581942f99..51909fdd7 100644 --- a/docs/docs/installation/openbsd_en.md +++ b/docs/docs/installation/openbsd_en.md @@ -1,6 +1,6 @@ # Installing on OpenBSD -This guide describes the installation and configuration of akkoma (and the required software to run it) on a single OpenBSD 6.6 server. +This guide describes the installation and configuration of akkoma (and the required software to run it) on a single OpenBSD 7.2 server. For any additional information regarding commands and configuration files mentioned here, check the man pages [online](https://man.openbsd.org/) or directly on your server with the man command. @@ -12,7 +12,8 @@ For any additional information regarding commands and configuration files mentio To install them, run the following command (with doas or as root): ``` -pkg_add elixir gmake git postgresql-server postgresql-contrib cmake ffmpeg ImageMagick +pkg_add elixir gmake git postgresql-server postgresql-contrib cmake ffmpeg erlang-wx libmagic +pkg_add erlang-wx # Choose the latest version as package version when promted ``` Akkoma requires a reverse proxy, OpenBSD has relayd in base (and is used in this guide) and packages/ports are available for nginx (www/nginx) and apache (www/apache-httpd). Independently of the reverse proxy, [acme-client(1)](https://man.openbsd.org/acme-client) can be used to get a certificate from Let's Encrypt. @@ -27,32 +28,35 @@ Per [`docs/installation/optional/media_graphics_packages.md`](../installation/op To install the above: ``` -pkg_add ImageMagick ffmpeg p5-Image-ExifTool +pkg_add ffmpeg p5-Image-ExifTool ``` #### Creating the akkoma user -Akkoma will be run by a dedicated user, \_akkoma. Before creating it, insert the following lines in login.conf: +Akkoma will be run by a dedicated user, `_akkoma`. Before creating it, insert the following lines in `/etc/login.conf`: ``` akkoma:\ :datasize-max=1536M:\ :datasize-cur=1536M:\ :openfiles-max=4096 ``` -This creates a "akkoma" login class and sets higher values than default for datasize and openfiles (see [login.conf(5)](https://man.openbsd.org/login.conf)), this is required to avoid having akkoma crash some time after starting. +This creates a `akkoma` login class and sets higher values than default for datasize and openfiles (see [login.conf(5)](https://man.openbsd.org/login.conf)), this is required to avoid having akkoma crash some time after starting. -Create the \_akkoma user, assign it the akkoma login class and create its home directory (/home/\_akkoma/): `useradd -m -L akkoma _akkoma` +Create the `_akkoma` user, assign it the akkoma login class and create its home directory (`/home/_akkoma/`): `useradd -m -L akkoma _akkoma` #### Clone akkoma's directory -Enter a shell as the \_akkoma user. As root, run `su _akkoma -;cd`. Then clone the repository with `git clone https://akkoma.dev/AkkomaGang/akkoma.git`. Akkoma is now installed in /home/\_akkoma/akkoma/, it will be configured and started at the end of this guide. +Enter a shell as the `_akkoma` user. As root, run `su _akkoma -;cd`. Then clone the repository with `git clone https://akkoma.dev/AkkomaGang/akkoma.git`. Akkoma is now installed in `/home/_akkoma/akkoma/`, it will be configured and started at the end of this guide. #### PostgreSQL -Start a shell as the \_postgresql user (as root run `su _postgresql -` then run the `initdb` command to initialize postgresql: -You will need to specify pgdata directory to the default (/var/postgresql/data) with the `-D ` and set the user to postgres with the `-U ` flag. This can be done as follows: +Create `_postgresql`'s user directory (it hasn't been created yet): `mdir var/postgresql/data`. To set it as home +directory for user `_postgresql` run `usermod -d /var/postgresql/data _postgresql`. + +Start a shell as the `_postgresql` user (as root run `su _postgresql -` then run the `initdb` command to initialize postgresql. +You will need to specify pgdata directory to the default (`/var/postgresql/data`) with the `-D ` and set the user to postgres with the `-U ` flag. This can be done as follows: ``` initdb -D /var/postgresql/data -U postgres ``` -If you are not using the default directory, you will have to update the `datadir` variable in the /etc/rc.d/postgresql script. +If you are not using the default directory, you will have to update the `datadir` variable in the `/etc/rc.d/postgresql` script. When this is done, enable postgresql so that it starts on boot and start it. As root, run: ``` @@ -68,7 +72,7 @@ httpd will have three fuctions: * serve a robots.txt file * get Let's Encrypt certificates, with acme-client -Insert the following config in httpd.conf: +Insert the following config in `/etc/httpd.conf`: ``` # $OpenBSD: httpd.conf,v 1.17 2017/04/16 08:50:49 ajacoutot Exp $ @@ -91,13 +95,10 @@ server "default" { location "/robots.txt" { root "/htdocs/local/" } location "/*" { block return 302 "https://$HTTP_HOST$REQUEST_URI" } } - -types { -} ``` Do not forget to change ** to your server's address(es). If httpd should only listen on one protocol family, comment one of the two first *listen* options. -Create the /var/www/htdocs/local/ folder and write the content of your robots.txt in /var/www/htdocs/local/robots.txt. +Create the `/var/www/htdocs/local/` folder and write the content of your robots.txt in `/var/www/htdocs/local/robots.txt`. Check the configuration with `httpd -n`, if it is OK enable and start httpd (as root): ``` rcctl enable httpd @@ -106,7 +107,7 @@ rcctl start httpd #### acme-client acme-client is used to get SSL/TLS certificates from Let's Encrypt. -Insert the following configuration in /etc/acme-client.conf: +Insert the following configuration in `/etc/acme-client.conf`: ``` # # $OpenBSD: acme-client.conf,v 1.4 2017/03/22 11:14:14 benno Exp $ @@ -127,7 +128,7 @@ domain { } ``` Replace ** by the domain name you'll use for your instance. As root, run `acme-client -n` to check the config, then `acme-client -ADv ` to create account and domain keys, and request a certificate for the first time. -Make acme-client run everyday by adding it in /etc/daily.local. As root, run the following command: `echo "acme-client " >> /etc/daily.local`. +Make acme-client run everyday by adding it in `/etc/daily.local`. As root, run the following command: `echo "acme-client " >> /etc/daily.local`. Relayd will look for certificates and keys based on the address it listens on (see next part), the easiest way to make them available to relayd is to create a link, as root run: ``` @@ -138,7 +139,7 @@ This will have to be done for each IPv4 and IPv6 address relayd listens on. #### relayd relayd will be used as the reverse proxy sitting in front of akkoma. -Insert the following configuration in /etc/relayd.conf: +Insert the following configuration in `/etc/relayd.conf`: ``` # $OpenBSD: relayd.conf,v 1.4 2018/03/23 09:55:06 claudio Exp $ @@ -160,15 +161,14 @@ http protocol plerup { # Protocol for upstream akkoma server match request header append "X-Forwarded-For" value "$REMOTE_ADDR" # This two header and the next one are not strictly required by akkoma but adding them won't hurt match request header append "X-Forwarded-By" value "$SERVER_ADDR:$SERVER_PORT" - match response header append "X-XSS-Protection" value "1; mode=block" + match response header append "X-XSS-Protection" value "0" match response header append "X-Permitted-Cross-Domain-Policies" value "none" match response header append "X-Frame-Options" value "DENY" match response header append "X-Content-Type-Options" value "nosniff" match response header append "Referrer-Policy" value "same-origin" - match response header append "X-Download-Options" value "noopen" - match response header append "Content-Security-Policy" value "default-src 'none'; base-uri 'self'; form-action 'self'; img-src 'self' data: https:; media-src 'self' https:; style-src 'self' 'unsafe-inline'; font-src 'self'; script-src 'self'; connect-src 'self' wss://CHANGEME.tld; upgrade-insecure-requests;" # Modify "CHANGEME.tld" and set your instance's domain here + match response header append "Content-Security-Policy" value "default-src 'none'; base-uri 'none'; form-action 'self'; img-src 'self' data: https:; media-src 'self' https:; style-src 'self' 'unsafe-inline'; font-src 'self'; script-src 'self'; connect-src 'self' wss://CHANGEME.tld; upgrade-insecure-requests;" # Modify "CHANGEME.tld" and set your instance's domain here match request header append "Connection" value "upgrade" - #match response header append "Strict-Transport-Security" value "max-age=31536000; includeSubDomains" # Uncomment this only after you get HTTPS working. + #match response header append "Strict-Transport-Security" value "max-age=63072000; includeSubDomains; preload" # Uncomment this only after you get HTTPS working. # If you do not want remote frontends to be able to access your Akkoma backend server, comment these lines match response header append "Access-Control-Allow-Origin" value "*" @@ -197,7 +197,7 @@ rcctl start relayd #### pf Enabling and configuring pf is highly recommended. -In /etc/pf.conf, insert the following configuration: +In `/etc/pf.conf`, insert the following configuration: ``` # Macros if="" @@ -221,31 +221,30 @@ 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`. #### Configure and start akkoma -Enter a shell as \_akkoma (as root `su _akkoma -`) and enter akkoma's installation directory (`cd ~/akkoma/`). +Enter a shell as `_akkoma` (as root `su _akkoma -`) and enter akkoma's installation directory (`cd ~/akkoma/`). Then follow the main installation guide: * run `mix deps.get` * run `MIX_ENV=prod mix pleroma.instance gen` and enter your instance's information when asked - * copy config/generated\_config.exs to config/prod.secret.exs. The default values should be sufficient but you should edit it and check that everything seems OK. + * copy `config/generated_config.exs` to `config/prod.secret.exs`. The default values should be sufficient but you should edit it and check that everything seems OK. * exit your current shell back to a root one and run `psql -U postgres -f /home/_akkoma/akkoma/config/setup_db.psql` to setup the database. - * return to a \_akkoma shell into akkoma's installation directory (`su _akkoma -;cd ~/akkoma`) and run `MIX_ENV=prod mix ecto.migrate` + * return to a `_akkoma` shell into akkoma's installation directory (`su _akkoma -;cd ~/akkoma`) and run `MIX_ENV=prod mix ecto.migrate` -As \_akkoma in /home/\_akkoma/akkoma, you can now run `LC_ALL=en_US.UTF-8 MIX_ENV=prod mix phx.server` to start your instance. +As `_akkoma` in `/home/_akkoma/akkoma`, you can now run `LC_ALL=en_US.UTF-8 MIX_ENV=prod mix phx.server` to start your instance. In another SSH session/tmux window, check that it is working properly by running `ftp -MVo - http://127.0.0.1:4000/api/v1/instance`, you should get json output. Double-check that *uri*'s value is your instance's domain name. ##### Starting akkoma at boot An rc script to automatically start akkoma at boot hasn't been written yet, it can be run in a tmux session (tmux is in base). - #### Create administrative user -If your instance is up and running, you can create your first user with administrative rights with the following command as the \_akkoma user. +If your instance is up and running, you can create your first user with administrative rights with the following command as the `_akkoma` user. ``` LC_ALL=en_US.UTF-8 MIX_ENV=prod mix pleroma.user new --admin ``` diff --git a/docs/docs/installation/optional/media_graphics_packages.md b/docs/docs/installation/optional/media_graphics_packages.md index de402d1c4..cbde2a952 100644 --- a/docs/docs/installation/optional/media_graphics_packages.md +++ b/docs/docs/installation/optional/media_graphics_packages.md @@ -1,33 +1,33 @@ # 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` +For specific Akkoma functionality (which is disabled by default) some or all of the below packages are required: + * `ImageMagick` + * `ffmpeg` + * `exiftool` Please refer to documentation in `docs/installation` on how to install them on specific OS. -Note: the packages are not required with the current default settings of Pleroma. +Note: the packages are not required with the current default settings of Akkoma. ## `ImageMagick` `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`) +It is required for the following Akkoma 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`) ## `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`) +It is required for the following Akkoma features: + * 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`) +It is required for the following Akkoma 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`) diff --git a/docs/docs/installation/otp_en.md b/docs/docs/installation/otp_en.md index 3e00d3262..8a8ae077b 100644 --- a/docs/docs/installation/otp_en.md +++ b/docs/docs/installation/otp_en.md @@ -5,7 +5,7 @@ This guide covers a installation using an OTP release. To install Akkoma 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 running Linux with GNU (e.g. Debian, Ubuntu) or musl (e.g. Alpine) libc and an `x86_64` or `arm64` CPU you have root access to. If you are not sure if it's compatible see [Detecting flavour section](#detecting-flavour) below * For installing OTP releases on RedHat-based distros like Fedora and Centos Stream, please follow [this guide](./otp_redhat_en.md) instead. * A (sub)domain pointed to the machine @@ -15,16 +15,16 @@ While in theory OTP releases are possbile to install on any compatible machine, ### Detecting flavour -This is a little more complex than it used to be (thanks ubuntu) - Use the following mapping to figure out your flavour: -| distribution | flavour | available branches | -| ------------- | ------------------ | ------------------- | -| debian stable | amd64 | develop, stable | -| ubuntu focal | amd64 | develop, stable | -| ubuntu jammy | amd64-ubuntu-jammy | develop, stable | -| alpine | amd64-musl | stable | +| distribution | architecture | flavour | available branches | +| --------------- | ------------------ | ------------------- | ------------------- | +| debian bookworm | amd64 | amd64 | develop, stable | +| debian bookworm | arm64 | arm64 | stable | +| ubuntu jammy | amd64 | amd64 | develop, stable | +| ubuntu jammy | arm64 | arm64 | develop, stable | +| alpine | amd64 | amd64-musl | stable | +| alpine | arm64 | arm64-musl | stable | Other similar distributions will _probably_ work, but if it is not listed above, there is no official support. @@ -119,11 +119,15 @@ adduser --system --shell /bin/false --home /opt/akkoma akkoma # Set the flavour environment variable to the string you got in Detecting flavour section. # For example if the flavour is `amd64-musl` the command will be -export FLAVOUR="amd64-musl" +# export FLAVOUR="amd64-musl" +export FLAVOUR="" + +# Make sure the SHELL variable is set +export SHELL="${SHELL:-/bin/sh}" # Clone the release build into a temporary directory and unpack it su akkoma -s $SHELL -lc " -curl 'https://akkoma-updates.s3-website.fr-par.scw.cloud/develop/akkoma-$FLAVOUR.zip' -o /tmp/akkoma.zip +curl 'https://akkoma-updates.s3-website.fr-par.scw.cloud/stable/akkoma-$FLAVOUR.zip' -o /tmp/akkoma.zip unzip /tmp/akkoma.zip -d /tmp/ " @@ -183,18 +187,18 @@ The location of nginx configs is dependent on the distro === "Alpine" ``` - cp /opt/akkoma/installation/nginx/akkoma.nginx /etc/nginx/conf.d/akkoma.conf + cp /opt/akkoma/installation/akkoma.nginx /etc/nginx/conf.d/akkoma.conf ``` === "Debian/Ubuntu" ``` - cp /opt/akkoma/installation/nginx/akkoma.nginx /etc/nginx/sites-available/akkoma.conf + cp /opt/akkoma/installation/akkoma.nginx /etc/nginx/sites-available/akkoma.conf ln -s /etc/nginx/sites-available/akkoma.conf /etc/nginx/sites-enabled/akkoma.conf ``` If your distro does not have either of those you can append `include /etc/nginx/akkoma.conf` to the end of the http section in /etc/nginx/nginx.conf and ```sh -cp /opt/akkoma/installation/nginx/akkoma.nginx /etc/nginx/akkoma.conf +cp /opt/akkoma/installation/akkoma.nginx /etc/nginx/akkoma.conf ``` #### Edit the nginx config diff --git a/docs/docs/installation/otp_redhat_en.md b/docs/docs/installation/otp_redhat_en.md index ec6c30bcf..ea27af6f4 100644 --- a/docs/docs/installation/otp_redhat_en.md +++ b/docs/docs/installation/otp_redhat_en.md @@ -37,7 +37,7 @@ sudo dnf install git gcc g++ erlang elixir erlang-os_mon erlang-eldap erlang-xme ```shell cd ~ -git clone https://akkoma.dev/AkkomaGang/akkoma.git +git clone https://akkoma.dev/AkkomaGang/akkoma.git -b stable ``` * Change to the new directory: @@ -178,7 +178,7 @@ certbot certonly --standalone --preferred-challenges http -d yourinstance.tld #### Copy Akkoma nginx configuration to the nginx folder ```shell -cp /opt/akkoma/installation/nginx/akkoma.nginx /etc/nginx/conf.d/akkoma.conf +cp /opt/akkoma/installation/akkoma.nginx /etc/nginx/conf.d/akkoma.conf ``` #### Edit the nginx config diff --git a/docs/docs/installation/verifying_otp_releases.md b/docs/docs/installation/verifying_otp_releases.md index 86dacfec2..6e3c6f8ca 100644 --- a/docs/docs/installation/verifying_otp_releases.md +++ b/docs/docs/installation/verifying_otp_releases.md @@ -4,7 +4,7 @@ All stable OTP releases are cryptographically signed, to allow you to verify the integrity if you choose to. Releases are signed with [Signify](https://man.openbsd.org/signify.1), -with [the public key in the main repository](https://akkoma.dev/AkkomaGang/akkoma/src/branch/develop/SIGNING_KEY.pub) +with [the public key in the main repository](https://akkoma.dev/AkkomaGang/akkoma/src/branch/stable/SIGNING_KEY.pub) Release URLs will always be of the form @@ -12,7 +12,7 @@ Release URLs will always be of the form https://akkoma-updates.s3-website.fr-par.scw.cloud/{branch}/akkoma-{flavour}.zip ``` -Where branch is usually `stable` or `develop`, and `flavour` is +Where branch is usually `stable` and `flavour` is the one [that you detect on install](../otp_en/#detecting-flavour). So, for an AMD64 stable install, your update URL will be diff --git a/docs/docs/installation/yunohost_en.md b/docs/docs/installation/yunohost_en.md new file mode 100644 index 000000000..0d3adb4fe --- /dev/null +++ b/docs/docs/installation/yunohost_en.md @@ -0,0 +1,9 @@ +# Installing on Yunohost + +[YunoHost](https://yunohost.org) is a server operating system aimed at self-hosting. The YunoHost community maintains a package of Akkoma which allows you to install Akkoma on YunoHost. You can install it via the normal way through the admin web interface, or through the CLI. More information can be found at [the repo of the package](https://github.com/YunoHost-Apps/akkoma_ynh). + +## Questions + +Questions and problems related to the YunoHost parts can be done through the [YunoHost channels](https://yunohost.org/en/help). + +For questions about Akkoma, check out the [Akkoma community channels](../../#community-channels). diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index c19439942..655abdd69 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -1,16 +1,32 @@ site_name: Akkoma Documentation theme: - favicon: 'images/akko_badday.png' + favicon: 'images/favicon.ico' name: 'material' custom_dir: 'theme' # Disable google fonts font: false - logo: 'images/akko_badday.png' + logo: 'images/logo.png' features: - - tabs + - navigation.tabs + - toc.follow + - navigation.instant + - navigation.sections palette: - primary: 'deep purple' - accent: 'blue grey' + - media: "(prefers-color-scheme: light)" + scheme: default + toggle: + icon: material/brightness-7 + name: Switch to dark mode + primary: 'deep purple' + accent: 'blue grey' + + - media: "(prefers-color-scheme: dark)" + scheme: slate + toggle: + icon: material/brightness-4 + name: Switch to light mode + primary: 'deep purple' + accent: 'blue grey' extra_css: - css/extra.css @@ -31,7 +47,8 @@ markdown_extensions: - pymdownx.tasklist: custom_checkbox: true - pymdownx.superfences - - pymdownx.tabbed + - pymdownx.tabbed: + alternate_style: true - pymdownx.details - markdown_include.include: base_path: docs diff --git a/docs/requirements.txt b/docs/requirements.txt index 70fbd9dc4..d67bbf65f 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,22 +1,26 @@ +certifi==2022.9.24 +charset-normalizer==2.1.1 click==8.1.3 ghp-import==2.1.0 +idna==3.4 importlib-metadata==4.12.0 Jinja2==3.1.2 Markdown==3.3.7 -markdown-include==0.6.0 +markdown-include==0.7.0 MarkupSafe==2.1.1 mergedeep==1.3.4 -mkdocs==1.3.0 -mkdocs-bootswatch==1.1 -mkdocs-material==8.1.8 -mkdocs-material-extensions==1.0.3 +mkdocs==1.4.2 +mkdocs-material==8.5.9 +mkdocs-material-extensions==1.1 packaging==21.3 -Pygments==2.11.2 -pymdown-extensions==9.1 +Pygments==2.13.0 +pymdown-extensions==9.8 pyparsing==3.0.9 python-dateutil==2.8.2 PyYAML==6.0 pyyaml_env_tag==0.1 +requests==2.28.1 six==1.16.0 +urllib3==1.26.12 watchdog==2.1.9 zipp==3.8.0 diff --git a/docs/theme/partials/source.html b/docs/theme/partials/source.html index 3ed0fff24..3b10223b1 100644 --- a/docs/theme/partials/source.html +++ b/docs/theme/partials/source.html @@ -38,11 +38,11 @@ {% endif %} {% if page and page.url.startswith('backend') %} - {% set repo_url = "https://git.pleroma.social/pleroma/pleroma" %} - {% set repo_name = "pleroma/pleroma" %} + {% set repo_url = "https://akkoma.dev/AkkomaGang/akkoma" %} + {% set repo_name = "AkkomaGang/akkoma" %} {% elif page and page.url.startswith('frontend') %} - {% set repo_url = "https://git.pleroma.social/pleroma/pleroma-fe" %} - {% set repo_name = "pleroma/pleroma-fe" %} + {% set repo_url = "https://akkoma.dev/AkkomaGang/akkoma-fe" %} + {% set repo_name = "AkkomaGang/akkoma-fe" %} {% else %} {% set repo_url = config.repo_url %} {% set repo_name = config.repo_name %} diff --git a/elixir_buildpack.config b/elixir_buildpack.config index 946408c12..ee9e051a6 100644 --- a/elixir_buildpack.config +++ b/elixir_buildpack.config @@ -1,2 +1,2 @@ -elixir_version=1.9.4 -erlang_version=22.3.4.1 +elixir_version=1.14.3 +erlang_version=25.3 diff --git a/installation/akkoma.service b/installation/akkoma.service index caddf7c4b..717693495 100644 --- a/installation/akkoma.service +++ b/installation/akkoma.service @@ -4,14 +4,21 @@ After=network.target postgresql.service [Service] ExecReload=/bin/kill $MAINPID -KillMode=process Restart=on-failure +; Uncomment this if you're on Arch Linux +; Environment="PATH=/usr/local/sbin:/usr/local/bin:/usr/bin:/usr/bin/site_perl:/usr/bin/vendor_perl:/usr/bin/core_perl" +; Uncomment if using asdf to manage Elixir and Erlang +; Environment="PATH=/var/lib/akkoma/.asdf/shims:/var/lib/akkoma/.asdf/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + ; Name of the user that runs the Akkoma service. User=akkoma ; Declares that Akkoma runs in production mode. Environment="MIX_ENV=prod" +; Don't listen epmd on 0.0.0.0 +Environment="ERL_EPMD_ADDRESS=127.0.0.1" + ; Make sure that all paths fit your installation. ; Path to the home directory of the user running the Akkoma service. Environment="HOME=/var/lib/akkoma" @@ -19,6 +26,8 @@ Environment="HOME=/var/lib/akkoma" WorkingDirectory=/opt/akkoma ; Path to the Mix binary. ExecStart=/usr/bin/mix phx.server +; If using asdf comment the above line and uncomment the one below instead +; ExecStart=/var/lib/akkoma/.asdf/shims/mix phx.server ; Some security directives. ; Use private /tmp and /var/tmp folders inside a new file system namespace, which are discarded after the process stops. @@ -29,6 +38,8 @@ ProtectHome=true ProtectSystem=full ; Sets up a new /dev mount for the process and only adds API pseudo devices like /dev/null, /dev/zero or /dev/random but not physical devices. Disabled by default because it may not work on devices like the Raspberry Pi. PrivateDevices=false +; Ensures that the service process and all its children can never gain new privileges through execve(). +NoNewPrivileges=true ; Drops the sysadmin capability from the daemon. CapabilityBoundingSet=~CAP_SYS_ADMIN diff --git a/installation/akkoma.supervisord b/installation/akkoma.supervisord index 8fd5e8d42..1e0ee9744 100644 --- a/installation/akkoma.supervisord +++ b/installation/akkoma.supervisord @@ -12,7 +12,8 @@ environment = HOME=/home/akkoma, USER=akkoma, PATH="/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin:/home/akkoma/bin:%(ENV_PATH)s", - PWD=/home/akkoma/akkoma + PWD=/home/akkoma/akkoma, + ERL_EPMD_ADDRESS=127.0.0.1 stdout_logfile=/home/akkoma/logs/stdout.log stdout_logfile_maxbytes=50MB stdout_logfile_backups=10 diff --git a/installation/akkoma.vcl b/installation/akkoma.vcl index 4752510ea..4eb2f3cfa 100644 --- a/installation/akkoma.vcl +++ b/installation/akkoma.vcl @@ -1,4 +1,5 @@ # Recommended varnishncsa logging format: '%h %l %u %t "%m %{X-Forwarded-Proto}i://%{Host}i%U%q %H" %s %b "%{Referer}i" "%{User-agent}i"' +# Please use Varnish 7.0+ for proper Range Requests / Chunked encoding support vcl 4.1; import std; @@ -22,11 +23,6 @@ sub vcl_recv { set req.http.X-Forwarded-Proto = "https"; } - # CHUNKED SUPPORT - if (req.http.Range ~ "bytes=") { - set req.http.x-range = req.http.Range; - } - # Pipe if WebSockets request is coming through if (req.http.upgrade ~ "(?i)websocket") { return (pipe); @@ -35,9 +31,9 @@ sub vcl_recv { # Allow purging of the cache if (req.method == "PURGE") { if (!client.ip ~ purge) { - return(synth(405,"Not allowed.")); + return (synth(405,"Not allowed.")); } - return(purge); + return (purge); } } @@ -53,17 +49,11 @@ sub vcl_backend_response { return (retry); } - # CHUNKED SUPPORT - if (bereq.http.x-range ~ "bytes=" && beresp.status == 206) { - set beresp.ttl = 10m; - set beresp.http.CR = beresp.http.content-range; - } - # Bypass cache for large files # 50000000 ~ 50MB if (std.integer(beresp.http.content-length, 0) > 50000000) { set beresp.uncacheable = true; - return(deliver); + return (deliver); } # Don't cache objects that require authentication @@ -94,7 +84,7 @@ sub vcl_synth { if (resp.status == 750) { set resp.status = 301; set resp.http.Location = req.http.x-redir; - return(deliver); + return (deliver); } } @@ -106,25 +96,12 @@ sub vcl_pipe { } } -sub vcl_hash { - # CHUNKED SUPPORT - if (req.http.x-range ~ "bytes=") { - hash_data(req.http.x-range); - unset req.http.Range; - } -} - sub vcl_backend_fetch { # Be more lenient for slow servers on the fediverse if (bereq.url ~ "^/proxy/") { set bereq.first_byte_timeout = 300s; } - # CHUNKED SUPPORT - if (bereq.http.x-range) { - set bereq.http.Range = bereq.http.x-range; - } - if (bereq.retries == 0) { # Clean up the X-Varnish-Backend-503 flag that is used internally # to mark broken backend responses that should be retried. @@ -143,14 +120,6 @@ sub vcl_backend_fetch { } } -sub vcl_deliver { - # CHUNKED SUPPORT - if (resp.http.CR) { - set resp.http.Content-Range = resp.http.CR; - unset resp.http.CR; - } -} - sub vcl_backend_error { # Retry broken backend responses. set bereq.http.X-Varnish-Backend-503 = "1"; diff --git a/installation/caddy/Caddyfile b/installation/caddy/Caddyfile index 5cc75a1fc..d50848207 100644 --- a/installation/caddy/Caddyfile +++ b/installation/caddy/Caddyfile @@ -4,6 +4,9 @@ # 1. Replace 'example.tld' with your instance's domain wherever it appears. # 2. Copy this section into your Caddyfile and restart Caddy. +# If you are able to, it's highly recommended to have your media served via a separate subdomain for improved security. +# Uncomment the relevant sectons here and modify the base_url setting for Pleroma.Upload and :media_proxy accordingly. + example.tld { log { output file /var/log/caddy/akkoma.log @@ -14,4 +17,21 @@ example.tld { # this is explicitly IPv4 since Pleroma.Web.Endpoint binds on IPv4 only # and `localhost.` resolves to [::0] on some systems: see issue #930 reverse_proxy 127.0.0.1:4000 + + # Uncomment if using a separate media subdomain + #@mediaproxy path /media/* /proxy/* + #handle @mediaproxy { + # redir https://media.example.tld{uri} permanent + #} } + +# Uncomment if using a separate media subdomain +#media.example.tld { +# @mediaproxy path /media/* /proxy/* +# reverse_proxy @mediaproxy 127.0.0.1:4000 { +# transport http { +# response_header_timeout 10s +# read_timeout 15s +# } +# } +#} diff --git a/installation/download-mastofe-build.sh b/installation/download-mastofe-build.sh deleted file mode 100755 index ee353c48c..000000000 --- a/installation/download-mastofe-build.sh +++ /dev/null @@ -1,48 +0,0 @@ -#!/bin/sh -# Pleroma: A lightweight social networking server -# Copyright © 2017-2021 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only -project_id="74" -project_branch="rebase/glitch-soc" -static_dir="instance/static" -# For bundling: -# project_branch="pleroma" -# static_dir="priv/static" - -if [ ! -d "${static_dir}" ] -then - echo "Error: ${static_dir} directory is missing, are you sure you are running this script at the root of pleroma’s repository?" - exit 1 -fi - -last_modified="$(curl --fail -s -I 'https://git.pleroma.social/api/v4/projects/'${project_id}'/jobs/artifacts/'${project_branch}'/download?job=build' | grep '^Last-Modified:' | cut -d: -f2-)" - -echo "branch:${project_branch}" -echo "Last-Modified:${last_modified}" - -artifact="mastofe.zip" - -if [ "${last_modified}x" = "x" ] -then - echo "ERROR: Couldn't get the modification date of the latest build archive, maybe it expired, exiting..." - exit 1 -fi - -if [ -e mastofe.timestamp ] && [ "$(cat mastofe.timestamp)" = "${last_modified}" ] -then - echo "MastoFE is up-to-date, exiting..." - exit 0 -fi - -curl --fail -c - "https://git.pleroma.social/api/v4/projects/${project_id}/jobs/artifacts/${project_branch}/download?job=build" -o "${artifact}" || exit - -# TODO: Update the emoji as well -rm -fr "${static_dir}/sw.js" "${static_dir}/packs" || exit -unzip -q "${artifact}" || exit - -cp public/assets/sw.js "${static_dir}/sw.js" || exit -cp -r public/packs "${static_dir}/packs" || exit - -echo "${last_modified}" > mastofe.timestamp -rm -fr public -rm -i "${artifact}" diff --git a/installation/freebsd/rc.d/akkoma b/installation/freebsd/rc.d/akkoma index 38186522b..e87c26b57 100755 --- a/installation/freebsd/rc.d/akkoma +++ b/installation/freebsd/rc.d/akkoma @@ -18,7 +18,8 @@ load_rc_config ${name} : ${akkoma_user:=akkoma} : ${akkoma_home:=$(getent passwd ${akkoma_user} | awk -F: '{print $6}')} : ${akkoma_chdir:="${akkoma_home}/akkoma"} -: ${akkoma_env:="HOME=${akkoma_home} MIX_ENV=prod"} +: ${akkoma_env:="HOME=${akkoma_home} MIX_ENV=prod ERL_EPMD_ADDRESS=127.0.0.1"} + command=/usr/local/bin/elixir command_args="--erl \"-detached\" -S /usr/local/bin/mix phx.server" diff --git a/installation/init.d/akkoma b/installation/init.d/akkoma index bf70c34fb..bd17516f2 100755 --- a/installation/init.d/akkoma +++ b/installation/init.d/akkoma @@ -8,6 +8,7 @@ pidfile="/var/run/akkoma.pid" directory=/opt/akkoma healthcheck_delay=60 healthcheck_timer=30 +no_new_privs="yes" : ${akkoma_port:-4000} @@ -31,6 +32,7 @@ else fi export MIX_ENV=prod +export ERL_EPMD_ADDRESS=127.0.0.1 depend() { need nginx postgresql diff --git a/installation/netbsd/rc.d/akkoma b/installation/netbsd/rc.d/akkoma index 7b80bc414..6dfe80f4a 100755 --- a/installation/netbsd/rc.d/akkoma +++ b/installation/netbsd/rc.d/akkoma @@ -14,7 +14,7 @@ start_precmd="ulimit -n unlimited" pidfile="/dev/null" akkoma_chdir="${akkoma_home}/akkoma" -akkoma_env="HOME=${akkoma_home} MIX_ENV=prod" +akkoma_env="HOME=${akkoma_home} MIX_ENV=prod ERL_EPMD_ADDRESS=127.0.0.1" check_pidfile() { diff --git a/installation/nginx/akkoma.nginx b/installation/nginx/akkoma.nginx index 772716677..1d91ce22f 100644 --- a/installation/nginx/akkoma.nginx +++ b/installation/nginx/akkoma.nginx @@ -54,8 +54,6 @@ server { 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; @@ -77,9 +75,48 @@ server { proxy_set_header Host $http_host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + location ~ ^/(media|proxy) { + return 404; + } + location / { proxy_pass http://phoenix; } +} + +# Upload and MediaProxy Subdomain +# (see main domain setup for more details) +server { + server_name media.example.tld; + + listen 80; + listen [::]:80; + + location / { + return 301 https://$server_name$request_uri; + } +} + +server { + server_name media.example.tld; + + listen 443 ssl http2; + listen [::]:443 ssl http2; + + ssl_trusted_certificate /etc/letsencrypt/live/media.example.tld/chain.pem; + ssl_certificate /etc/letsencrypt/live/media.example.tld/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/media.example.tld/privkey.pem; + # .. copy all other the ssl_* and gzip_* stuff from main domain + + # 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 ~ ^/(media|proxy) { proxy_cache akkoma_media_cache; @@ -93,4 +130,8 @@ server { chunked_transfer_encoding on; proxy_pass http://phoenix; } + + location / { + return 404; + } } diff --git a/lib/mix/tasks/pleroma/activity.ex b/lib/mix/tasks/pleroma/activity.ex index 3a79d8f20..84b9c16f9 100644 --- a/lib/mix/tasks/pleroma/activity.ex +++ b/lib/mix/tasks/pleroma/activity.ex @@ -1,3 +1,4 @@ +# credo:disable-for-this-file # Pleroma: A lightweight social networking server # Copyright © 2017-2018 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex index c259a6cbd..8661d8d7c 100644 --- a/lib/mix/tasks/pleroma/config.ex +++ b/lib/mix/tasks/pleroma/config.ex @@ -79,6 +79,45 @@ def run(["dump", group]) do end) end + def run(["dump_to_file", group, key, fname]) do + check_configdb(fn -> + start_pleroma() + + group = maybe_atomize(group) + key = maybe_atomize(key) + + config = ConfigDB.get_by_group_and_key(group, key) + + json = + %{ + group: ConfigDB.to_json_types(config.group), + key: ConfigDB.to_json_types(config.key), + value: ConfigDB.to_json_types(config.value) + } + |> Jason.encode!() + |> Jason.Formatter.pretty_print() + + File.write(fname, json) + shell_info("Wrote #{group}_#{key}.json") + end) + end + + def run(["load_from_file", fname]) do + check_configdb(fn -> + start_pleroma() + + json = File.read!(fname) + config = Jason.decode!(json) + group = ConfigDB.to_elixir_types(config["group"]) + key = ConfigDB.to_elixir_types(config["key"]) + value = ConfigDB.to_elixir_types(config["value"]) + params = %{group: group, key: key, value: value} + + ConfigDB.update_or_create(params) + shell_info("Loaded #{config["group"]}, #{config["key"]}") + end) + end + def run(["groups"]) do check_configdb(fn -> start_pleroma() diff --git a/lib/mix/tasks/pleroma/database.ex b/lib/mix/tasks/pleroma/database.ex index 99897e83e..09d2a4072 100644 --- a/lib/mix/tasks/pleroma/database.ex +++ b/lib/mix/tasks/pleroma/database.ex @@ -67,49 +67,181 @@ def run(["prune_objects" | args]) do OptionParser.parse( args, strict: [ - vacuum: :boolean + vacuum: :boolean, + keep_threads: :boolean, + keep_non_public: :boolean, + prune_orphaned_activities: :boolean ] ) start_pleroma() deadline = Pleroma.Config.get([:instance, :remote_post_retention_days]) + time_deadline = NaiveDateTime.utc_now() |> NaiveDateTime.add(-(deadline * 86_400)) - Logger.info("Pruning objects older than #{deadline} days") + log_message = "Pruning objects older than #{deadline} days" - time_deadline = - NaiveDateTime.utc_now() - |> NaiveDateTime.add(-(deadline * 86_400)) + log_message = + if Keyword.get(options, :keep_non_public) do + log_message <> ", keeping non public posts" + else + log_message + end - from(o in Object, - where: - fragment( - "?->'to' \\? ? OR ?->'cc' \\? ?", - o.data, - ^Pleroma.Constants.as_public(), - o.data, - ^Pleroma.Constants.as_public() - ), - where: o.inserted_at < ^time_deadline, - where: + log_message = + if Keyword.get(options, :keep_threads) do + log_message <> ", keeping threads intact" + else + log_message + end + + log_message = + if Keyword.get(options, :prune_orphaned_activities) do + log_message <> ", pruning orphaned activities" + else + log_message + end + + log_message = + if Keyword.get(options, :vacuum) do + log_message <> + ", doing a full vacuum (you shouldn't do this as a recurring maintanance task)" + else + log_message + end + + Logger.info(log_message) + + if Keyword.get(options, :keep_threads) do + # We want to delete objects from threads where + # 1. the newest post is still old + # 2. none of the activities is local + # 3. none of the activities is bookmarked + # 4. optionally none of the posts is non-public + deletable_context = + if Keyword.get(options, :keep_non_public) do + Pleroma.Activity + |> join(:left, [a], b in Pleroma.Bookmark, on: a.id == b.activity_id) + |> group_by([a], fragment("? ->> 'context'::text", a.data)) + |> having( + [a], + not fragment( + # Posts (checked on Create Activity) is non-public + "bool_or((not(?->'to' \\? ? OR ?->'cc' \\? ?)) and ? ->> 'type' = 'Create')", + a.data, + ^Pleroma.Constants.as_public(), + a.data, + ^Pleroma.Constants.as_public(), + a.data + ) + ) + else + Pleroma.Activity + |> join(:left, [a], b in Pleroma.Bookmark, on: a.id == b.activity_id) + |> group_by([a], fragment("? ->> 'context'::text", a.data)) + end + |> having([a], max(a.updated_at) < ^time_deadline) + |> having([a], not fragment("bool_or(?)", a.local)) + |> having([_, b], fragment("max(?::text) is null", b.id)) + |> select([a], fragment("? ->> 'context'::text", a.data)) + + Pleroma.Object + |> where([o], fragment("? ->> 'context'::text", o.data) in subquery(deletable_context)) + else + if Keyword.get(options, :keep_non_public) do + Pleroma.Object + |> where( + [o], + fragment( + "?->'to' \\? ? OR ?->'cc' \\? ?", + o.data, + ^Pleroma.Constants.as_public(), + o.data, + ^Pleroma.Constants.as_public() + ) + ) + else + Pleroma.Object + end + |> where([o], o.updated_at < ^time_deadline) + |> where( + [o], fragment("split_part(?->>'actor', '/', 3) != ?", o.data, ^Pleroma.Web.Endpoint.host()) - ) + ) + end |> Repo.delete_all(timeout: :infinity) - prune_hashtags_query = """ + if !Keyword.get(options, :keep_threads) do + # Without the --keep-threads option, it's possible that bookmarked + # objects have been deleted. We remove the corresponding bookmarks. + """ + delete from public.bookmarks + where id in ( + select b.id from public.bookmarks b + left join public.activities a on b.activity_id = a.id + left join public.objects o on a."data" ->> 'object' = o.data ->> 'id' + where o.id is null + ) + """ + |> Repo.query([], timeout: :infinity) + end + + if Keyword.get(options, :prune_orphaned_activities) do + # Prune activities who link to a single object + """ + delete from public.activities + where id in ( + select a.id from public.activities a + left join public.objects o on a.data ->> 'object' = o.data ->> 'id' + left join public.activities a2 on a.data ->> 'object' = a2.data ->> 'id' + left join public.users u on a.data ->> 'object' = u.ap_id + where not a.local + and jsonb_typeof(a."data" -> 'object') = 'string' + and o.id is null + and a2.id is null + and u.id is null + ) + """ + |> Repo.query([], timeout: :infinity) + + # Prune activities who link to an array of objects + """ + delete from public.activities + where id in ( + select a.id from public.activities a + join json_array_elements_text((a."data" -> 'object')::json) as j on jsonb_typeof(a."data" -> 'object') = 'array' + left join public.objects o on j.value = o.data ->> 'id' + left join public.activities a2 on j.value = a2.data ->> 'id' + left join public.users u on j.value = u.ap_id + group by a.id + having max(o.data ->> 'id') is null + and max(a2.data ->> 'id') is null + and max(u.ap_id) is null + ) + """ + |> Repo.query([], timeout: :infinity) + end + + """ DELETE FROM hashtags AS ht WHERE NOT EXISTS ( SELECT 1 FROM hashtags_objects hto WHERE ht.id = hto.hashtag_id) """ - - Repo.query(prune_hashtags_query) + |> Repo.query() if Keyword.get(options, :vacuum) do Maintenance.vacuum("full") end end + def run(["prune_task"]) do + start_pleroma() + + nil + |> Pleroma.Workers.Cron.PruneDatabaseWorker.perform() + end + def run(["fix_likes_collections"]) do start_pleroma() @@ -227,7 +359,7 @@ def run(["set_text_search_config", tsconfig]) do ) end - shell_info('Done.') + shell_info(~c"Done.") end end diff --git a/lib/mix/tasks/pleroma/diagnostics.ex b/lib/mix/tasks/pleroma/diagnostics.ex new file mode 100644 index 000000000..87be38b78 --- /dev/null +++ b/lib/mix/tasks/pleroma/diagnostics.ex @@ -0,0 +1,127 @@ +# credo:disable-for-this-file +defmodule Mix.Tasks.Pleroma.Diagnostics do + alias Pleroma.Repo + alias Pleroma.User + + require Logger + require Pleroma.Constants + + import Mix.Pleroma + import Ecto.Query + use Mix.Task + + def run(["http", url]) do + start_pleroma() + + Pleroma.HTTP.get(url) + |> IO.inspect() + end + + def run(["home_timeline", nickname]) do + start_pleroma() + user = Repo.get_by!(User, nickname: nickname) + Logger.info("Home timeline query #{user.nickname}") + + followed_hashtags = + user + |> User.followed_hashtags() + |> Enum.map(& &1.id) + + params = + %{limit: 20} + |> Map.put(:type, ["Create", "Announce"]) + |> Map.put(:blocking_user, user) + |> Map.put(:muting_user, user) + |> Map.put(:reply_filtering_user, user) + |> Map.put(:announce_filtering_user, user) + |> Map.put(:user, user) + |> Map.put(:followed_hashtags, followed_hashtags) + |> Map.delete(:local) + + list_memberships = Pleroma.List.memberships(user) + recipients = [user.ap_id | User.following(user)] + + query = + Pleroma.Web.ActivityPub.ActivityPub.fetch_activities_query( + recipients ++ list_memberships, + params + ) + |> limit(20) + + Ecto.Adapters.SQL.explain(Repo, :all, query, analyze: true, timeout: :infinity) + |> IO.puts() + end + + def run(["user_timeline", nickname, reading_nickname]) do + start_pleroma() + user = Repo.get_by!(User, nickname: nickname) + reading_user = Repo.get_by!(User, nickname: reading_nickname) + Logger.info("User timeline query #{user.nickname}") + + params = + %{limit: 20} + |> Map.put(:type, ["Create", "Announce"]) + |> Map.put(:user, reading_user) + |> Map.put(:actor_id, user.ap_id) + |> Map.put(:pinned_object_ids, Map.keys(user.pinned_objects)) + + list_memberships = Pleroma.List.memberships(user) + + recipients = + %{ + godmode: params[:godmode], + reading_user: reading_user + } + |> Pleroma.Web.ActivityPub.ActivityPub.user_activities_recipients() + + query = + (recipients ++ list_memberships) + |> Pleroma.Web.ActivityPub.ActivityPub.fetch_activities_query(params) + |> limit(20) + + Ecto.Adapters.SQL.explain(Repo, :all, query, analyze: true, timeout: :infinity) + |> IO.puts() + end + + def run(["notifications", nickname]) do + start_pleroma() + user = Repo.get_by!(User, nickname: nickname) + account_ap_id = user.ap_id + options = %{account_ap_id: user.ap_id} + + query = + user + |> Pleroma.Notification.for_user_query(options) + |> where([n, a], a.actor == ^account_ap_id) + |> limit(20) + + Ecto.Adapters.SQL.explain(Repo, :all, query, analyze: true, timeout: :infinity) + |> IO.puts() + end + + def run(["known_network", nickname]) do + start_pleroma() + user = Repo.get_by!(User, nickname: nickname) + + params = + %{} + |> Map.put(:type, ["Create"]) + |> Map.put(:local_only, false) + |> Map.put(:blocking_user, user) + |> Map.put(:muting_user, user) + |> Map.put(:reply_filtering_user, user) + # Restricts unfederated content to authenticated users + |> Map.put(:includes_local_public, not is_nil(user)) + |> Map.put(:restrict_unlisted, true) + + query = + Pleroma.Web.ActivityPub.ActivityPub.fetch_activities_query( + [Pleroma.Constants.as_public()], + params + ) + |> limit(20) + + Ecto.Adapters.SQL.explain(Repo, :all, query, analyze: true, timeout: :infinity) + |> IO.puts() + end +end diff --git a/lib/mix/tasks/pleroma/emoji.ex b/lib/mix/tasks/pleroma/emoji.ex index 5dedf276a..8dda1512d 100644 --- a/lib/mix/tasks/pleroma/emoji.ex +++ b/lib/mix/tasks/pleroma/emoji.ex @@ -130,6 +130,7 @@ def run(["get-packs" | args]) do } File.write!(Path.join(pack_path, "pack.json"), Jason.encode!(pack_json, pretty: true)) + Pleroma.Emoji.reload() else IO.puts(IO.ANSI.format([:bright, :red, "No pack named \"#{pack_name}\" found"])) end @@ -235,6 +236,8 @@ def run(["gen-pack" | args]) do IO.puts("#{pack_file} has been created with the #{name} pack") end + + Pleroma.Emoji.reload() end def run(["reload"]) do diff --git a/lib/mix/tasks/pleroma/instance.ex b/lib/mix/tasks/pleroma/instance.ex index c5354ac43..8f89ebe3e 100644 --- a/lib/mix/tasks/pleroma/instance.ex +++ b/lib/mix/tasks/pleroma/instance.ex @@ -20,6 +20,7 @@ def run(["gen" | rest]) do output: :string, output_psql: :string, domain: :string, + media_url: :string, instance_name: :string, admin_email: :string, notify_email: :string, @@ -36,8 +37,7 @@ def run(["gen" | rest]) do listen_port: :string, strip_uploads_location: :string, read_uploads_description: :string, - anonymize_uploads: :string, - dedupe_uploads: :string + anonymize_uploads: :string ], aliases: [ o: :output, @@ -60,11 +60,19 @@ def run(["gen" | rest]) do get_option( options, :domain, - "What domain will your instance use? (e.g pleroma.soykaf.com)" + "What domain will your instance use? (e.g akkoma.example.com)" ), ":" ) ++ [443] + media_url = + get_option( + options, + :media_url, + "What base url will uploads use? (e.g https://media.example.com/media)\n" <> + " Generally this should NOT use the same domain as the instance " + ) + name = get_option( options, @@ -204,14 +212,6 @@ def run(["gen" | rest]) do "n" ) === "y" - dedupe_uploads = - get_option( - options, - :dedupe_uploads, - "Do you want to deduplicate uploaded files? (y/n)", - "n" - ) === "y" - Config.put([:instance, :static_dir], static_dir) secret = :crypto.strong_rand_bytes(64) |> Base.encode64() |> binary_part(0, 64) @@ -225,6 +225,7 @@ def run(["gen" | rest]) do EEx.eval_file( template_dir <> "/sample_config.eex", domain: domain, + media_url: media_url, port: port, email: email, notify_email: notify_email, @@ -249,8 +250,7 @@ def run(["gen" | rest]) do upload_filters(%{ strip_location: strip_uploads_location, read_description: read_uploads_description, - anonymize: anonymize_uploads, - dedupe: dedupe_uploads + anonymize: anonymize_uploads }) ) @@ -266,12 +266,22 @@ def run(["gen" | rest]) do config_dir = Path.dirname(config_path) psql_dir = Path.dirname(psql_path) - [config_dir, psql_dir, static_dir, uploads_dir] - |> Enum.reject(&File.exists?/1) - |> Enum.map(&File.mkdir_p!/1) + # Note: Distros requiring group read (0o750) on those directories should + # pre-create the directories. + to_create = + [config_dir, psql_dir, static_dir, uploads_dir] + |> Enum.reject(&File.exists?/1) + + for dir <- to_create do + 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) @@ -290,8 +300,7 @@ def run(["gen" | rest]) do 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 @@ -336,15 +345,6 @@ defp upload_filters(filters) when is_map(filters) do enabled_filters end - enabled_filters = - if filters.dedupe do - enabled_filters ++ [Pleroma.Upload.Filter.Dedupe] - else - enabled_filters - end - enabled_filters end - - defp upload_filters(_), do: [] end diff --git a/lib/mix/tasks/pleroma/search.ex b/lib/mix/tasks/pleroma/search.ex index 67aba79db..102bc5b63 100644 --- a/lib/mix/tasks/pleroma/search.ex +++ b/lib/mix/tasks/pleroma/search.ex @@ -10,14 +10,11 @@ defmodule Mix.Tasks.Pleroma.Search do def run(["import", "activities" | _rest]) do start_pleroma() - IO.inspect(Pleroma.Config.get([Pleroma.Search.Elasticsearch.Cluster, :indexes, :activities])) - IO.inspect( - Elasticsearch.Index.Bulk.upload( - Pleroma.Search.Elasticsearch.Cluster, - "activities", - Pleroma.Config.get([Pleroma.Search.Elasticsearch.Cluster, :indexes, :activities]) - ) + Elasticsearch.Index.Bulk.upload( + Pleroma.Search.Elasticsearch.Cluster, + "activities", + Pleroma.Config.get([Pleroma.Search.Elasticsearch.Cluster, :indexes, :activities]) ) end end diff --git a/lib/mix/tasks/pleroma/search/meilisearch.ex b/lib/mix/tasks/pleroma/search/meilisearch.ex index 27a31afcf..299fb5b14 100644 --- a/lib/mix/tasks/pleroma/search/meilisearch.ex +++ b/lib/mix/tasks/pleroma/search/meilisearch.ex @@ -30,12 +30,12 @@ def run(["index"]) do meili_put( "/indexes/objects/settings/ranking-rules", [ - "published:desc", "words", - "exactness", "proximity", "typo", + "exactness", "attribute", + "published:desc", "sort" ] ) diff --git a/lib/mix/tasks/pleroma/security.ex b/lib/mix/tasks/pleroma/security.ex new file mode 100644 index 000000000..f039e0980 --- /dev/null +++ b/lib/mix/tasks/pleroma/security.ex @@ -0,0 +1,330 @@ +# Akkoma: Magically expressive social media +# Copyright © 2024 Akkoma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Mix.Tasks.Pleroma.Security do + use Mix.Task + import Ecto.Query + import Mix.Pleroma + + alias Pleroma.Config + + require Logger + + @shortdoc """ + Security-related tasks, like e.g. checking for signs past exploits were abused. + """ + + # Constants etc + defp local_id_prefix(), do: Pleroma.Web.Endpoint.url() <> "/" + + defp local_id_pattern(), do: local_id_prefix() <> "%" + + @activity_exts ["activity+json", "activity%2Bjson"] + + defp activity_ext_url_patterns() do + for e <- @activity_exts do + for suf <- ["", "?%"] do + # Escape literal % for use in SQL patterns + ee = String.replace(e, "%", "\\%") + "%.#{ee}#{suf}" + end + end + |> List.flatten() + end + + # Search for malicious uploads exploiting the lack of Content-Type sanitisation from before 2024-03 + def run(["spoof-uploaded"]) do + Logger.put_process_level(self(), :notice) + start_pleroma() + + IO.puts(""" + +------------------------+ + | SPOOF SEARCH UPLOADS | + +------------------------+ + Checking if any uploads are using privileged types. + NOTE if attachment deletion is enabled, payloads used + in the past may no longer exist. + """) + + do_spoof_uploaded() + end + + # Fuzzy search for potentially counterfeit activities in the database resulting from the same exploit + def run(["spoof-inserted"]) do + Logger.put_process_level(self(), :notice) + start_pleroma() + + IO.puts(""" + +----------------------+ + | SPOOF SEARCH NOTES | + +----------------------+ + Starting fuzzy search for counterfeit activities. + NOTE this can not guarantee detecting all counterfeits + and may yield a small percentage of false positives. + """) + + do_spoof_inserted() + end + + # +-----------------------------+ + # | S P O O F - U P L O A D E D | + # +-----------------------------+ + defp do_spoof_uploaded() do + files = + case Config.get!([Pleroma.Upload, :uploader]) do + Pleroma.Uploaders.Local -> + uploads_search_spoofs_local_dir(Config.get!([Pleroma.Uploaders.Local, :uploads])) + + _ -> + IO.puts(""" + NOTE: + Not using local uploader; thus not affected by this exploit. + It's impossible to check for files, but in case local uploader was used before + or to check if anyone futilely attempted a spoof, notes will still be scanned. + """) + + [] + end + + emoji = uploads_search_spoofs_local_dir(Config.get!([:instance, :static_dir])) + + post_attachs = uploads_search_spoofs_notes() + + not_orphaned_urls = + post_attachs + |> Enum.map(fn {_u, _a, url} -> url end) + |> MapSet.new() + + orphaned_attachs = upload_search_orphaned_attachments(not_orphaned_urls) + + IO.puts("\nSearch concluded; here are the results:") + pretty_print_list_with_title(emoji, "Emoji") + pretty_print_list_with_title(files, "Uploaded Files") + pretty_print_list_with_title(post_attachs, "(Not Deleted) Post Attachments") + pretty_print_list_with_title(orphaned_attachs, "Orphaned Uploads") + + IO.puts(""" + In total found + #{length(emoji)} emoji + #{length(files)} uploads + #{length(post_attachs)} not deleted posts + #{length(orphaned_attachs)} orphaned attachments + """) + end + + defp uploads_search_spoofs_local_dir(dir) do + local_dir = String.replace_suffix(dir, "/", "") + + IO.puts("Searching for suspicious files in #{local_dir}...") + + glob_ext = "{" <> Enum.join(@activity_exts, ",") <> "}" + + Path.wildcard(local_dir <> "/**/*." <> glob_ext, match_dot: true) + |> Enum.map(fn path -> + String.replace_prefix(path, local_dir <> "/", "") + end) + |> Enum.sort() + end + + defp uploads_search_spoofs_notes() do + IO.puts("Now querying DB for posts with spoofing attachments. This might take a while...") + + patterns = [local_id_pattern() | activity_ext_url_patterns()] + + # if jsonb_array_elemsts in FROM can be used with normal Ecto functions, idk how + """ + SELECT DISTINCT a.data->>'actor', a.id, url->>'href' + FROM public.objects AS o JOIN public.activities AS a + ON o.data->>'id' = a.data->>'object', + jsonb_array_elements(o.data->'attachment') AS attachs, + jsonb_array_elements(attachs->'url') AS url + WHERE o.data->>'type' = 'Note' AND + o.data->>'id' LIKE $1::text AND ( + url->>'href' LIKE $2::text OR + url->>'href' LIKE $3::text OR + url->>'href' LIKE $4::text OR + url->>'href' LIKE $5::text + ) + ORDER BY a.data->>'actor', a.id, url->>'href'; + """ + |> Pleroma.Repo.query!(patterns, timeout: :infinity) + |> map_raw_id_apid_tuple() + end + + defp upload_search_orphaned_attachments(not_orphaned_urls) do + IO.puts(""" + Now querying DB for orphaned spoofing attachment (i.e. their post was deleted, + but if :cleanup_attachments was not enabled traces remain in the database) + This might take a bit... + """) + + patterns = activity_ext_url_patterns() + + """ + SELECT DISTINCT attach.id, url->>'href' + FROM public.objects AS attach, + jsonb_array_elements(attach.data->'url') AS url + WHERE (attach.data->>'type' = 'Image' OR + attach.data->>'type' = 'Document') + AND ( + url->>'href' LIKE $1::text OR + url->>'href' LIKE $2::text OR + url->>'href' LIKE $3::text OR + url->>'href' LIKE $4::text + ) + ORDER BY attach.id, url->>'href'; + """ + |> Pleroma.Repo.query!(patterns, timeout: :infinity) + |> then(fn res -> Enum.map(res.rows, fn [id, url] -> {id, url} end) end) + |> Enum.filter(fn {_, url} -> !(url in not_orphaned_urls) end) + end + + # +-----------------------------+ + # | S P O O F - I N S E R T E D | + # +-----------------------------+ + defp do_spoof_inserted() do + IO.puts(""" + Searching for local posts whose Create activity has no ActivityPub id... + This is a pretty good indicator, but only for spoofs of local actors + and only if the spoofing happened after around late 2021. + """) + + idless_create = + search_local_notes_without_create_id() + |> Enum.sort() + + IO.puts("Done.\n") + + IO.puts(""" + Now trying to weed out other poorly hidden spoofs. + This can't detect all and may have some false positives. + """) + + likely_spoofed_posts_set = MapSet.new(idless_create) + + sus_pattern_posts = + search_sus_notes_by_id_patterns() + |> Enum.filter(fn r -> !(r in likely_spoofed_posts_set) end) + + IO.puts("Done.\n") + + IO.puts(""" + Finally, searching for spoofed, local user accounts. + (It's impossible to detect spoofed remote users) + """) + + spoofed_users = search_bogus_local_users() + + pretty_print_list_with_title(sus_pattern_posts, "Maybe Spoofed Posts") + pretty_print_list_with_title(idless_create, "Likely Spoofed Posts") + pretty_print_list_with_title(spoofed_users, "Spoofed local user accounts") + + IO.puts(""" + In total found: + #{length(spoofed_users)} bogus users + #{length(idless_create)} likely spoofed posts + #{length(sus_pattern_posts)} maybe spoofed posts + """) + end + + defp search_local_notes_without_create_id() do + Pleroma.Object + |> where([o], fragment("?->>'id' LIKE ?", o.data, ^local_id_pattern())) + |> join(:inner, [o], a in Pleroma.Activity, + on: fragment("?->>'object' = ?->>'id'", a.data, o.data) + ) + |> where([o, a], fragment("NOT (? \\? 'id') OR ?->>'id' IS NULL", a.data, a.data)) + |> select([o, a], {a.id, fragment("?->>'id'", o.data)}) + |> order_by([o, a], a.id) + |> Pleroma.Repo.all(timeout: :infinity) + end + + defp search_sus_notes_by_id_patterns() do + [ep1, ep2, ep3, ep4] = activity_ext_url_patterns() + + Pleroma.Object + |> where( + [o], + # for local objects we know exactly how a genuine id looks like + # (though a thorough attacker can emulate this) + # for remote posts, use some best-effort patterns + fragment( + """ + (?->>'id' LIKE ? AND ?->>'id' NOT SIMILAR TO + ? || 'objects/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}') + """, + o.data, + ^local_id_pattern(), + o.data, + ^local_id_prefix() + ) or + fragment("?->>'id' LIKE ?", o.data, "%/emoji/%") or + fragment("?->>'id' LIKE ?", o.data, "%/media/%") or + fragment("?->>'id' LIKE ?", o.data, "%/proxy/%") or + fragment("?->>'id' LIKE ?", o.data, ^ep1) or + fragment("?->>'id' LIKE ?", o.data, ^ep2) or + fragment("?->>'id' LIKE ?", o.data, ^ep3) or + fragment("?->>'id' LIKE ?", o.data, ^ep4) + ) + |> join(:inner, [o], a in Pleroma.Activity, + on: fragment("?->>'object' = ?->>'id'", a.data, o.data) + ) + |> select([o, a], {a.id, fragment("?->>'id'", o.data)}) + |> order_by([o, a], a.id) + |> Pleroma.Repo.all(timeout: :infinity) + end + + defp search_bogus_local_users() do + Pleroma.User.Query.build(%{}) + |> where([u], u.local == false and like(u.ap_id, ^local_id_pattern())) + |> order_by([u], u.ap_id) + |> select([u], u.ap_id) + |> Pleroma.Repo.all(timeout: :infinity) + end + + # +-----------------------------------+ + # | module-specific utility functions | + # +-----------------------------------+ + defp pretty_print_list_with_title(list, title) do + title_len = String.length(title) + title_underline = String.duplicate("=", title_len) + IO.puts(title) + IO.puts(title_underline) + pretty_print_list(list) + end + + defp pretty_print_list([]), do: IO.puts("") + + defp pretty_print_list([{a, o} | rest]) + when (is_binary(a) or is_number(a)) and is_binary(o) do + IO.puts(" {#{a}, #{o}}") + pretty_print_list(rest) + end + + defp pretty_print_list([{u, a, o} | rest]) + when is_binary(a) and is_binary(u) and is_binary(o) do + IO.puts(" {#{u}, #{a}, #{o}}") + pretty_print_list(rest) + end + + defp pretty_print_list([e | rest]) when is_binary(e) do + IO.puts(" #{e}") + pretty_print_list(rest) + end + + defp pretty_print_list([e | rest]), do: pretty_print_list([inspect(e) | rest]) + + defp map_raw_id_apid_tuple(res) do + user_prefix = local_id_prefix() <> "users/" + + Enum.map(res.rows, fn + [uid, aid, oid] -> + { + String.replace_prefix(uid, user_prefix, ""), + FlakeId.to_string(aid), + oid + } + end) + end +end diff --git a/lib/mix/tasks/pleroma/user.ex b/lib/mix/tasks/pleroma/user.ex index 50c3fd7ce..1a8e866ef 100644 --- a/lib/mix/tasks/pleroma/user.ex +++ b/lib/mix/tasks/pleroma/user.ex @@ -11,6 +11,7 @@ defmodule Mix.Tasks.Pleroma.User do alias Pleroma.UserInviteToken alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.Pipeline + use Pleroma.Web, :verified_routes @shortdoc "Manages Pleroma users" @moduledoc File.read!("docs/docs/administration/CLI_tasks/user.md") @@ -113,9 +114,7 @@ def run(["reset_password", nickname]) do {:ok, token} <- Pleroma.PasswordResetToken.create_token(user) do shell_info("Generated password reset token for #{user.nickname}") - IO.puts("URL: #{Pleroma.Web.Router.Helpers.reset_password_url(Pleroma.Web.Endpoint, - :reset, - token.token)}") + IO.puts("URL: #{~p[/api/v1/pleroma/password_reset/#{token.token}]}") else _ -> shell_error("No local user #{nickname}") @@ -301,13 +300,7 @@ def run(["invite" | rest]) do {:ok, invite} <- UserInviteToken.create_invite(options) do shell_info("Generated user invite token " <> String.replace(invite.invite_type, "_", " ")) - url = - Pleroma.Web.Router.Helpers.redirect_url( - Pleroma.Web.Endpoint, - :registration_page, - invite.token - ) - + url = url(~p[/registration/#{invite.token}]) IO.puts(url) else error -> @@ -376,9 +369,11 @@ def run(["change_email", nickname, email]) do def run(["show", nickname]) do start_pleroma() - nickname - |> User.get_cached_by_nickname() - |> IO.inspect() + user = + nickname + |> User.get_cached_by_nickname() + + shell_info("#{inspect(user)}") end def run(["send_confirmation", nickname]) do @@ -387,7 +382,6 @@ def run(["send_confirmation", nickname]) do with %User{} = user <- User.get_cached_by_nickname(nickname) do user |> Pleroma.Emails.UserEmail.account_confirmation_email() - |> IO.inspect() |> Pleroma.Emails.Mailer.deliver!() shell_info("#{nickname}'s email sent") @@ -463,15 +457,21 @@ def run(["blocking", nickname]) do with %User{local: true} = user <- User.get_cached_by_nickname(nickname) do blocks = User.following_ap_ids(user) - IO.inspect(blocks, limit: :infinity) + IO.puts("#{inspect(blocks)}") end end def run(["timeline_query", nickname]) do start_pleroma() + params = %{local: true} with %User{local: true} = user <- User.get_cached_by_nickname(nickname) do + followed_hashtags = + user + |> User.followed_hashtags() + |> Enum.map(& &1.id) + params = params |> Map.put(:type, ["Create", "Announce"]) @@ -482,6 +482,7 @@ def run(["timeline_query", nickname]) do |> Map.put(:announce_filtering_user, user) |> Map.put(:user, user) |> Map.put(:local_only, params[:local]) + |> Map.put(:hashtags, followed_hashtags) |> Map.delete(:local) _activities = diff --git a/lib/phoenix/transports/web_socket/raw.ex b/lib/phoenix/transports/web_socket/raw.ex index 8ed64eb16..72def9dff 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 b01a838d8..bf851f808 100644 --- a/lib/pleroma/activity.ex +++ b/lib/pleroma/activity.ex @@ -277,6 +277,13 @@ def get_create_by_object_ap_id_with_object(ap_id) when is_binary(ap_id) do def get_create_by_object_ap_id_with_object(_), do: nil + def get_local_create_by_object_ap_id(ap_id) when is_binary(ap_id) do + ap_id + |> create_by_object_ap_id() + |> where(local: true) + |> Repo.one() + end + @spec create_by_id_with_object(String.t()) :: t() | nil def create_by_id_with_object(id) do get_by_id_with_opts(id, preload: [:object], filter: [type: "Create"]) @@ -367,6 +374,14 @@ def following_requests_for_actor(%User{ap_id: ap_id}) do |> Repo.all() end + def follow_activity(%User{ap_id: ap_id}, %User{ap_id: followed_ap_id}) do + Queries.by_type("Follow") + |> where([a], a.actor == ^ap_id) + |> where([a], fragment("?->>'object' = ?", a.data, ^followed_ap_id)) + |> where([a], fragment("?->>'state'", a.data) in ["pending", "accept"]) + |> Repo.one() + end + def restrict_deactivated_users(query) do query |> join( @@ -375,7 +390,8 @@ def restrict_deactivated_users(query) do active in fragment( "SELECT is_active from users WHERE ap_id = ? AND is_active = TRUE", activity.actor - ) + ), + on: true ) end diff --git a/lib/pleroma/activity/html.ex b/lib/pleroma/activity/html.ex index 30409d93d..e4aaad523 100644 --- a/lib/pleroma/activity/html.ex +++ b/lib/pleroma/activity/html.ex @@ -38,7 +38,11 @@ defp add_cache_key_for(activity_id, additional_key) do def invalidate_cache_for(activity_id) do keys = get_cache_keys_for(activity_id) - Enum.map(keys, &@cachex.del(:scrubber_cache, &1)) + + for key <- keys do + @cachex.del(:scrubber_cache, key) + end + @cachex.del(:scrubber_management_cache, activity_id) end diff --git a/lib/pleroma/activity/ir/topics.ex b/lib/pleroma/activity/ir/topics.ex index 7a603a615..bdbf4a285 100644 --- a/lib/pleroma/activity/ir/topics.ex +++ b/lib/pleroma/activity/ir/topics.ex @@ -21,7 +21,7 @@ defp generate_topics(object, activity) do ["user", "list"] ++ visibility_tags(object, activity) end - defp visibility_tags(object, activity) do + defp visibility_tags(object, %{data: %{"type" => type}} = activity) when type != "Announce" do case Visibility.get_visibility(activity) do "public" -> if activity.local do @@ -31,6 +31,10 @@ defp visibility_tags(object, activity) do end |> item_creation_tags(object, activity) + "local" -> + ["public:local"] + |> item_creation_tags(object, activity) + "direct" -> ["direct"] @@ -39,6 +43,10 @@ defp visibility_tags(object, activity) do end end + defp visibility_tags(_object, _activity) do + [] + end + defp item_creation_tags(tags, object, %{data: %{"type" => "Create"}} = activity) do tags ++ remote_topics(activity) ++ hashtags_to_topics(object) ++ attachment_topics(object, activity) @@ -63,7 +71,18 @@ defp remote_topics(_), do: [] defp attachment_topics(%{data: %{"attachment" => []}}, _act), do: [] - defp attachment_topics(_object, %{local: true}), do: ["public:media", "public:local:media"] + defp attachment_topics(_object, %{local: true} = activity) do + case Visibility.get_visibility(activity) do + "public" -> + ["public:media", "public:local:media"] + + "local" -> + ["public:local:media"] + + _ -> + [] + end + end defp attachment_topics(_object, %{actor: actor}) when is_binary(actor), do: ["public:media", "public:remote:media:" <> URI.parse(actor).host] diff --git a/lib/pleroma/activity/pruner.ex b/lib/pleroma/activity/pruner.ex new file mode 100644 index 000000000..54a40b534 --- /dev/null +++ b/lib/pleroma/activity/pruner.ex @@ -0,0 +1,61 @@ +defmodule Pleroma.Activity.Pruner do + @moduledoc """ + Prunes activities from the database. + """ + @cutoff 30 + + alias Pleroma.Activity + alias Pleroma.Repo + import Ecto.Query + + def prune_deletes do + before_time = cutoff() + + from(a in Activity, + where: fragment("?->>'type' = ?", a.data, "Delete") and a.inserted_at < ^before_time + ) + |> Repo.delete_all(timeout: :infinity) + end + + def prune_undos do + before_time = cutoff() + + from(a in Activity, + where: fragment("?->>'type' = ?", a.data, "Undo") and a.inserted_at < ^before_time + ) + |> Repo.delete_all(timeout: :infinity) + end + + def prune_updates do + before_time = cutoff() + + from(a in Activity, + where: fragment("?->>'type' = ?", a.data, "Update") and a.inserted_at < ^before_time + ) + |> Repo.delete_all(timeout: :infinity) + end + + def prune_removes do + before_time = cutoff() + + from(a in Activity, + where: fragment("?->>'type' = ?", a.data, "Remove") and a.inserted_at < ^before_time + ) + |> Repo.delete_all(timeout: :infinity) + end + + def prune_stale_follow_requests do + before_time = cutoff() + + from(a in Activity, + where: + fragment("?->>'type' = ?", a.data, "Follow") and a.inserted_at < ^before_time and + fragment("?->>'state' = ?", a.data, "reject") + ) + |> Repo.delete_all(timeout: :infinity) + end + + defp cutoff do + DateTime.utc_now() |> Timex.shift(days: -@cutoff) + end +end diff --git a/lib/pleroma/akkoma/translators/translator.ex b/lib/pleroma/akkoma/translator.ex similarity index 100% rename from lib/pleroma/akkoma/translators/translator.ex rename to lib/pleroma/akkoma/translator.ex diff --git a/lib/pleroma/akkoma/translators/argos_translate.ex b/lib/pleroma/akkoma/translators/argos_translate.ex new file mode 100644 index 000000000..dfec81d0a --- /dev/null +++ b/lib/pleroma/akkoma/translators/argos_translate.ex @@ -0,0 +1,109 @@ +defmodule Pleroma.Akkoma.Translators.ArgosTranslate do + @behaviour Pleroma.Akkoma.Translator + + alias Pleroma.Config + + defp argos_translate do + Config.get([:argos_translate, :command_argos_translate]) + end + + defp argospm do + Config.get([:argos_translate, :command_argospm]) + end + + defp strip_html? do + Config.get([:argos_translate, :strip_html]) + end + + defp safe_languages() do + try do + System.cmd(argospm(), ["list"], stderr_to_stdout: true, parallelism: true) + rescue + _ -> {"Command #{argospm()} not found", 1} + end + end + + @impl Pleroma.Akkoma.Translator + def languages do + with {response, 0} <- safe_languages() do + langs = + response + |> String.split("\n", trim: true) + |> Enum.map(fn + "translate-" <> l -> String.split(l, "_") + end) + + source_langs = + langs + |> Enum.map(fn [l, _] -> %{code: l, name: l} end) + |> Enum.uniq() + + dest_langs = + langs + |> Enum.map(fn [_, l] -> %{code: l, name: l} end) + |> Enum.uniq() + + {:ok, source_langs, dest_langs} + else + {response, _} -> {:error, "ArgosTranslate failed to fetch languages (#{response})"} + end + end + + defp safe_translate(string, from_language, to_language) do + try do + System.cmd( + argos_translate(), + ["--from-lang", from_language, "--to-lang", to_language, string], + stderr_to_stdout: true, + parallelism: true + ) + rescue + _ -> {"Command #{argos_translate()} not found", 1} + end + end + + defp clean_string(string, true) do + string + |> String.replace("

", "\n") + |> String.replace("

", "\n") + |> String.replace("
", "\n") + |> String.replace("
", "\n") + |> String.replace("
  • ", "\n") + |> Pleroma.HTML.strip_tags() + |> HtmlEntities.decode() + end + + defp clean_string(string, _), do: string + + defp htmlify_response(string, true) do + string + |> HtmlEntities.encode() + |> String.replace("\n", "
    ") + end + + defp htmlify_response(string, _), do: string + + @impl Pleroma.Akkoma.Translator + def translate(string, nil, to_language) do + # Akkoma's Pleroma-fe expects us to detect the source language automatically. + # Argos-translate doesn't have that option (yet?) + # see + # For now we return the text unchanged, supposedly translated from the target language. + # Afterwards people get the option to overwrite the source language from a dropdown. + {:ok, to_language, string} + end + + def translate(string, from_language, to_language) do + # Argos Translate doesn't properly translate HTML (yet?) + # For now we give admins the option to strip the html before translating + # Note that we have to add some html back to the response afterwards + string = clean_string(string, strip_html?()) + + with {translated, 0} <- + safe_translate(string, from_language, to_language) do + {:ok, from_language, translated |> htmlify_response(strip_html?())} + else + {response, _} -> {:error, "ArgosTranslate failed to translate (#{response})"} + end + end +end diff --git a/lib/pleroma/akkoma/translators/libre_translate.ex b/lib/pleroma/akkoma/translators/libre_translate.ex index 3a8d9d827..80956ab66 100644 --- a/lib/pleroma/akkoma/translators/libre_translate.ex +++ b/lib/pleroma/akkoma/translators/libre_translate.ex @@ -40,7 +40,7 @@ def translate(string, from_language, to_language) do if Map.has_key?(body, "detectedLanguage") do get_in(body, ["detectedLanguage", "language"]) else - from_language + from_language || "" end {:ok, detected, translated} diff --git a/lib/pleroma/announcement.ex b/lib/pleroma/announcement.ex index d97c5e728..6dc1a9c7b 100644 --- a/lib/pleroma/announcement.ex +++ b/lib/pleroma/announcement.ex @@ -24,8 +24,10 @@ defmodule Pleroma.Announcement do end def change(struct, params \\ %{}) do + params = validate_params(struct, params) + struct - |> cast(validate_params(struct, params), [:data, :starts_at, :ends_at, :rendered]) + |> cast(params, [:data, :starts_at, :ends_at, :rendered]) |> validate_required([:data]) end diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index adccd7c5d..28a86d0aa 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -53,6 +53,7 @@ def start(_type, _args) do Config.DeprecationWarnings.warn() Pleroma.Web.Plugs.HTTPSecurityPlug.warn_if_disabled() Pleroma.ApplicationRequirements.verify!() + load_all_pleroma_modules() load_custom_modules() Pleroma.Docs.JSON.compile() limiters_setup() @@ -73,7 +74,8 @@ def start(_type, _args) do Pleroma.JobQueueMonitor, {Majic.Pool, [name: Pleroma.MajicPool, pool_size: Config.get([:majic_pool, :size], 2)]}, {Oban, Config.get(Oban)}, - Pleroma.Web.Endpoint + Pleroma.Web.Endpoint, + Pleroma.Web.Telemetry ] ++ elasticsearch_children() ++ task_children(@mix_env) ++ @@ -86,7 +88,7 @@ def start(_type, _args) do # Go for the default 3 unless we're in test max_restarts = if @mix_env == :test do - 100 + 1000 else 3 end @@ -111,7 +113,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" ) @@ -143,6 +145,24 @@ def load_custom_modules do end end + def load_all_pleroma_modules do + :code.all_available() + |> Enum.filter(fn {mod, _, _} -> + mod + |> to_string() + |> String.starts_with?("Elixir.Pleroma.") + end) + |> Enum.map(fn {mod, _, _} -> + mod + |> to_string() + |> String.to_existing_atom() + |> Code.ensure_loaded!() + end) + + # Use this when 1.15 is standard + # |> Code.ensure_all_loaded!() + end + defp cachex_children do [ build_cachex("used_captcha", ttl_interval: seconds_valid_interval()), @@ -156,7 +176,10 @@ defp cachex_children do build_cachex("emoji_packs", expiration: emoji_packs_expiration(), limit: 10), build_cachex("failed_proxy_url", limit: 2500), build_cachex("banned_urls", default_ttl: :timer.hours(24 * 30), limit: 5_000), - build_cachex("translations", default_ttl: :timer.hours(24 * 30), limit: 2500) + build_cachex("translations", default_ttl: :timer.hours(24 * 30), limit: 2500), + build_cachex("instances", default_ttl: :timer.hours(24), ttl_interval: 1000, limit: 2500), + build_cachex("request_signatures", default_ttl: :timer.hours(24 * 30), limit: 3000), + build_cachex("rel_me", default_ttl: :timer.hours(24 * 30), limit: 300) ] end @@ -196,6 +219,8 @@ defp background_migrators do ] end + @spec task_children(atom()) :: [map()] + defp task_children(:test) do [ %{ @@ -221,6 +246,7 @@ defp task_children(_) do ] end + @spec elasticsearch_children :: [Pleroma.Search.Elasticsearch.Cluster] def elasticsearch_children do config = Config.get([Pleroma.Search, :module]) @@ -253,11 +279,16 @@ def limiters_setup do defp http_children do proxy_url = Config.get([:http, :proxy_url]) proxy = Pleroma.HTTP.AdapterHelper.format_proxy(proxy_url) + pool_size = Config.get([:http, :pool_size]) + + :public_key.cacerts_load() config = [:http, :adapter] |> Config.get([]) + |> Pleroma.HTTP.AdapterHelper.add_pool_size(pool_size) |> Pleroma.HTTP.AdapterHelper.maybe_add_proxy_pool(proxy) + |> Pleroma.HTTP.AdapterHelper.ensure_ipv6() |> Keyword.put(:name, MyFinch) [{Finch, config}] diff --git a/lib/pleroma/application_requirements.ex b/lib/pleroma/application_requirements.ex index 141f7888a..ee9b1ef82 100644 --- a/lib/pleroma/application_requirements.ex +++ b/lib/pleroma/application_requirements.ex @@ -34,7 +34,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 +53,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. @@ -195,8 +195,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/collections/fetcher.ex b/lib/pleroma/collections/fetcher.ex index ab69f4b84..a2fcb7794 100644 --- a/lib/pleroma/collections/fetcher.ex +++ b/lib/pleroma/collections/fetcher.ex @@ -68,7 +68,7 @@ defp fetch_page_items(id, items \\ []) do items end else - {:error, "Object has been deleted"} -> + {:error, {"Object has been deleted", _, _}} -> items {:error, error} -> diff --git a/lib/pleroma/config/deprecation_warnings.ex b/lib/pleroma/config/deprecation_warnings.ex index 317106bde..3858bf88e 100644 --- a/lib/pleroma/config/deprecation_warnings.ex +++ b/lib/pleroma/config/deprecation_warnings.ex @@ -26,7 +26,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: @@ -62,10 +62,10 @@ def check_exiftool_filter do def check_simple_policy_tuples do has_strings = Config.get([:mrf_simple]) - |> Enum.any?(fn {_, v} -> Enum.any?(v, &is_binary/1) end) + |> Enum.any?(fn {_, v} -> is_list(v) and 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: @@ -103,6 +103,7 @@ def check_simple_policy_tuples do new_config = Config.get([:mrf_simple]) + |> Enum.filter(fn {_k, v} -> not is_atom(v) end) |> Enum.map(fn {k, v} -> {k, Enum.map(v, fn @@ -123,7 +124,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: @@ -160,7 +161,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: @@ -195,7 +196,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. """) @@ -218,6 +219,9 @@ def warn do check_quarantined_instances_tuples(), check_transparency_exclusions_tuples(), check_simple_policy_tuples(), + check_http_adapter(), + check_uploader_base_url_set(), + check_uploader_base_url_is_not_base_domain(), check_exiftool_filter() ] |> Enum.reduce(:ok, fn @@ -247,6 +251,32 @@ def check_welcome_message_config do end end + def check_http_adapter do + http_adapter = Application.get_env(:tesla, :adapter) + + case http_adapter do + {Tesla.Adapter.Finch, _} -> + :ok + + Tesla.Mock -> + # tests do be testing + :ok + + _anything_else -> + Logger.error(""" + !!!CONFIG ERROR!!! + Your config is using a custom tesla adapter, this was standardised + to finch in 2022.06, and alternate adapters were removed in 2023.02. + Please ensure you either: + \n* do not have any custom value for `:tesla, :adapter`, or + \n* have `config :tesla, :adapter, {Tesla.Adapter.Finch, name: MyFinch}` + (your current value is #{inspect(http_adapter)}) + """) + + :error + end + end + def check_old_mrf_config do warning_preface = """ !!!DEPRECATION WARNING!!! @@ -274,7 +304,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 +314,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. """) @@ -347,4 +377,54 @@ def check_uploders_s3_public_endpoint do :ok end end + + def check_uploader_base_url_set() do + uses_local_uploader? = Config.get([Pleroma.Upload, :uploader]) == Pleroma.Uploaders.Local + base_url = Pleroma.Config.get([Pleroma.Upload, :base_url]) + + if base_url || !uses_local_uploader? do + :ok + else + Logger.error(""" + !!!WARNING!!! + Your config does not specify a base_url for uploads! + Please make the following change:\n + \n* `config :pleroma, Pleroma.Upload, base_url: "https://example.com/media/` + \n + \nPlease note that it is HEAVILY recommended to use a subdomain to host user-uploaded media! + """) + + # This is a hard exit - the uploader will not work without a base_url + raise ArgumentError, message: "No base_url set for uploads - please set one in your config!" + end + end + + def check_uploader_base_url_is_not_base_domain() do + uses_local_uploader? = Config.get([Pleroma.Upload, :uploader]) == Pleroma.Uploaders.Local + + uploader_host = + [Pleroma.Upload, :base_url] + |> Pleroma.Config.get() + |> URI.parse() + |> Map.get(:host) + + akkoma_host = + [Pleroma.Web.Endpoint, :url] + |> Pleroma.Config.get() + |> Keyword.get(:host) + + if uploader_host == akkoma_host && uses_local_uploader? do + Logger.error(""" + !!!WARNING!!! + Your Akkoma Host and your Upload base_url's host are the same! + This can potentially be insecure! + + It is HIGHLY recommended that you migrate your media uploads + to a subdomain at your earliest convenience + """) + end + + # This isn't actually an error condition, just a warning + :ok + end end diff --git a/lib/pleroma/config/oban.ex b/lib/pleroma/config/oban.ex index 53ea7d7be..ab1eee96f 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 e5f2d6339..d8818dfee 100644 --- a/lib/pleroma/config/release_runtime_provider.ex +++ b/lib/pleroma/config/release_runtime_provider.ex @@ -22,6 +22,20 @@ def load(config, opts) do with_runtime_config = if File.exists?(config_path) do + # + %File.Stat{mode: mode} = File.stat!(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 81dc847cf..a8a96f393 100644 --- a/lib/pleroma/config/transfer_task.ex +++ b/lib/pleroma/config/transfer_task.ex @@ -25,7 +25,9 @@ defp reboot_time_subkeys, do: [ {:pleroma, Pleroma.Captcha, [:seconds_valid]}, {:pleroma, Pleroma.Upload, [:proxy_remote]}, - {:pleroma, :instance, [:upload_limit]} + {:pleroma, :instance, [:upload_limit]}, + {:pleroma, :http, [:pool_size]}, + {:pleroma, :http, [:proxy_url]} ] def start_link(restart_pleroma? \\ true) do @@ -40,8 +42,9 @@ def load_and_update_env(deleted_settings \\ [], restart_pleroma? \\ true) do # We need to restart applications for loaded settings take effect {logger, other} = (Repo.all(ConfigDB) ++ deleted_settings) + |> Enum.reject(&invalid_key_or_group/1) |> Enum.map(&merge_with_default/1) - |> Enum.split_with(fn {group, _, _, _} -> group in [:logger, :quack] end) + |> Enum.split_with(fn {group, _, _, _} -> group == :logger end) logger |> Enum.sort() @@ -83,6 +86,10 @@ defp maybe_set_pleroma_last(apps) do end end + defp invalid_key_or_group(%ConfigDB{key: :invalid_atom}), do: true + defp invalid_key_or_group(%ConfigDB{group: :invalid_atom}), do: true + defp invalid_key_or_group(_), do: false + defp merge_with_default(%{group: group, key: key, value: value} = setting) do default = if group == :pleroma do @@ -101,15 +108,9 @@ defp merge_with_default(%{group: group, key: key, value: value} = setting) do {group, key, value, merged} end - # change logger configuration in runtime, without restart - defp configure({:quack, key, _, merged}) do - Logger.configure_backend(Quack.Logger, [{key, merged}]) - :ok = update_env(:quack, key, merged) - end - defp configure({_, :backends, _, merged}) do # removing current backends - Enum.each(Application.get_env(:logger, :backends), &Logger.remove_backend/1) + Enum.each(Application.get_env(:logger, :backends, []), &Logger.remove_backend/1) Enum.each(merged, &Logger.add_backend/1) @@ -148,7 +149,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 @@ -182,12 +183,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 cb57673e3..9e4e6f3ea 100644 --- a/lib/pleroma/config_db.ex +++ b/lib/pleroma/config_db.ex @@ -163,7 +163,6 @@ defp can_be_partially_updated?(%ConfigDB{} = config), do: not only_full_update?( defp only_full_update?(%ConfigDB{group: group, key: key}) do full_key_update = [ {:pleroma, :ecto_repos}, - {:quack, :meta}, {:mime, :types}, {:cors_plug, [:max_age, :methods, :expose, :headers]}, {:swarm, :node_blacklist}, @@ -343,7 +342,11 @@ def string_to_elixir_types(":" <> atom), do: String.to_atom(atom) def string_to_elixir_types(value) do if module_name?(value) do - String.to_existing_atom("Elixir." <> value) + try do + String.to_existing_atom("Elixir." <> value) + rescue + ArgumentError -> :invalid_atom + end else value end diff --git a/lib/pleroma/constants.ex b/lib/pleroma/constants.ex index 7343ef8c3..94608a99b 100644 --- a/lib/pleroma/constants.ex +++ b/lib/pleroma/constants.ex @@ -25,7 +25,7 @@ defmodule Pleroma.Constants do const(static_only_files, do: - ~w(index.html robots.txt static static-fe finmoji emoji packs sounds images instance sw.js sw-pleroma.js favicon.png schemas doc embed.js embed.css) + ~w(index.html robots.txt static static-fe finmoji emoji packs sounds images instance embed sw.js sw-pleroma.js favicon.png schemas doc) ) const(status_updatable_fields, @@ -38,7 +38,8 @@ defmodule Pleroma.Constants do "summary", "sensitive", "attachment", - "generator" + "generator", + "contentMap" ] ) diff --git a/lib/pleroma/emails/admin_email.ex b/lib/pleroma/emails/admin_email.ex index 88bc78aec..683de8e3b 100644 --- a/lib/pleroma/emails/admin_email.ex +++ b/lib/pleroma/emails/admin_email.ex @@ -6,10 +6,13 @@ defmodule Pleroma.Emails.AdminEmail do @moduledoc "Admin emails" import Swoosh.Email - + use Pleroma.Web, :mailer alias Pleroma.Config alias Pleroma.HTML - alias Pleroma.Web.Router.Helpers + + use Phoenix.VerifiedRoutes, + endpoint: Pleroma.Web.Endpoint, + router: Pleroma.Web.Router defp instance_config, do: Config.get(:instance) defp instance_name, do: instance_config()[:name] @@ -45,7 +48,7 @@ def report(to, reporter, account, statuses, comment) do statuses |> Enum.map(fn %{id: id} -> - status_url = Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, id) + status_url = url(~p[/notice/#{id}]) "
  • #{status_url}
  • " %{"id" => id} when is_binary(id) -> diff --git a/lib/pleroma/emails/mailer.ex b/lib/pleroma/emails/mailer.ex index c68550bee..6a79a7694 100644 --- a/lib/pleroma/emails/mailer.ex +++ b/lib/pleroma/emails/mailer.ex @@ -35,11 +35,6 @@ def perform(:deliver_async, email, config), do: deliver(email, config) def deliver(email, config \\ []) def deliver(email, config) do - # temporary hackney fix until hackney max_connections bug is fixed - # https://git.pleroma.social/pleroma/pleroma/-/issues/2101 - email = - Swoosh.Email.put_private(email, :hackney_options, ssl_options: [versions: [:"tlsv1.2"]]) - case enabled?() do true -> Swoosh.Mailer.deliver(email, parse_config(config)) false -> {:error, :deliveries_disabled} @@ -60,12 +55,61 @@ def deliver!(email, config) do @doc false def validate_dependency do - parse_config([]) + parse_config([], defaults: false) |> Keyword.get(:adapter) |> Swoosh.Mailer.validate_dependency() end - defp parse_config(config) do - Swoosh.Mailer.parse_config(@otp_app, __MODULE__, @mailer_config, config) + defp ensure_charlist(input) do + case input do + i when is_binary(i) -> String.to_charlist(input) + i when is_list(i) -> i + end + end + + defp default_config(adapter, conf, opts) + + defp default_config(_, _, defaults: false) do + [] + end + + defp default_config(Swoosh.Adapters.SMTP, conf, _) do + # gen_smtp and Erlang's tls defaults are very barebones, if nothing is set. + # Add sane defaults for our usecase to make config less painful for admins + relay = ensure_charlist(Keyword.get(conf, :relay)) + ssl_disabled = Keyword.get(conf, :ssl) === false + os_cacerts = :public_key.cacerts_get() + + common_tls_opts = [ + cacerts: os_cacerts, + versions: [:"tlsv1.2", :"tlsv1.3"], + verify: :verify_peer, + # some versions have supposedly issues verifying wildcard certs without this + server_name_indication: relay, + # the default of 10 is too restrictive + depth: 32 + ] + + [ + auth: :always, + no_mx_lookups: false, + # Direct SSL/TLS + # (if ssl was explicitly disabled, we must not pass TLS options to the socket) + ssl: true, + sockopts: if(ssl_disabled, do: [], else: common_tls_opts), + # STARTTLS upgrade (can't be set to :always when already using direct TLS) + tls: :if_available, + tls_options: common_tls_opts + ] + end + + defp default_config(_, _, _), do: [] + + defp parse_config(config, opts \\ []) do + conf = Swoosh.Mailer.parse_config(@otp_app, __MODULE__, @mailer_config, config) + adapter = Keyword.get(conf, :adapter) + + default_config(adapter, conf, opts) + |> Keyword.merge(conf) end end diff --git a/lib/pleroma/emails/user_email.ex b/lib/pleroma/emails/user_email.ex index 24adfabd7..a7bcc03b6 100644 --- a/lib/pleroma/emails/user_email.ex +++ b/lib/pleroma/emails/user_email.ex @@ -6,12 +6,11 @@ defmodule Pleroma.Emails.UserEmail do @moduledoc "User emails" require Pleroma.Web.Gettext + use Pleroma.Web, :mailer alias Pleroma.Config alias Pleroma.User - alias Pleroma.Web.Endpoint alias Pleroma.Web.Gettext - alias Pleroma.Web.Router import Swoosh.Email import Phoenix.Swoosh, except: [render_body: 3] @@ -75,7 +74,7 @@ def welcome(user, opts \\ %{}) do def password_reset_email(user, token) when is_binary(token) do Gettext.with_locale_or_default user.language do - password_reset_url = Router.Helpers.reset_password_url(Endpoint, :reset, token) + password_reset_url = url(~p[/api/v1/pleroma/password_reset/#{token}]) html_body = Gettext.dpgettext( @@ -108,12 +107,7 @@ def user_invitation_email( to_name \\ nil ) do Gettext.with_locale_or_default user.language do - registration_url = - Router.Helpers.redirect_url( - Endpoint, - :registration_page, - user_invite_token.token - ) + registration_url = url(~p[/registration/#{user_invite_token.token}]) html_body = Gettext.dpgettext( @@ -121,7 +115,7 @@ def user_invitation_email( "user invitation email body", """

    You are invited to %{instance_name}

    -

    %{inviter_name} invites you to join %{instance_name}, an instance of Pleroma federated social networking platform.

    +

    %{inviter_name} invites you to join %{instance_name}, an instance of Akkoma federated social networking platform.

    Click the following link to register: accept invitation.

    """, instance_name: instance_name(), @@ -146,13 +140,7 @@ def user_invitation_email( def account_confirmation_email(user) do Gettext.with_locale_or_default user.language do - confirmation_url = - Router.Helpers.confirm_email_url( - Endpoint, - :confirm_email, - user.id, - to_string(user.confirmation_token) - ) + confirmation_url = url(~p[/api/account/confirm_email/#{user.id}/#{user.confirmation_token}]) html_body = Gettext.dpgettext( @@ -342,7 +330,7 @@ def unsubscribe_url(user, notifications_type) do |> Pleroma.JWT.generate_and_sign!() |> Base.encode64() - Router.Helpers.subscription_url(Endpoint, :unsubscribe, token) + url(~p[/mailer/unsubscribe/#{token}]) end def backup_is_ready_email(backup, admin_user_id \\ nil) do @@ -357,7 +345,7 @@ def backup_is_ready_email(backup, admin_user_id \\ nil) do "static_pages", "account archive email body - self-requested", """ -

    You requested a full backup of your Pleroma account. It's ready for download:

    +

    You requested a full backup of your Akkoma account. It's ready for download:

    %{download_url}

    """, download_url: download_url @@ -369,7 +357,7 @@ def backup_is_ready_email(backup, admin_user_id \\ nil) do "static_pages", "account archive email body - admin requested", """ -

    Admin @%{admin_nickname} requested a full backup of your Pleroma account. It's ready for download:

    +

    Admin @%{admin_nickname} requested a full backup of your Akkoma account. It's ready for download:

    %{download_url}

    """, admin_nickname: admin.nickname, diff --git a/lib/pleroma/emoji.ex b/lib/pleroma/emoji.ex index dbe9abe8d..933f4275a 100644 --- a/lib/pleroma/emoji.ex +++ b/lib/pleroma/emoji.ex @@ -21,6 +21,7 @@ defmodule Pleroma.Emoji do :named_table, {:read_concurrency, true} ] + @emoji_regex ~r/:[A-Za-z0-9_-]+(@.+)?:/ defstruct [:code, :file, :tags, :safe_code, :safe_file] @@ -205,4 +206,7 @@ def fully_qualify_emoji(unquote(unqualified)), do: unquote(qualified) end def fully_qualify_emoji(emoji), do: emoji + + def matches_shortcode?(nil), do: false + def matches_shortcode?(s), do: Regex.match?(@emoji_regex, s) end diff --git a/lib/pleroma/emoji/loader.ex b/lib/pleroma/emoji/loader.ex index abc95d902..e09279dd0 100644 --- a/lib/pleroma/emoji/loader.ex +++ b/lib/pleroma/emoji/loader.ex @@ -59,7 +59,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 diff --git a/lib/pleroma/emoji/pack.ex b/lib/pleroma/emoji/pack.ex index 8d233d5e4..142208854 100644 --- a/lib/pleroma/emoji/pack.ex +++ b/lib/pleroma/emoji/pack.ex @@ -26,12 +26,37 @@ defmodule Pleroma.Emoji.Pack do alias Pleroma.Emoji.Pack alias Pleroma.Utils + # Invalid/Malicious names are supposed to be filtered out before path joining, + # but there are many entrypoints to affected functions so as the code changes + # we might accidentally let an unsanitised name slip through. + # To make sure, use the below which crash the process otherwise. + + # ALWAYS use this when constructing paths from external name! + # (name meaning it must be only a single path component) + defp path_join_name_safe(dir, name) do + if to_string(name) != Path.basename(name) or name in ["..", ".", ""] do + raise "Invalid or malicious pack name: #{name}" + else + Path.join(dir, name) + end + end + + # ALWAYS use this to join external paths + # (which are allowed to have several components) + defp path_join_safe(dir, path) do + {:ok, safe_path} = Path.safe_relative(path) + Path.join(dir, safe_path) + end + @spec create(String.t()) :: {:ok, t()} | {:error, File.posix()} | {:error, :empty_values} def create(name) do with :ok <- validate_not_empty([name]), - dir <- Path.join(emoji_path(), name), + dir <- path_join_name_safe(emoji_path(), name), :ok <- File.mkdir(dir) do - save_pack(%__MODULE__{pack_file: Path.join(dir, "pack.json")}) + save_pack(%__MODULE__{ + path: dir, + pack_file: Path.join(dir, "pack.json") + }) end end @@ -65,7 +90,7 @@ def show(opts) do {:ok, [binary()]} | {:error, File.posix(), binary()} | {:error, :empty_values} def delete(name) do with :ok <- validate_not_empty([name]), - pack_path <- Path.join(emoji_path(), name) do + pack_path <- path_join_name_safe(emoji_path(), name) do File.rm_rf(pack_path) end end @@ -89,7 +114,7 @@ defp unpack_zip_emojies(zip_files) do end) end - @spec add_file(t(), String.t(), Path.t(), Plug.Upload.t()) :: + @spec add_file(t(), String.t(), Path.t(), Plug.Upload.t() | binary()) :: {:ok, t()} | {:error, File.posix() | atom()} def add_file(%Pack{} = pack, _, _, %Plug.Upload{content_type: "application/zip"} = file) do @@ -107,7 +132,7 @@ def add_file(%Pack{} = pack, _, _, %Plug.Upload{content_type: "application/zip"} Enum.map_reduce(emojies, pack, fn item, emoji_pack -> emoji_file = %Plug.Upload{ filename: item[:filename], - path: Path.join(tmp_dir, item[:path]) + path: path_join_safe(tmp_dir, item[:path]) } {:ok, updated_pack} = @@ -137,6 +162,14 @@ def add_file(%Pack{} = pack, _, _, %Plug.Upload{content_type: "application/zip"} end def add_file(%Pack{} = pack, shortcode, filename, %Plug.Upload{} = file) do + try_add_file(pack, shortcode, filename, file) + end + + def add_file(%Pack{} = pack, shortcode, filename, filedata) when is_binary(filedata) do + try_add_file(pack, shortcode, filename, filedata) + end + + defp try_add_file(%Pack{} = pack, shortcode, filename, file) do with :ok <- validate_not_empty([shortcode, filename]), :ok <- validate_emoji_not_exists(shortcode), {:ok, updated_pack} <- do_add_file(pack, shortcode, filename, file) do @@ -189,6 +222,7 @@ def import_from_filesystem do {:ok, results} <- File.ls(emoji_path) do names = results + # items come from File.ls, thus safe |> Enum.map(&Path.join(emoji_path, &1)) |> Enum.reject(fn path -> File.dir?(path) and File.exists?(Path.join(path, "pack.json")) @@ -209,7 +243,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 @@ -250,7 +286,7 @@ def download(name, url, as) do with :ok <- validate_shareable_packs_available(uri), {:ok, remote_pack} <- - uri |> URI.merge("/api/pleroma/emoji/pack?name=#{name}") |> http_get(), + uri |> URI.merge("/api/v1/pleroma/emoji/pack?name=#{URI.encode(name)}") |> 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,7 +321,8 @@ def update_metadata(name, data) do @spec load_pack(String.t()) :: {:ok, t()} | {:error, :file.posix()} def load_pack(name) do - pack_file = Path.join([emoji_path(), name, "pack.json"]) + pack_dir = path_join_name_safe(emoji_path(), name) + pack_file = Path.join(pack_dir, "pack.json") with {:ok, _} <- File.stat(pack_file), {:ok, pack_data} <- File.read(pack_file) do @@ -409,10 +446,16 @@ defp downloadable?(pack) do end defp create_archive_and_cache(pack, hash) do - files = ['pack.json' | Enum.map(pack.files, fn {_, file} -> to_charlist(file) end)] + files = [ + ~c"pack.json" + | Enum.map(pack.files, fn {_, file} -> + {:ok, file} = Path.safe_relative(file) + to_charlist(file) + end) + ] {:ok, {_, result}} = - :zip.zip('#{pack.name}.zip', files, [:memory, cwd: to_charlist(pack.path)]) + :zip.zip(~c"#{pack.name}.zip", files, [:memory, cwd: to_charlist(pack.path)]) ttl_per_file = Pleroma.Config.get!([:emoji, :shared_pack_cache_seconds_per_file]) overall_ttl = :timer.seconds(ttl_per_file * Enum.count(files)) @@ -471,7 +514,7 @@ defp validate_not_empty(list) do end defp save_file(%Plug.Upload{path: upload_path}, pack, filename) do - file_path = Path.join(pack.path, filename) + file_path = path_join_safe(pack.path, filename) create_subdirs(file_path) with {:ok, _} <- File.copy(upload_path, file_path) do @@ -479,6 +522,12 @@ defp save_file(%Plug.Upload{path: upload_path}, pack, filename) do end end + defp save_file(file_data, pack, filename) when is_binary(file_data) do + file_path = path_join_safe(pack.path, filename) + create_subdirs(file_path) + File.write(file_path, file_data, [:binary]) + end + defp put_emoji(pack, shortcode, filename) do files = Map.put(pack.files, shortcode, filename) %{pack | files: files, files_count: length(Map.keys(files))} @@ -490,8 +539,8 @@ defp delete_emoji(pack, shortcode) do end defp rename_file(pack, filename, new_filename) do - old_path = Path.join(pack.path, filename) - new_path = Path.join(pack.path, new_filename) + old_path = path_join_safe(pack.path, filename) + new_path = path_join_safe(pack.path, new_filename) create_subdirs(new_path) with :ok <- File.rename(old_path, new_path) do @@ -509,7 +558,7 @@ defp create_subdirs(file_path) do defp remove_file(pack, shortcode) do with {:ok, filename} <- get_filename(pack, shortcode), - emoji <- Path.join(pack.path, filename), + emoji <- path_join_safe(pack.path, filename), :ok <- File.rm(emoji) do remove_dir_if_empty(emoji, filename) end @@ -527,7 +576,7 @@ defp remove_dir_if_empty(emoji, filename) do defp get_filename(pack, shortcode) do with %{^shortcode => filename} when is_binary(filename) <- pack.files, - file_path <- Path.join(pack.path, filename), + file_path <- path_join_safe(pack.path, filename), {:ok, _} <- File.stat(file_path) do {:ok, filename} else @@ -565,7 +614,7 @@ defp validate_downloadable(pack) do end defp copy_as(remote_pack, local_name) do - path = Path.join(emoji_path(), local_name) + path = path_join_name_safe(emoji_path(), local_name) %__MODULE__{ name: local_name, @@ -579,7 +628,7 @@ defp unzip(archive, pack_info, remote_pack, local_pack) do with :ok <- File.mkdir_p!(local_pack.path) do files = Enum.map(remote_pack["files"], fn {_, path} -> to_charlist(path) end) # Fallback cannot contain a pack.json file - files = if pack_info[:fallback], do: files, else: ['pack.json' | files] + files = if pack_info[:fallback], do: files, else: [~c"pack.json" | files] :zip.unzip(archive, cwd: to_charlist(local_pack.path), file_list: files) end @@ -591,7 +640,9 @@ 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=#{URI.encode(name)}") + |> to_string() }} %{"fallback-src" => src, "fallback-src-sha256" => sha} when is_binary(src) -> diff --git a/lib/pleroma/following_relationship.ex b/lib/pleroma/following_relationship.ex index 42db9463d..9e75458e5 100644 --- a/lib/pleroma/following_relationship.ex +++ b/lib/pleroma/following_relationship.ex @@ -14,6 +14,8 @@ defmodule Pleroma.FollowingRelationship do alias Pleroma.Repo alias Pleroma.User + @type follow_state :: :follow_pending | :follow_accept | :follow_reject | :unfollow + schema "following_relationships" do field(:state, State, default: :follow_pending) @@ -72,6 +74,7 @@ def update(%User{} = follower, %User{} = following, state) do end end + @spec follow(User.t(), User.t()) :: {:ok, User.t(), User.t()} | {:error, any} def follow(%User{} = follower, %User{} = following, state \\ :follow_accept) do with {:ok, _following_relationship} <- %__MODULE__{} @@ -81,6 +84,7 @@ def follow(%User{} = follower, %User{} = following, state \\ :follow_accept) do end end + @spec unfollow(User.t(), User.t()) :: {:ok, User.t(), User.t()} | {:error, any} def unfollow(%User{} = follower, %User{} = following) do case get(follower, following) do %__MODULE__{} = following_relationship -> @@ -89,10 +93,12 @@ def unfollow(%User{} = follower, %User{} = following) do end _ -> - {:ok, nil} + {:ok, follower, following} end end + @spec after_update(follow_state(), User.t(), User.t()) :: + {:ok, User.t(), User.t()} | {:error, any()} defp after_update(state, %User{} = follower, %User{} = following) do with {:ok, following} <- User.update_follower_count(following), {:ok, follower} <- User.update_following_count(follower) do @@ -103,6 +109,8 @@ defp after_update(state, %User{} = follower, %User{} = following) do }) {:ok, follower, following} + else + err -> {:error, err} end end @@ -147,14 +155,13 @@ def following_count(%User{} = user) do |> Repo.aggregate(:count, :id) end - def get_follow_requests(%User{id: id}) do + def get_follow_requests_query(%User{id: id}) do __MODULE__ - |> join(:inner, [r], f in assoc(r, :follower)) + |> join(:inner, [r], f in assoc(r, :follower), as: :follower) |> where([r], r.state == ^:follow_pending) |> where([r], r.following_id == ^id) - |> where([r, f], f.is_active == true) - |> select([r, f], f) - |> Repo.all() + |> where([r, follower: f], f.is_active == true) + |> select([r, follower: f], f) end def following?(%User{id: follower_id}, %User{id: followed_id}) do diff --git a/lib/pleroma/formatter.ex b/lib/pleroma/formatter.ex index 575bf9b2d..fc841a550 100644 --- a/lib/pleroma/formatter.ex +++ b/lib/pleroma/formatter.ex @@ -124,8 +124,8 @@ def mentions_escape(text, options \\ []) do end end - def markdown_to_html(text) do - Earmark.as_html!(text, %Earmark.Options{compact_output: true}) + def markdown_to_html(text, opts \\ %{}) do + Earmark.as_html!(text, %Earmark.Options{compact_output: true} |> Map.merge(opts)) end def html_escape({text, mentions, hashtags}, type) do diff --git a/lib/pleroma/frontend.ex b/lib/pleroma/frontend.ex index adda71eef..dc9d55646 100644 --- a/lib/pleroma/frontend.ex +++ b/lib/pleroma/frontend.ex @@ -93,7 +93,7 @@ defp download_build(frontend_info, dest) do url = String.replace(frontend_info["build_url"], "${ref}", frontend_info["ref"]) with {:ok, %{status: 200, body: zip_body}} <- - Pleroma.HTTP.get(url, [], recv_timeout: 120_000) do + Pleroma.HTTP.get(url, [], receive_timeout: 120_000) do unzip(zip_body, dest) else {:error, e} -> {:error, e} diff --git a/lib/pleroma/gun.ex b/lib/pleroma/gun.ex deleted file mode 100644 index bef1c9872..000000000 --- a/lib/pleroma/gun.ex +++ /dev/null @@ -1,29 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2021 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Gun do - @callback open(charlist(), pos_integer(), map()) :: {:ok, pid()} - @callback info(pid()) :: map() - @callback close(pid()) :: :ok - @callback await_up(pid, pos_integer()) :: {:ok, atom()} | {:error, atom()} - @callback connect(pid(), map()) :: reference() - @callback await(pid(), reference()) :: {:response, :fin, 200, []} - @callback set_owner(pid(), pid()) :: :ok - - defp api, do: Pleroma.Config.get([Pleroma.Gun], Pleroma.Gun.API) - - def open(host, port, opts), do: api().open(host, port, opts) - - def info(pid), do: api().info(pid) - - def close(pid), do: api().close(pid) - - def await_up(pid, timeout \\ 5_000), do: api().await_up(pid, timeout) - - def connect(pid, opts), do: api().connect(pid, opts) - - def await(pid, ref), do: api().await(pid, ref) - - def set_owner(pid, owner), do: api().set_owner(pid, owner) -end diff --git a/lib/pleroma/gun/api.ex b/lib/pleroma/gun/api.ex deleted file mode 100644 index 24d542781..000000000 --- a/lib/pleroma/gun/api.ex +++ /dev/null @@ -1,46 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2021 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Gun.API do - @behaviour Pleroma.Gun - - alias Pleroma.Gun - - @gun_keys [ - :connect_timeout, - :http_opts, - :http2_opts, - :protocols, - :retry, - :retry_timeout, - :trace, - :transport, - :tls_opts, - :tcp_opts, - :socks_opts, - :ws_opts, - :supervise - ] - - @impl Gun - def open(host, port, opts \\ %{}), do: :gun.open(host, port, Map.take(opts, @gun_keys)) - - @impl Gun - defdelegate info(pid), to: :gun - - @impl Gun - defdelegate close(pid), to: :gun - - @impl Gun - defdelegate await_up(pid, timeout \\ 5_000), to: :gun - - @impl Gun - defdelegate connect(pid, opts), to: :gun - - @impl Gun - defdelegate await(pid, ref), to: :gun - - @impl Gun - defdelegate set_owner(pid, owner), to: :gun -end diff --git a/lib/pleroma/gun/conn.ex b/lib/pleroma/gun/conn.ex deleted file mode 100644 index a1210eabf..000000000 --- a/lib/pleroma/gun/conn.ex +++ /dev/null @@ -1,131 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2021 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Gun.Conn do - alias Pleroma.Gun - - require Logger - - def open(%URI{} = uri, opts) do - pool_opts = Pleroma.Config.get([:connections_pool], []) - - opts = - opts - |> Enum.into(%{}) - |> Map.put_new(:connect_timeout, pool_opts[:connect_timeout] || 5_000) - |> Map.put_new(:supervise, false) - |> maybe_add_tls_opts(uri) - - do_open(uri, opts) - end - - defp maybe_add_tls_opts(opts, %URI{scheme: "http"}), do: opts - - defp maybe_add_tls_opts(opts, %URI{scheme: "https"}) do - tls_opts = [ - verify: :verify_peer, - cacertfile: CAStore.file_path(), - depth: 20, - reuse_sessions: false, - log_level: :warning, - customize_hostname_check: [match_fun: :public_key.pkix_verify_hostname_match_fun(:https)] - ] - - tls_opts = - if Keyword.keyword?(opts[:tls_opts]) do - Keyword.merge(tls_opts, opts[:tls_opts]) - else - tls_opts - end - - Map.put(opts, :tls_opts, tls_opts) - end - - defp do_open(uri, %{proxy: {proxy_host, proxy_port}} = opts) do - connect_opts = - uri - |> destination_opts() - |> add_http2_opts(uri.scheme, Map.get(opts, :tls_opts, [])) - - with open_opts <- Map.delete(opts, :tls_opts), - {:ok, conn} <- Gun.open(proxy_host, proxy_port, open_opts), - {:ok, protocol} <- Gun.await_up(conn, opts[:connect_timeout]), - stream <- Gun.connect(conn, connect_opts), - {:response, :fin, 200, _} <- Gun.await(conn, stream) do - {:ok, conn, protocol} - else - error -> - Logger.warn( - "Opening proxied connection to #{compose_uri_log(uri)} failed with error #{inspect(error)}" - ) - - error - end - end - - defp do_open(uri, %{proxy: {proxy_type, proxy_host, proxy_port}} = opts) do - version = - proxy_type - |> to_string() - |> String.last() - |> case do - "4" -> 4 - _ -> 5 - end - - socks_opts = - uri - |> destination_opts() - |> add_http2_opts(uri.scheme, Map.get(opts, :tls_opts, [])) - |> Map.put(:version, version) - - opts = - opts - |> Map.put(:protocols, [:socks]) - |> Map.put(:socks_opts, socks_opts) - - with {:ok, conn} <- Gun.open(proxy_host, proxy_port, opts), - {:ok, protocol} <- Gun.await_up(conn, opts[:connect_timeout]) do - {:ok, conn, protocol} - else - error -> - Logger.warn( - "Opening socks proxied connection to #{compose_uri_log(uri)} failed with error #{inspect(error)}" - ) - - error - end - end - - defp do_open(%URI{host: host, port: port} = uri, opts) do - host = Pleroma.HTTP.AdapterHelper.parse_host(host) - - with {:ok, conn} <- Gun.open(host, port, opts), - {:ok, protocol} <- Gun.await_up(conn, opts[:connect_timeout]) do - {:ok, conn, protocol} - else - error -> - Logger.warn( - "Opening connection to #{compose_uri_log(uri)} failed with error #{inspect(error)}" - ) - - error - end - end - - defp destination_opts(%URI{host: host, port: port}) do - host = Pleroma.HTTP.AdapterHelper.parse_host(host) - %{host: host, port: port} - end - - defp add_http2_opts(opts, "https", tls_opts) do - Map.merge(opts, %{protocols: [:http2], transport: :tls, tls_opts: tls_opts}) - end - - defp add_http2_opts(opts, _, _), do: opts - - def compose_uri_log(%URI{scheme: scheme, host: host, path: path}) do - "#{scheme}://#{host}#{path}" - end -end diff --git a/lib/pleroma/gun/connection_pool.ex b/lib/pleroma/gun/connection_pool.ex deleted file mode 100644 index f9fd77ade..000000000 --- a/lib/pleroma/gun/connection_pool.ex +++ /dev/null @@ -1,86 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2021 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Gun.ConnectionPool do - @registry __MODULE__ - - alias Pleroma.Gun.ConnectionPool.WorkerSupervisor - - def children do - [ - {Registry, keys: :unique, name: @registry}, - Pleroma.Gun.ConnectionPool.WorkerSupervisor - ] - end - - @spec get_conn(URI.t(), keyword()) :: {:ok, pid()} | {:error, term()} - def get_conn(uri, opts) do - key = "#{uri.scheme}:#{uri.host}:#{uri.port}" - - case Registry.lookup(@registry, key) do - # The key has already been registered, but connection is not up yet - [{worker_pid, nil}] -> - get_gun_pid_from_worker(worker_pid, true) - - [{worker_pid, {gun_pid, _used_by, _crf, _last_reference}}] -> - GenServer.call(worker_pid, :add_client) - {:ok, gun_pid} - - [] -> - # :gun.set_owner fails in :connected state for whatevever reason, - # so we open the connection in the process directly and send it's pid back - # We trust gun to handle timeouts by itself - case WorkerSupervisor.start_worker([key, uri, opts, self()]) do - {:ok, worker_pid} -> - get_gun_pid_from_worker(worker_pid, false) - - {:error, {:already_started, worker_pid}} -> - get_gun_pid_from_worker(worker_pid, true) - - err -> - err - end - end - end - - defp get_gun_pid_from_worker(worker_pid, register) do - # GenServer.call will block the process for timeout length if - # the server crashes on startup (which will happen if gun fails to connect) - # so instead we use cast + monitor - - ref = Process.monitor(worker_pid) - if register, do: GenServer.cast(worker_pid, {:add_client, self()}) - - receive do - {:conn_pid, pid} -> - Process.demonitor(ref) - {:ok, pid} - - {:DOWN, ^ref, :process, ^worker_pid, reason} -> - case reason do - {:shutdown, {:error, _} = error} -> error - {:shutdown, error} -> {:error, error} - _ -> {:error, reason} - end - end - end - - @spec release_conn(pid()) :: :ok - def release_conn(conn_pid) do - # :ets.fun2ms(fn {_, {worker_pid, {gun_pid, _, _, _}}} when gun_pid == conn_pid -> - # worker_pid end) - query_result = - Registry.select(@registry, [ - {{:_, :"$1", {:"$2", :_, :_, :_}}, [{:==, :"$2", conn_pid}], [:"$1"]} - ]) - - case query_result do - [worker_pid] -> - GenServer.call(worker_pid, :remove_client) - - [] -> - :ok - end - end -end diff --git a/lib/pleroma/gun/connection_pool/reclaimer.ex b/lib/pleroma/gun/connection_pool/reclaimer.ex deleted file mode 100644 index 4c643d7cb..000000000 --- a/lib/pleroma/gun/connection_pool/reclaimer.ex +++ /dev/null @@ -1,89 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2021 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Gun.ConnectionPool.Reclaimer do - use GenServer, restart: :temporary - - defp registry, do: Pleroma.Gun.ConnectionPool - - def start_monitor do - pid = - case :gen_server.start(__MODULE__, [], name: {:via, Registry, {registry(), "reclaimer"}}) do - {:ok, pid} -> - pid - - {:error, {:already_registered, pid}} -> - pid - end - - {pid, Process.monitor(pid)} - end - - @impl true - def init(_) do - {:ok, nil, {:continue, :reclaim}} - end - - @impl true - def handle_continue(:reclaim, _) do - max_connections = Pleroma.Config.get([:connections_pool, :max_connections]) - - reclaim_max = - [:connections_pool, :reclaim_multiplier] - |> Pleroma.Config.get() - |> Kernel.*(max_connections) - |> round - |> max(1) - - :telemetry.execute([:pleroma, :connection_pool, :reclaim, :start], %{}, %{ - max_connections: max_connections, - reclaim_max: reclaim_max - }) - - # :ets.fun2ms( - # fn {_, {worker_pid, {_, used_by, crf, last_reference}}} when used_by == [] -> - # {worker_pid, crf, last_reference} end) - unused_conns = - Registry.select( - registry(), - [ - {{:_, :"$1", {:_, :"$2", :"$3", :"$4"}}, [{:==, :"$2", []}], [{{:"$1", :"$3", :"$4"}}]} - ] - ) - - case unused_conns do - [] -> - :telemetry.execute( - [:pleroma, :connection_pool, :reclaim, :stop], - %{reclaimed_count: 0}, - %{ - max_connections: max_connections - } - ) - - {:stop, :no_unused_conns, nil} - - unused_conns -> - reclaimed = - unused_conns - |> Enum.sort(fn {_pid1, crf1, last_reference1}, {_pid2, crf2, last_reference2} -> - crf1 <= crf2 and last_reference1 <= last_reference2 - end) - |> Enum.take(reclaim_max) - - reclaimed - |> Enum.each(fn {pid, _, _} -> - DynamicSupervisor.terminate_child(Pleroma.Gun.ConnectionPool.WorkerSupervisor, pid) - end) - - :telemetry.execute( - [:pleroma, :connection_pool, :reclaim, :stop], - %{reclaimed_count: Enum.count(reclaimed)}, - %{max_connections: max_connections} - ) - - {:stop, :normal, nil} - end - end -end diff --git a/lib/pleroma/gun/connection_pool/worker.ex b/lib/pleroma/gun/connection_pool/worker.ex deleted file mode 100644 index a3fa75386..000000000 --- a/lib/pleroma/gun/connection_pool/worker.ex +++ /dev/null @@ -1,153 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2021 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Gun.ConnectionPool.Worker do - alias Pleroma.Gun - use GenServer, restart: :temporary - - defp registry, do: Pleroma.Gun.ConnectionPool - - def start_link([key | _] = opts) do - GenServer.start_link(__MODULE__, opts, name: {:via, Registry, {registry(), key}}) - end - - @impl true - def init([_key, _uri, _opts, _client_pid] = opts) do - {:ok, nil, {:continue, {:connect, opts}}} - end - - @impl true - def handle_continue({:connect, [key, uri, opts, client_pid]}, _) do - with {:ok, conn_pid, protocol} <- Gun.Conn.open(uri, opts), - Process.link(conn_pid) do - time = :erlang.monotonic_time(:millisecond) - - {_, _} = - Registry.update_value(registry(), key, fn _ -> - {conn_pid, [client_pid], 1, time} - end) - - send(client_pid, {:conn_pid, conn_pid}) - - {:noreply, - %{ - key: key, - timer: nil, - client_monitors: %{client_pid => Process.monitor(client_pid)}, - protocol: protocol - }, :hibernate} - else - err -> - {:stop, {:shutdown, err}, nil} - end - end - - @impl true - def handle_cast({:add_client, client_pid}, state) do - case handle_call(:add_client, {client_pid, nil}, state) do - {:reply, conn_pid, state, :hibernate} -> - send(client_pid, {:conn_pid, conn_pid}) - {:noreply, state, :hibernate} - end - end - - @impl true - def handle_cast({:remove_client, client_pid}, state) do - case handle_call(:remove_client, {client_pid, nil}, state) do - {:reply, _, state, :hibernate} -> - {:noreply, state, :hibernate} - end - end - - @impl true - def handle_call(:add_client, {client_pid, _}, %{key: key, protocol: protocol} = state) do - time = :erlang.monotonic_time(:millisecond) - - {{conn_pid, used_by, _, _}, _} = - Registry.update_value(registry(), key, fn {conn_pid, used_by, crf, last_reference} -> - {conn_pid, [client_pid | used_by], crf(time - last_reference, crf), time} - end) - - :telemetry.execute( - [:pleroma, :connection_pool, :client, :add], - %{client_pid: client_pid, clients: used_by}, - %{key: state.key, protocol: protocol} - ) - - state = - if state.timer != nil do - Process.cancel_timer(state[:timer]) - %{state | timer: nil} - else - state - end - - ref = Process.monitor(client_pid) - - state = put_in(state.client_monitors[client_pid], ref) - {:reply, conn_pid, state, :hibernate} - end - - @impl true - def handle_call(:remove_client, {client_pid, _}, %{key: key} = state) do - {{_conn_pid, used_by, _crf, _last_reference}, _} = - Registry.update_value(registry(), key, fn {conn_pid, used_by, crf, last_reference} -> - {conn_pid, List.delete(used_by, client_pid), crf, last_reference} - end) - - {ref, state} = pop_in(state.client_monitors[client_pid]) - - Process.demonitor(ref, [:flush]) - - timer = - if used_by == [] do - max_idle = Pleroma.Config.get([:connections_pool, :max_idle_time], 30_000) - Process.send_after(self(), :idle_close, max_idle) - else - nil - end - - {:reply, :ok, %{state | timer: timer}, :hibernate} - end - - @impl true - def handle_info(:idle_close, state) do - # Gun monitors the owner process, and will close the connection automatically - # when it's terminated - {:stop, :normal, state} - end - - @impl true - def handle_info({:gun_up, _pid, _protocol}, state) do - {:noreply, state, :hibernate} - end - - # Gracefully shutdown if the connection got closed without any streams left - @impl true - def handle_info({:gun_down, _pid, _protocol, _reason, []}, state) do - {:stop, :normal, state} - end - - # Otherwise, wait for retry - @impl true - def handle_info({:gun_down, _pid, _protocol, _reason, _killed_streams}, state) do - {:noreply, state, :hibernate} - end - - @impl true - def handle_info({:DOWN, _ref, :process, pid, reason}, state) do - :telemetry.execute( - [:pleroma, :connection_pool, :client, :dead], - %{client_pid: pid, reason: reason}, - %{key: state.key} - ) - - handle_cast({:remove_client, pid}, state) - end - - # LRFU policy: https://citeseerx.ist.psu.edu/viewdoc/summary?doi=10.1.1.55.1478 - defp crf(time_delta, prev_crf) do - 1 + :math.pow(0.5, 0.0001 * time_delta) * prev_crf - end -end diff --git a/lib/pleroma/gun/connection_pool/worker_supervisor.ex b/lib/pleroma/gun/connection_pool/worker_supervisor.ex deleted file mode 100644 index 016b675f4..000000000 --- a/lib/pleroma/gun/connection_pool/worker_supervisor.ex +++ /dev/null @@ -1,49 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2021 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Gun.ConnectionPool.WorkerSupervisor do - @moduledoc "Supervisor for pool workers. Does not do anything except enforce max connection limit" - - use DynamicSupervisor - - def start_link(opts) do - DynamicSupervisor.start_link(__MODULE__, opts, name: __MODULE__) - end - - def init(_opts) do - DynamicSupervisor.init( - strategy: :one_for_one, - max_children: Pleroma.Config.get([:connections_pool, :max_connections]) - ) - end - - 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 - :telemetry.execute([:pleroma, :connection_pool, :provision_failure], %{opts: opts}) - {:error, :pool_full} - else - start_worker(opts, true) - end - - res -> - res - end - end - - defp free_pool do - wait_for_reclaimer_finish(Pleroma.Gun.ConnectionPool.Reclaimer.start_monitor()) - end - - defp wait_for_reclaimer_finish({pid, mon}) do - receive do - {:DOWN, ^mon, :process, ^pid, :no_unused_conns} -> - :error - - {:DOWN, ^mon, :process, ^pid, :normal} -> - :ok - end - end -end diff --git a/lib/pleroma/hashtag.ex b/lib/pleroma/hashtag.ex index 53e2e9c89..9030ee4e9 100644 --- a/lib/pleroma/hashtag.ex +++ b/lib/pleroma/hashtag.ex @@ -10,6 +10,7 @@ defmodule Pleroma.Hashtag do alias Ecto.Multi alias Pleroma.Hashtag + alias Pleroma.User.HashtagFollow alias Pleroma.Object alias Pleroma.Repo @@ -27,6 +28,14 @@ def normalize_name(name) do |> String.trim() end + def get_by_id(id) do + Repo.get(Hashtag, id) + end + + def get_by_name(name) do + Repo.get_by(Hashtag, name: normalize_name(name)) + end + def get_or_create_by_name(name) do changeset = changeset(%Hashtag{}, %{name: name}) @@ -103,4 +112,22 @@ def delete_unreferenced(ids) do {:ok, deleted_count} end end + + def get_followers(%Hashtag{id: hashtag_id}) do + from(hf in HashtagFollow) + |> where([hf], hf.hashtag_id == ^hashtag_id) + |> join(:inner, [hf], u in assoc(hf, :user)) + |> select([hf, u], u.id) + |> Repo.all() + end + + def get_recipients_for_activity(%Pleroma.Activity{object: %{hashtags: tags}}) + when is_list(tags) do + tags + |> Enum.map(&get_followers/1) + |> List.flatten() + |> Enum.uniq() + end + + def get_recipients_for_activity(_activity), do: [] end diff --git a/lib/pleroma/helpers/media_helper.ex b/lib/pleroma/helpers/media_helper.ex index d0c3ab5cc..cb95d0e68 100644 --- a/lib/pleroma/helpers/media_helper.ex +++ b/lib/pleroma/helpers/media_helper.ex @@ -104,10 +104,10 @@ defp run_fifo(fifo_path, env, executable, args) do args: args ]) - fifo = Port.open(to_charlist(fifo_path), [:eof, :binary, :stream, :out]) + fifo = File.open!(fifo_path, [:append, :binary]) fix = Pleroma.Helpers.QtFastStart.fix(env.body) - true = Port.command(fifo, fix) - :erlang.port_close(fifo) + IO.binwrite(fifo, fix) + File.close(fifo) loop_recv(pid) after File.rm(fifo_path) diff --git a/lib/pleroma/http.ex b/lib/pleroma/http.ex index d8028651c..9346ffa16 100644 --- a/lib/pleroma/http.ex +++ b/lib/pleroma/http.ex @@ -27,10 +27,10 @@ defmodule Pleroma.HTTP do nil | {:ok, Env.t()} | {:error, any()} def get(url, headers \\ [], options \\ []) def get(nil, _, _), do: nil - def get(url, headers, options), do: request(:get, url, "", headers, options) + def get(url, headers, options), do: request(:get, url, nil, headers, options) @spec head(Request.url(), Request.headers(), keyword()) :: {:ok, Env.t()} | {:error, any()} - def head(url, headers \\ [], options \\ []), do: request(:head, url, "", headers, options) + def head(url, headers \\ [], options \\ []), do: request(:head, url, nil, headers, options) @doc """ Performs POST request. @@ -62,10 +62,17 @@ def request(method, url, body, headers, options) when is_binary(url) do uri = URI.parse(url) adapter_opts = AdapterHelper.options(uri, options || []) + adapter_opts = + if uri.scheme == :https do + AdapterHelper.maybe_add_cacerts(adapter_opts, :public_key.cacerts_get()) + else + adapter_opts + end + options = put_in(options[:adapter], adapter_opts) params = options[:params] || [] request = build_request(method, headers, options, url, body, params) - client = Tesla.client([Tesla.Middleware.FollowRedirects]) + client = Tesla.client([Tesla.Middleware.FollowRedirects, Tesla.Middleware.Telemetry]) request(client, request) end diff --git a/lib/pleroma/http/adapter_helper.ex b/lib/pleroma/http/adapter_helper.ex index 4949dd727..c1d95b3a4 100644 --- a/lib/pleroma/http/adapter_helper.ex +++ b/lib/pleroma/http/adapter_helper.ex @@ -14,9 +14,7 @@ defmodule Pleroma.HTTP.AdapterHelper do alias Pleroma.HTTP.AdapterHelper require Logger - @type proxy :: - {Connection.host(), pos_integer()} - | {Connection.proxy_type(), Connection.host(), pos_integer()} + @type proxy :: {Connection.proxy_type(), Connection.host(), pos_integer(), list()} @callback options(keyword(), URI.t()) :: keyword() @@ -25,7 +23,6 @@ def format_proxy(nil), do: nil def format_proxy(proxy_url) do case parse_proxy(proxy_url) do - {:ok, host, port} -> {:http, host, port, []} {:ok, type, host, port} -> {type, host, port, []} _ -> nil end @@ -50,6 +47,33 @@ def maybe_add_proxy_pool(opts, proxy) do |> put_in([:pools, :default, :conn_opts, :proxy], proxy) end + def maybe_add_cacerts(opts, nil), do: opts + + def maybe_add_cacerts(opts, cacerts) do + opts + |> maybe_add_pools() + |> maybe_add_default_pool() + |> maybe_add_conn_opts() + |> maybe_add_transport_opts() + |> put_in([:pools, :default, :conn_opts, :transport_opts, :cacerts], cacerts) + end + + def add_pool_size(opts, pool_size) do + opts + |> maybe_add_pools() + |> maybe_add_default_pool() + |> put_in([:pools, :default, :size], pool_size) + end + + def ensure_ipv6(opts) do + # Default transport opts already enable IPv6, so just ensure they're loaded + opts + |> maybe_add_pools() + |> maybe_add_default_pool() + |> maybe_add_conn_opts() + |> maybe_add_transport_opts() + end + defp maybe_add_pools(opts) do if Keyword.has_key?(opts, :pools) do opts @@ -78,6 +102,20 @@ defp maybe_add_conn_opts(opts) do end end + defp maybe_add_transport_opts(opts) do + transport_opts = get_in(opts, [:pools, :default, :conn_opts, :transport_opts]) + + opts = + unless is_nil(transport_opts) do + opts + else + put_in(opts, [:pools, :default, :conn_opts, :transport_opts], []) + end + + # IPv6 is disabled and IPv4 enabled by default; ensure we can use both + put_in(opts, [:pools, :default, :conn_opts, :transport_opts, :inet6], true) + end + @doc """ Merge default connection & adapter options with received ones. """ @@ -94,11 +132,11 @@ defp proxy_type("https"), do: {:ok, :https} defp proxy_type(_), do: {:error, :unknown} @spec parse_proxy(String.t() | tuple() | nil) :: - {:ok, host(), pos_integer()} - | {:ok, proxy_type(), host(), pos_integer()} + {:ok, proxy_type(), host(), pos_integer()} | {:error, atom()} | nil def parse_proxy(nil), do: nil + def parse_proxy(""), do: nil def parse_proxy(proxy) when is_binary(proxy) do with %URI{} = uri <- URI.parse(proxy), @@ -106,7 +144,7 @@ def parse_proxy(proxy) when is_binary(proxy) do {:ok, type, uri.host, uri.port} else e -> - Logger.warn("Parsing proxy failed #{inspect(proxy)}, #{inspect(e)}") + Logger.warning("Parsing proxy failed #{inspect(proxy)}, #{inspect(e)}") {:error, :invalid_proxy} end end @@ -116,7 +154,7 @@ def parse_proxy(proxy) when is_tuple(proxy) do {:ok, type, 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/adapter_helper/default.ex b/lib/pleroma/http/adapter_helper/default.ex index 630536871..fc377b376 100644 --- a/lib/pleroma/http/adapter_helper/default.ex +++ b/lib/pleroma/http/adapter_helper/default.ex @@ -10,7 +10,13 @@ defmodule Pleroma.HTTP.AdapterHelper.Default do @spec options(keyword(), URI.t()) :: keyword() def options(opts, _uri) do proxy = Pleroma.Config.get([:http, :proxy_url]) - AdapterHelper.maybe_add_proxy(opts, AdapterHelper.format_proxy(proxy)) + pool_timeout = Pleroma.Config.get([:http, :pool_timeout], 5000) + receive_timeout = Pleroma.Config.get([:http, :receive_timeout], 15_000) + + opts + |> AdapterHelper.maybe_add_proxy(AdapterHelper.format_proxy(proxy)) + |> Keyword.put(:pool_timeout, pool_timeout) + |> Keyword.put(:receive_timeout, receive_timeout) end @spec get_conn(URI.t(), keyword()) :: {:ok, keyword()} diff --git a/lib/pleroma/instances.ex b/lib/pleroma/instances.ex index 6b57e56da..daea102db 100644 --- a/lib/pleroma/instances.ex +++ b/lib/pleroma/instances.ex @@ -43,4 +43,6 @@ def host(url_or_host) when is_binary(url_or_host) do url_or_host end end + + defdelegate set_request_signatures(url_or_host), to: Instance end diff --git a/lib/pleroma/instances/instance.ex b/lib/pleroma/instances/instance.ex index 533dbbb82..5c70748b6 100644 --- a/lib/pleroma/instances/instance.ex +++ b/lib/pleroma/instances/instance.ex @@ -5,6 +5,8 @@ defmodule Pleroma.Instances.Instance do @moduledoc "Instance." + @cachex Pleroma.Config.get([:cachex, :provider], Cachex) + alias Pleroma.Instances alias Pleroma.Instances.Instance alias Pleroma.Repo @@ -22,7 +24,9 @@ defmodule Pleroma.Instances.Instance do field(:host, :string) field(:unreachable_since, :naive_datetime_usec) field(:favicon, :string) - field(:favicon_updated_at, :naive_datetime) + field(:metadata_updated_at, :naive_datetime) + field(:nodeinfo, :map, default: %{}) + field(:has_request_signatures, :boolean) timestamps() end @@ -31,7 +35,14 @@ defmodule Pleroma.Instances.Instance do def changeset(struct, params \\ %{}) do struct - |> cast(params, [:host, :unreachable_since, :favicon, :favicon_updated_at]) + |> cast(params, [ + :host, + :unreachable_since, + :favicon, + :nodeinfo, + :metadata_updated_at, + :has_request_signatures + ]) |> validate_required([:host]) |> unique_constraint(:host) end @@ -138,63 +149,138 @@ defp parse_datetime(datetime) when is_binary(datetime) do defp parse_datetime(datetime), do: datetime - def get_or_update_favicon(%URI{host: host} = instance_uri) do - existing_record = Repo.get_by(Instance, %{host: host}) + def needs_update(nil), do: true + + def needs_update(%Instance{metadata_updated_at: nil}), do: true + + def needs_update(%Instance{metadata_updated_at: metadata_updated_at}) do now = NaiveDateTime.utc_now() + NaiveDateTime.diff(now, metadata_updated_at) > 86_400 + end - if existing_record && existing_record.favicon_updated_at && - NaiveDateTime.diff(now, existing_record.favicon_updated_at) < 86_400 do - existing_record.favicon + def local do + %Instance{ + host: Pleroma.Web.Endpoint.host(), + favicon: Pleroma.Web.Endpoint.url() <> "/favicon.png", + nodeinfo: Pleroma.Web.Nodeinfo.Nodeinfo.get_nodeinfo("2.1") + } + end + + def update_metadata(%URI{host: host} = uri) do + Logger.debug("Checking metadata for #{host}") + existing_record = Repo.get_by(Instance, %{host: host}) + + if reachable?(host) do + do_update_metadata(uri, existing_record) else - favicon = scrape_favicon(instance_uri) + {:discard, :unreachable} + end + end + + defp do_update_metadata(%URI{host: host} = uri, existing_record) do + if existing_record do + if needs_update(existing_record) do + Logger.info("Updating metadata for #{host}") + favicon = scrape_favicon(uri) + nodeinfo = scrape_nodeinfo(uri) - if existing_record do existing_record - |> changeset(%{favicon: favicon, favicon_updated_at: now}) + |> changeset(%{ + host: host, + favicon: favicon, + nodeinfo: nodeinfo, + metadata_updated_at: NaiveDateTime.utc_now() + }) |> Repo.update() else - %Instance{} - |> changeset(%{host: host, favicon: favicon, favicon_updated_at: now}) - |> Repo.insert() + {:discard, "Does not require update"} end + else + favicon = scrape_favicon(uri) + nodeinfo = scrape_nodeinfo(uri) - favicon + Logger.info("Creating metadata for #{host}") + + %Instance{} + |> changeset(%{ + host: host, + favicon: favicon, + nodeinfo: nodeinfo, + metadata_updated_at: NaiveDateTime.utc_now() + }) + |> Repo.insert() end - rescue - e -> - Logger.warn("Instance.get_or_update_favicon(\"#{host}\") error: #{inspect(e)}") + end + + def get_favicon(%URI{host: host}) do + existing_record = Repo.get_by(Instance, %{host: host}) + + if existing_record do + existing_record.favicon + else nil + end + end + + defp scrape_nodeinfo(%URI{} = instance_uri) do + with true <- Pleroma.Config.get([:instances_nodeinfo, :enabled]), + {_, true} <- {:reachable, reachable?(instance_uri.host)}, + {:ok, %Tesla.Env{status: 200, body: body}} <- + Tesla.get( + "https://#{instance_uri.host}/.well-known/nodeinfo", + headers: [{"Accept", "application/json"}] + ), + {:ok, json} <- Jason.decode(body), + {:ok, %{"links" => links}} <- {:ok, json}, + {:ok, %{"href" => href}} <- + {:ok, + Enum.find(links, &(&1["rel"] == "http://nodeinfo.diaspora.software/ns/schema/2.0"))}, + {:ok, %Tesla.Env{body: data}} <- + Pleroma.HTTP.get(href, [{"accept", "application/json"}], []), + {:length, true} <- {:length, String.length(data) < 50_000}, + {:ok, nodeinfo} <- Jason.decode(data) do + nodeinfo + else + {:reachable, false} -> + Logger.debug( + "Instance.scrape_nodeinfo(\"#{to_string(instance_uri)}\") ignored unreachable host" + ) + + nil + + {:length, false} -> + Logger.debug( + "Instance.scrape_nodeinfo(\"#{to_string(instance_uri)}\") ignored too long body" + ) + + nil + + _ -> + nil + end end defp scrape_favicon(%URI{} = instance_uri) do - try do - with {_, true} <- {:reachable, reachable?(instance_uri.host)}, - {:ok, %Tesla.Env{body: html}} <- - Pleroma.HTTP.get(to_string(instance_uri), [{"accept", "text/html"}], []), - {_, [favicon_rel | _]} when is_binary(favicon_rel) <- - {:parse, - html |> Floki.parse_document!() |> Floki.attribute("link[rel=icon]", "href")}, - {_, favicon} when is_binary(favicon) <- - {:merge, URI.merge(instance_uri, favicon_rel) |> to_string()} do - favicon - else - {:reachable, false} -> - Logger.debug( - "Instance.scrape_favicon(\"#{to_string(instance_uri)}\") ignored unreachable host" - ) - - nil - - _ -> - nil - end - rescue - e -> - Logger.warn( - "Instance.scrape_favicon(\"#{to_string(instance_uri)}\") error: #{inspect(e)}" + with true <- Pleroma.Config.get([:instances_favicons, :enabled]), + {_, true} <- {:reachable, reachable?(instance_uri.host)}, + {:ok, %Tesla.Env{body: html}} <- + Pleroma.HTTP.get(to_string(instance_uri), [{"accept", "text/html"}], []), + {_, [favicon_rel | _]} when is_binary(favicon_rel) <- + {:parse, html |> Floki.parse_document!() |> Floki.attribute("link[rel=icon]", "href")}, + {_, favicon} when is_binary(favicon) <- + {:merge, URI.merge(instance_uri, favicon_rel) |> to_string()}, + {:length, true} <- {:length, String.length(favicon) < 255} do + favicon + else + {:reachable, false} -> + Logger.debug( + "Instance.scrape_favicon(\"#{to_string(instance_uri)}\") ignored unreachable host" ) nil + + _ -> + nil end end @@ -217,4 +303,45 @@ def perform(:delete_instance, host) when is_binary(host) do end) |> Stream.run() end + + def get_by_url(url_or_host) do + url = host(url_or_host) + Repo.get_by(Instance, host: url) + end + + def get_cached_by_url(url_or_host) do + url = host(url_or_host) + + if url == Pleroma.Web.Endpoint.host() do + {:ok, local()} + else + @cachex.fetch!(:instances_cache, "instances:#{url}", fn _ -> + with %Instance{} = instance <- get_by_url(url) do + {:commit, {:ok, instance}} + else + _ -> {:ignore, nil} + end + end) + end + end + + def set_request_signatures(url_or_host) when is_binary(url_or_host) do + host = host(url_or_host) + existing_record = Repo.get_by(Instance, %{host: host}) + changes = %{has_request_signatures: true} + + cond do + is_nil(existing_record) -> + %Instance{} + |> changeset(Map.put(changes, :host, host)) + |> Repo.insert() + + true -> + existing_record + |> changeset(changes) + |> Repo.update() + end + end + + def set_request_signatures(_), do: {:error, :invalid_input} end diff --git a/lib/pleroma/iso639.ex b/lib/pleroma/iso639.ex new file mode 100644 index 000000000..a80fab7e9 --- /dev/null +++ b/lib/pleroma/iso639.ex @@ -0,0 +1,11 @@ +defmodule Pleroma.ISO639 do + @file "priv/language-codes.json" + @data File.read!(@file) + |> Jason.decode!() + + for %{"alpha2" => alpha2} <- @data do + def valid_alpha2?(unquote(alpha2)), do: true + end + + def valid_alpha2?(_alpha2), do: false +end diff --git a/lib/pleroma/job_queue_monitor.ex b/lib/pleroma/job_queue_monitor.ex index b5f124923..8d81ffcac 100644 --- a/lib/pleroma/job_queue_monitor.ex +++ b/lib/pleroma/job_queue_monitor.ex @@ -15,8 +15,19 @@ def start_link(_) do @impl true def init(state) do - :telemetry.attach("oban-monitor-failure", [:oban, :job, :exception], &handle_event/4, nil) - :telemetry.attach("oban-monitor-success", [:oban, :job, :stop], &handle_event/4, nil) + :telemetry.attach( + "oban-monitor-failure", + [:oban, :job, :exception], + &Pleroma.JobQueueMonitor.handle_event/4, + nil + ) + + :telemetry.attach( + "oban-monitor-success", + [:oban, :job, :stop], + &Pleroma.JobQueueMonitor.handle_event/4, + nil + ) {:ok, state} end diff --git a/lib/pleroma/maintenance.ex b/lib/pleroma/maintenance.ex index 41c799712..736fbf317 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/support/base_migrator.ex b/lib/pleroma/migrators/support/base_migrator.ex index 1f8a5402b..02c6ed080 100644 --- a/lib/pleroma/migrators/support/base_migrator.ex +++ b/lib/pleroma/migrators/support/base_migrator.ex @@ -14,7 +14,7 @@ defmodule Pleroma.Migrators.Support.BaseMigrator do @callback fault_rate_allowance() :: integer() | float() defmacro __using__(_opts) do - quote do + quote generated: true do use GenServer require Logger @@ -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." ) diff --git a/lib/pleroma/moderation_log.ex b/lib/pleroma/moderation_log.ex index 7da8d0c63..b94d53913 100644 --- a/lib/pleroma/moderation_log.ex +++ b/lib/pleroma/moderation_log.ex @@ -237,7 +237,8 @@ def insert_log(%{actor: %User{}, action: action, target: target} = attrs) insert_log_entry_with_message(%ModerationLog{data: data}) 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/notification.ex b/lib/pleroma/notification.ex index 3995be01f..885d61233 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -195,6 +195,7 @@ defp exclude_filtered(query, user) do from([_n, a, o] in query, where: fragment("not(?->>'content' ~* ?)", o.data, ^regex) or + fragment("?->>'content' is null", o.data) or fragment("?->>'actor' = ?", o.data, ^user.ap_id) ) end @@ -695,7 +696,7 @@ def skip?( cond do opts[:type] == "poll" -> false user.ap_id == actor -> false - !User.following?(follower, user) -> true + !User.following?(user, follower) -> true true -> false end end diff --git a/lib/pleroma/object.ex b/lib/pleroma/object.ex index a75d85c47..844251a18 100644 --- a/lib/pleroma/object.ex +++ b/lib/pleroma/object.ex @@ -240,7 +240,7 @@ def delete(%Object{data: %{"id" => id}} = object) do {:ok, _} <- invalid_object_cache(object) do cleanup_attachments( Config.get([:instance, :cleanup_attachments]), - %{"object" => object} + %{object: object} ) {:ok, object, deleted_activity} @@ -249,7 +249,7 @@ def delete(%Object{data: %{"id" => id}} = object) do @spec cleanup_attachments(boolean(), %{required(:object) => map()}) :: {:ok, Oban.Job.t() | nil} - def cleanup_attachments(true, %{"object" => _} = params) do + def cleanup_attachments(true, %{object: _} = params) do AttachmentsCleanupWorker.enqueue("cleanup_attachments", params) end diff --git a/lib/pleroma/object/containment.ex b/lib/pleroma/object/containment.ex index 040537acf..37bc20e4d 100644 --- a/lib/pleroma/object/containment.ex +++ b/lib/pleroma/object/containment.ex @@ -11,6 +11,9 @@ defmodule Pleroma.Object.Containment do Object containment is an important step in validating remote objects to prevent spoofing, therefore removal of object containment functions is NOT recommended. """ + + alias Pleroma.Web.ActivityPub.Transmogrifier + def get_actor(%{"actor" => actor}) when is_binary(actor) do actor end @@ -47,6 +50,31 @@ def get_object(_) do defp compare_uris(%URI{host: host} = _id_uri, %URI{host: host} = _other_uri), do: :ok defp compare_uris(_id_uri, _other_uri), do: :error + defp compare_uris_exact(uri, uri), do: :ok + + defp compare_uris_exact(%URI{} = id, %URI{} = other), + do: compare_uris_exact(URI.to_string(id), URI.to_string(other)) + + defp compare_uris_exact(id_uri, other_uri) + when is_binary(id_uri) and is_binary(other_uri) do + norm_id = String.replace_suffix(id_uri, "/", "") + norm_other = String.replace_suffix(other_uri, "/", "") + if norm_id == norm_other, do: :ok, else: :error + end + + @doc """ + Checks whether an URL to fetch from is from the local server. + + We never want to fetch from ourselves; if it’s not in the database + it can’t be authentic and must be a counterfeit. + """ + def contain_local_fetch(id) do + case compare_uris(URI.parse(id), Pleroma.Web.Endpoint.struct_url()) do + :ok -> :error + _ -> :ok + end + end + @doc """ Checks that an imported AP object's actor matches the host it came from. """ @@ -62,8 +90,31 @@ def contain_origin(id, %{"actor" => _actor} = params) do def contain_origin(id, %{"attributedTo" => actor} = params), do: contain_origin(id, Map.put(params, "actor", actor)) - def contain_origin(_id, _data), do: :error + def contain_origin(_id, _data), do: :ok + @doc """ + Check whether the fetch URL (after redirects) exactly (sans tralining slash) matches either + the canonical ActivityPub id or the objects url field (for display URLs from *key and Mastodon) + + Since this is meant to be used for fetches, anonymous or transient objects are not accepted here. + """ + def contain_id_to_fetch(url, %{"id" => id} = data) when is_binary(id) do + with {:id, :error} <- {:id, compare_uris_exact(id, url)}, + # "url" can be a "Link" object and this is checked before full normalisation + display_url <- Transmogrifier.fix_url(data)["url"], + true <- display_url != nil do + compare_uris_exact(display_url, url) + else + {:id, :ok} -> :ok + _ -> :error + end + end + + def contain_id_to_fetch(_url, _data), do: :error + + @doc """ + Check whether the object id is from the same host as another id + """ def contain_origin_from_id(id, %{"id" => other_id} = _params) when is_binary(other_id) do id_uri = URI.parse(id) other_uri = URI.parse(other_id) @@ -85,4 +136,12 @@ def contain_child(%{"object" => %{"id" => id, "attributedTo" => _} = object}), do: contain_origin(id, object) def contain_child(_), do: :ok + + @doc "Checks whether two URIs belong to the same domain" + def same_origin(id1, id2) do + uri1 = URI.parse(id1) + uri2 = URI.parse(id2) + + compare_uris(uri1, uri2) + end end diff --git a/lib/pleroma/object/fetcher.ex b/lib/pleroma/object/fetcher.ex index 8ec28345f..267a82b27 100644 --- a/lib/pleroma/object/fetcher.ex +++ b/lib/pleroma/object/fetcher.ex @@ -4,6 +4,7 @@ defmodule Pleroma.Object.Fetcher do alias Pleroma.HTTP + alias Pleroma.Instances alias Pleroma.Maps alias Pleroma.Object alias Pleroma.Object.Containment @@ -17,6 +18,16 @@ defmodule Pleroma.Object.Fetcher do require Logger require Pleroma.Constants + @moduledoc """ + This module deals with correctly fetching Acitivity Pub objects in a safe way. + + The core function is `fetch_and_contain_remote_object_from_id/1` which performs + the actual fetch and common safety and authenticity checks. Other `fetch_*` + function use the former and perform some additional tasks + """ + + @mix_env Mix.env() + defp touch_changeset(changeset) do updated_at = NaiveDateTime.utc_now() @@ -102,25 +113,38 @@ defp reinject_object(%Object{} = object, new_data) do end end + @doc "Assumes object already is in our database and refetches from remote to update (e.g. for polls)" def refetch_object(%Object{data: %{"id" => id}} = object) do with {:local, false} <- {:local, Object.local?(object)}, {:ok, new_data} <- fetch_and_contain_remote_object_from_id(id), + {:id, true} <- {:id, new_data["id"] == id}, {:ok, object} <- reinject_object(object, new_data) do {:ok, object} else {:local, true} -> {:ok, object} + {:id, false} -> {:error, "Object id changed on refetch"} e -> {:error, e} end end - # Note: will create a Create activity, which we need internally at the moment. + @doc """ + Fetches a new object and puts it through the processing pipeline for inbound objects + + Note: will also insert a fake Create activity, since atm we internally + need everything to be traced back to a Create activity. + """ def fetch_object_from_id(id, options \\ []) do - with {_, nil} <- {:fetch_object, Object.get_cached_by_ap_id(id)}, + with %URI{} = uri <- URI.parse(id), + # let's check the URI is even vaguely valid first + {:scheme, true} <- {:scheme, uri.scheme == "http" or uri.scheme == "https"}, + # If we have instance restrictions, apply them here to prevent fetching from unwanted instances + {:ok, nil} <- Pleroma.Web.ActivityPub.MRF.SimplePolicy.check_reject(uri), + {:ok, _} <- Pleroma.Web.ActivityPub.MRF.SimplePolicy.check_accept(uri), + {_, nil} <- {:fetch_object, Object.get_cached_by_ap_id(id)}, {_, true} <- {:allowed_depth, Federator.allowed_thread_distance?(options[:depth])}, {_, {:ok, data}} <- {:fetch, fetch_and_contain_remote_object_from_id(id)}, {_, nil} <- {:normalize, Object.normalize(data, fetch: false)}, params <- prepare_activity_params(data), - {_, :ok} <- {:containment, Containment.contain_origin(id, params)}, {_, {:ok, activity}} <- {:transmogrifier, Transmogrifier.handle_incoming(params, options)}, {_, _data, %Object{} = object} <- @@ -130,8 +154,8 @@ def fetch_object_from_id(id, options \\ []) do {:allowed_depth, false} -> {:error, "Max thread distance exceeded."} - {:containment, _} -> - {:error, "Object containment failed."} + {:scheme, false} -> + {:error, "URI Scheme Invalid"} {:transmogrifier, {:error, {:reject, e}}} -> {:reject, e} @@ -154,6 +178,9 @@ def fetch_object_from_id(id, options \\ []) do {:fetch, {:error, error}} -> {:error, error} + {:reject, reason} -> + {:reject, reason} + e -> e end @@ -172,6 +199,7 @@ defp prepare_activity_params(data) do |> Maps.put_if_present("bcc", data["bcc"]) end + @doc "Identical to `fetch_object_from_id/2` but just directly returns the object or on error `nil`" def fetch_object_from_id!(id, options \\ []) do with {:ok, object} <- fetch_object_from_id(id, options) do object @@ -179,11 +207,11 @@ def fetch_object_from_id!(id, options \\ []) do {:error, %Tesla.Mock.Error{}} -> nil - {:error, "Object has been deleted"} -> + {:error, {"Object has been deleted", _id, _code}} -> nil {:reject, reason} -> - Logger.info("Rejected #{id} while fetching: #{inspect(reason)}") + Logger.debug("Rejected #{id} while fetching: #{inspect(reason)}") nil e -> @@ -222,6 +250,7 @@ defp maybe_date_fetch(headers, date) do end end + @doc "Fetches arbitrary remote object and performs basic safety and authenticity checks" def fetch_and_contain_remote_object_from_id(id) def fetch_and_contain_remote_object_from_id(%{"id" => id}), @@ -231,14 +260,29 @@ def fetch_and_contain_remote_object_from_id(id) when is_binary(id) do Logger.debug("Fetching object #{id} via AP") with {:scheme, true} <- {:scheme, String.starts_with?(id, "http")}, - {:ok, body} <- get_object(id), + {_, :ok} <- {:local_fetch, Containment.contain_local_fetch(id)}, + {:ok, final_id, body} <- get_object(id), {:ok, data} <- safe_json_decode(body), - :ok <- Containment.contain_origin_from_id(id, data) do + {_, :ok} <- {:strict_id, Containment.contain_id_to_fetch(final_id, data)}, + {_, :ok} <- {:containment, Containment.contain_origin(final_id, data)} do + unless Instances.reachable?(final_id) do + Instances.set_reachable(final_id) + end + {:ok, data} else + {:strict_id, _} -> + {:error, "Object's ActivityPub id/url does not match final fetch URL"} + {:scheme, _} -> {:error, "Unsupported URI scheme"} + {:local_fetch, _} -> + {:error, "Trying to fetch local resource"} + + {:containment, _} -> + {:error, "Object containment failed."} + {:error, e} -> {:error, e} @@ -250,40 +294,80 @@ def fetch_and_contain_remote_object_from_id(id) when is_binary(id) do def fetch_and_contain_remote_object_from_id(_id), do: {:error, "id must be a string"} - defp get_object(id) do + defp check_crossdomain_redirect(final_host, original_url) + + # HOPEFULLY TEMPORARY + # Basically none of our Tesla mocks in tests set the (supposed to + # exist for Tesla proper) url parameter for their responses + # causing almost every fetch in test to fail otherwise + if @mix_env == :test do + defp check_crossdomain_redirect(nil, _) do + {:cross_domain_redirect, false} + end + end + + defp check_crossdomain_redirect(final_host, original_url) do + {:cross_domain_redirect, final_host != URI.parse(original_url).host} + end + + if @mix_env == :test do + defp get_final_id(nil, initial_url), do: initial_url + defp get_final_id("", initial_url), do: initial_url + end + + defp get_final_id(final_url, _intial_url) do + final_url + end + + @doc "Do NOT use; only public for use in tests" + def get_object(id) do date = Pleroma.Signature.signed_date() headers = - [{"accept", "application/activity+json"}] + [ + # The first is required by spec, the second provided as a fallback for buggy implementations + {"accept", "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\""}, + {"accept", "application/activity+json"} + ] |> maybe_date_fetch(date) |> sign_fetch(id, date) - case HTTP.get(id, headers) do - {:ok, %{body: body, status: code, headers: headers}} when code in 200..299 -> - case List.keyfind(headers, "content-type", 0) do - {_, content_type} -> - case Plug.Conn.Utils.media_type(content_type) do - {:ok, "application", "activity+json", _} -> - {:ok, body} + with {:ok, %{body: body, status: code, headers: headers, url: final_url}} + when code in 200..299 <- + HTTP.get(id, headers), + remote_host <- + URI.parse(final_url).host, + {:cross_domain_redirect, false} <- + check_crossdomain_redirect(remote_host, id), + {:has_content_type, {_, content_type}} <- + {:has_content_type, List.keyfind(headers, "content-type", 0)}, + {:parse_content_type, {:ok, "application", subtype, type_params}} <- + {:parse_content_type, Plug.Conn.Utils.media_type(content_type)} do + final_id = get_final_id(final_url, id) - {:ok, "application", "ld+json", - %{"profile" => "https://www.w3.org/ns/activitystreams"}} -> - {:ok, body} + case {subtype, type_params} do + {"activity+json", _} -> + {:ok, final_id, body} - _ -> - {:error, {:content_type, content_type}} - end - - _ -> - {:error, {:content_type, nil}} - end + {"ld+json", %{"profile" => "https://www.w3.org/ns/activitystreams"}} -> + {:ok, final_id, body} + _ -> + {:error, {:content_type, content_type}} + end + else {:ok, %{status: code}} when code in [404, 410] -> - {:error, "Object has been deleted"} + {:error, {"Object has been deleted", id, code}} {:error, e} -> {:error, e} + {:has_content_type, _} -> + {:error, {:content_type, nil}} + + {:parse_content_type, e} -> + {:error, {:content_type, e}} + e -> {:error, e} end diff --git a/lib/pleroma/object/pruner.ex b/lib/pleroma/object/pruner.ex new file mode 100644 index 000000000..991d8b0eb --- /dev/null +++ b/lib/pleroma/object/pruner.ex @@ -0,0 +1,31 @@ +defmodule Pleroma.Object.Pruner do + @moduledoc """ + Prunes objects from the database. + """ + @cutoff 30 + + alias Pleroma.Object + alias Pleroma.Delivery + alias Pleroma.Repo + import Ecto.Query + + def prune_tombstoned_deliveries do + from(d in Delivery) + |> join(:inner, [d], o in Object, on: d.object_id == o.id) + |> where([d, o], fragment("?->>'type' = ?", o.data, "Tombstone")) + |> Repo.delete_all(timeout: :infinity) + end + + def prune_tombstones do + before_time = cutoff() + + from(o in Object, + where: fragment("?->>'type' = ?", o.data, "Tombstone") and o.inserted_at < ^before_time + ) + |> Repo.delete_all(timeout: :infinity, on_delete: :delete_all) + end + + defp cutoff do + DateTime.utc_now() |> Timex.shift(days: -@cutoff) + end +end diff --git a/lib/pleroma/object/updater.ex b/lib/pleroma/object/updater.ex index ab38d3ed2..64faec074 100644 --- a/lib/pleroma/object/updater.ex +++ b/lib/pleroma/object/updater.ex @@ -129,7 +129,10 @@ def make_update_object_data(original_data, new_data, date) do else %{updated_object: updated_data} = updated_data - |> maybe_update_history(original_data, updated: updated, use_history_in_new_object?: false) + |> maybe_update_history(original_data, + updated: updated, + use_history_in_new_object?: false + ) updated_data |> Map.put("updated", date) diff --git a/lib/pleroma/pagination.ex b/lib/pleroma/pagination.ex index 33e45a0eb..28e37933e 100644 --- a/lib/pleroma/pagination.ex +++ b/lib/pleroma/pagination.ex @@ -88,9 +88,9 @@ def paginate(query, options, :offset, table_binding) do defp cast_params(params) do param_types = %{ - min_id: :string, - since_id: :string, - max_id: :string, + min_id: params[:id_type] || :string, + since_id: params[:id_type] || :string, + max_id: params[:id_type] || :string, offset: :integer, limit: :integer, skip_extra_order: :boolean, diff --git a/lib/pleroma/password.ex b/lib/pleroma/password.ex new file mode 100644 index 000000000..92d78552b --- /dev/null +++ b/lib/pleroma/password.ex @@ -0,0 +1,55 @@ +defmodule Pleroma.Password do + @moduledoc """ + This module handles password hashing and verification. + It will delegate to the appropriate module based on the password hash. + It also handles upgrading of password hashes. + """ + + alias Pleroma.User + alias Pleroma.Password.Pbkdf2 + require Logger + + @hashing_module Argon2 + + @spec hash_pwd_salt(String.t()) :: String.t() + defdelegate hash_pwd_salt(pass), to: @hashing_module + + @spec checkpw(String.t(), String.t()) :: boolean() + def checkpw(password, "$2" <> _ = password_hash) do + # Handle bcrypt passwords for Mastodon migration + Bcrypt.verify_pass(password, password_hash) + end + + def checkpw(password, "$pbkdf2" <> _ = password_hash) do + Pbkdf2.verify_pass(password, password_hash) + end + + def checkpw(password, "$argon2" <> _ = password_hash) do + Argon2.verify_pass(password, password_hash) + end + + def checkpw(_password, _password_hash) do + Logger.error("Password hash not recognized") + false + end + + @spec maybe_update_password(User.t(), String.t()) :: + {:ok, User.t()} | {:error, Ecto.Changeset.t()} + def maybe_update_password(%User{password_hash: "$2" <> _} = user, password) do + do_update_password(user, password) + end + + def maybe_update_password(%User{password_hash: "$6" <> _} = user, password) do + do_update_password(user, password) + end + + def maybe_update_password(%User{password_hash: "$pbkdf2" <> _} = user, password) do + do_update_password(user, password) + end + + def maybe_update_password(user, _), do: {:ok, user} + + defp do_update_password(user, password) do + User.reset_password(user, %{password: password, password_confirmation: password}) + end +end diff --git a/lib/pleroma/prometheus_exporter.ex b/lib/pleroma/prometheus_exporter.ex new file mode 100644 index 000000000..05170c8bb --- /dev/null +++ b/lib/pleroma/prometheus_exporter.ex @@ -0,0 +1,49 @@ +defmodule Pleroma.PrometheusExporter do + @moduledoc """ + Exports metrics in Prometheus format. + Mostly exists because of https://github.com/beam-telemetry/telemetry_metrics_prometheus_core/issues/52 + Basically we need to fetch metrics every so often, or the lib will let them pile up and eventually crash the VM. + It also sorta acts as a cache so there is that too. + """ + + use GenServer + require Logger + + def start_link(_opts) do + GenServer.start_link(__MODULE__, :ok, name: __MODULE__) + end + + def init(_opts) do + schedule_next() + {:ok, ""} + end + + defp schedule_next do + Process.send_after(self(), :gather, 60_000) + end + + # Scheduled function, gather metrics and schedule next run + def handle_info(:gather, _state) do + schedule_next() + state = TelemetryMetricsPrometheus.Core.scrape() + {:noreply, state} + end + + # Trigger the call dynamically, mostly for testing + def handle_call(:gather, _from, _state) do + state = TelemetryMetricsPrometheus.Core.scrape() + {:reply, state, state} + end + + def handle_call(:show, _from, state) do + {:reply, state, state} + end + + def show do + GenServer.call(__MODULE__, :show) + end + + def gather do + GenServer.call(__MODULE__, :gather) + end +end diff --git a/lib/pleroma/release_tasks.ex b/lib/pleroma/release_tasks.ex index e43eef070..75c027137 100644 --- a/lib/pleroma/release_tasks.ex +++ b/lib/pleroma/release_tasks.ex @@ -61,9 +61,6 @@ def create do IO.puts("The database for #{inspect(@repo)} has already been created") {: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)}" diff --git a/lib/pleroma/reverse_proxy.ex b/lib/pleroma/reverse_proxy.ex index 91cf1bba3..f017bf51b 100644 --- a/lib/pleroma/reverse_proxy.ex +++ b/lib/pleroma/reverse_proxy.ex @@ -17,6 +17,8 @@ defmodule Pleroma.ReverseProxy do @failed_request_ttl :timer.seconds(60) @methods ~w(GET HEAD) + @allowed_mime_types Pleroma.Config.get([Pleroma.Upload, :allowed_mime_types], []) + @cachex Pleroma.Config.get([:cachex, :provider], Cachex) def max_read_duration_default, do: @max_read_duration @@ -61,11 +63,14 @@ def default_cache_control_header, do: @default_cache_control_header """ @inline_content_types [ + "image/avif", "image/gif", "image/jpeg", "image/jpg", + "image/jxl", "image/png", "image/svg+xml", + "image/webp", "audio/mpeg", "audio/mp3", "video/webm", @@ -250,7 +255,9 @@ defp build_resp_headers(headers, opts) do headers |> Enum.filter(fn {k, _} -> k in @keep_resp_headers end) |> build_resp_cache_headers(opts) + |> sanitise_content_type() |> build_resp_content_disposition_header(opts) + |> build_csp_headers() |> Keyword.merge(Keyword.get(opts, :resp_headers, [])) end @@ -278,6 +285,21 @@ defp build_resp_cache_headers(headers, _opts) do end end + defp sanitise_content_type(headers) do + original_ct = get_content_type(headers) + + safe_ct = + Pleroma.Web.Plugs.Utils.get_safe_mime_type( + %{allowed_mime_types: @allowed_mime_types}, + original_ct + ) + + [ + {"content-type", safe_ct} + | Enum.filter(headers, fn {k, _v} -> k != "content-type" end) + ] + end + defp build_resp_content_disposition_header(headers, opts) do opt = Keyword.get(opts, :inline_content_types, @inline_content_types) @@ -316,6 +338,10 @@ defp build_resp_content_disposition_header(headers, opts) do end end + defp build_csp_headers(headers) do + List.keystore(headers, "content-security-policy", 0, {"content-security-policy", "sandbox"}) + end + defp header_length_constraint(headers, limit) when is_integer(limit) and limit > 0 do with {_, size} <- List.keyfind(headers, "content-length", 0), {size, _} <- Integer.parse(size), diff --git a/lib/pleroma/reverse_proxy/client/hackney.ex b/lib/pleroma/reverse_proxy/client/hackney.ex deleted file mode 100644 index dba946308..000000000 --- a/lib/pleroma/reverse_proxy/client/hackney.ex +++ /dev/null @@ -1,25 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2021 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.ReverseProxy.Client.Hackney do - @behaviour Pleroma.ReverseProxy.Client - - @impl true - def request(method, url, headers, body, opts \\ []) do - opts = Keyword.put(opts, :ssl_options, versions: [:"tlsv1.2", :"tlsv1.1", :tlsv1]) - :hackney.request(method, url, headers, body, opts) - end - - @impl true - def stream_body(ref) do - case :hackney.stream_body(ref) do - :done -> :done - {:ok, data} -> {:ok, data, ref} - {:error, error} -> {:error, error} - end - end - - @impl true - def close(ref), do: :hackney.close(ref) -end diff --git a/lib/pleroma/reverse_proxy/client/tesla.ex b/lib/pleroma/reverse_proxy/client/tesla.ex index 36a0a2060..a4fc1ebc2 100644 --- a/lib/pleroma/reverse_proxy/client/tesla.ex +++ b/lib/pleroma/reverse_proxy/client/tesla.ex @@ -5,8 +5,6 @@ defmodule Pleroma.ReverseProxy.Client.Tesla do @behaviour Pleroma.ReverseProxy.Client - alias Pleroma.Gun.ConnectionPool - @type headers() :: [{String.t(), String.t()}] @type status() :: pos_integer() @@ -33,8 +31,6 @@ def request(method, url, headers, body, opts \\ []) do if is_map(response.body) and method != :head do {:ok, response.status, response.headers, response.body} else - conn_pid = response.opts[:adapter][:conn] - ConnectionPool.release_conn(conn_pid) {:ok, response.status, response.headers} end else @@ -45,8 +41,7 @@ def request(method, url, headers, body, opts \\ []) do @impl true @spec stream_body(map()) :: {:ok, binary(), map()} | {:error, atom() | String.t()} | :done | no_return() - def stream_body(%{pid: pid, fin: true}) do - ConnectionPool.release_conn(pid) + def stream_body(%{pid: _pid, fin: true}) do :done end @@ -70,17 +65,13 @@ defp read_chunk!(%{pid: pid, stream: stream, opts: opts}) do @impl true @spec close(map) :: :ok | no_return() - def close(%{pid: pid}) do - ConnectionPool.release_conn(pid) + def close(%{pid: _pid}) do + :ok end defp check_adapter do adapter = Application.get_env(:tesla, :adapter) - unless adapter == Tesla.Adapter.Gun do - raise "#{adapter} doesn't support reading body in chunks" - end - adapter end end diff --git a/lib/pleroma/reverse_proxy/client/wrapper.ex b/lib/pleroma/reverse_proxy/client/wrapper.ex index ce144559f..b9a05ce11 100644 --- a/lib/pleroma/reverse_proxy/client/wrapper.ex +++ b/lib/pleroma/reverse_proxy/client/wrapper.ex @@ -23,8 +23,6 @@ defp client do |> client() end - defp client(Tesla.Adapter.Hackney), do: Pleroma.ReverseProxy.Client.Hackney - defp client(Tesla.Adapter.Gun), do: Pleroma.ReverseProxy.Client.Tesla - defp client({Tesla.Adapter.Finch, _}), do: Pleroma.ReverseProxy.Client.Hackney + defp client({Tesla.Adapter.Finch, _}), do: Pleroma.ReverseProxy.Client.Tesla defp client(_), do: Pleroma.Config.get!(Pleroma.ReverseProxy.Client) end diff --git a/lib/pleroma/search/elasticsearch.ex b/lib/pleroma/search/elasticsearch.ex index 16b01101a..20e03e1f0 100644 --- a/lib/pleroma/search/elasticsearch.ex +++ b/lib/pleroma/search/elasticsearch.ex @@ -13,25 +13,21 @@ defmodule Pleroma.Search.Elasticsearch do def es_query(:activity, query, offset, limit) do must = Parsers.Activity.parse(query) - if must == [] do - :skip - else - %{ - size: limit, - from: offset, - terminate_after: 50, - timeout: "5s", - sort: [ - "_score", - %{"_timestamp" => %{order: "desc", format: "basic_date_time"}} - ], - query: %{ - bool: %{ - must: must - } + %{ + size: limit, + from: offset, + terminate_after: 50, + timeout: "5s", + sort: [ + "_score", + %{"_timestamp" => %{order: "desc", format: "basic_date_time"}} + ], + query: %{ + bool: %{ + must: must } } - end + } end defp maybe_fetch(:activity, search_query) do diff --git a/lib/pleroma/search/elasticsearch/document_mappings/activity.ex b/lib/pleroma/search/elasticsearch/document_mappings/activity.ex index 3a84e991b..71ef75634 100644 --- a/lib/pleroma/search/elasticsearch/document_mappings/activity.ex +++ b/lib/pleroma/search/elasticsearch/document_mappings/activity.ex @@ -30,7 +30,7 @@ def object_to_search_data(object) do trimmed end - if String.length(content) > 1 do + if String.length(content) > 1 and not is_nil(data["published"]) do {:ok, published, _} = DateTime.from_iso8601(data["published"]) %{ @@ -57,5 +57,5 @@ def encode(activity) do defimpl Elasticsearch.Document, for: Pleroma.Object do def id(obj), do: obj.id def routing(_), do: false - def encode(_), do: nil + def encode(_), do: %{} end diff --git a/lib/pleroma/search/meilisearch.ex b/lib/pleroma/search/meilisearch.ex index 770557858..8fcf9310a 100644 --- a/lib/pleroma/search/meilisearch.ex +++ b/lib/pleroma/search/meilisearch.ex @@ -128,7 +128,7 @@ def object_to_search_data(object) do trimmed end - if String.length(content) > 1 do + if String.length(content) > 1 and not is_nil(data["published"]) do {:ok, published, _} = DateTime.from_iso8601(data["published"]) %{ @@ -154,10 +154,11 @@ def add_to_index(activity) do with {:ok, res} <- result, true <- Map.has_key?(res, "taskUid") do - # Do nothing + {:ok, res} else - _ -> + err -> Logger.error("Failed to add activity #{activity.id} to index: #{inspect(result)}") + {:error, err} end end end diff --git a/lib/pleroma/search/search_backend.ex b/lib/pleroma/search/search_backend.ex index ed6bfd329..56e3b7de5 100644 --- a/lib/pleroma/search/search_backend.ex +++ b/lib/pleroma/search/search_backend.ex @@ -4,7 +4,7 @@ defmodule Pleroma.Search.SearchBackend do The whole activity is passed, to allow filtering on things such as scope. """ - @callback add_to_index(activity :: Pleroma.Activity.t()) :: nil + @callback add_to_index(activity :: Pleroma.Activity.t()) :: {:ok, any()} | {:error, any()} @doc """ Remove the object from the index. @@ -13,5 +13,5 @@ defmodule Pleroma.Search.SearchBackend do is what contains the actual content and there is no need for fitlering when removing from index. """ - @callback remove_from_index(object :: Pleroma.Object.t()) :: nil + @callback remove_from_index(object :: Pleroma.Object.t()) :: {:ok, any()} | {:error, any()} end diff --git a/lib/pleroma/signature.ex b/lib/pleroma/signature.ex index 043a0643e..3d33fcd62 100644 --- a/lib/pleroma/signature.ex +++ b/lib/pleroma/signature.ex @@ -17,6 +17,7 @@ def key_id_to_actor_id(key_id) do key_id |> URI.parse() |> Map.put(:fragment, nil) + |> Map.put(:query, nil) |> remove_suffix(@known_suffixes) maybe_ap_id = URI.to_string(uri) @@ -27,7 +28,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 @@ -75,6 +76,6 @@ def sign(%User{keys: keys} = user, headers) do def signed_date, do: signed_date(NaiveDateTime.utc_now()) def signed_date(%NaiveDateTime{} = date) do - Timex.format!(date, "{WDshort}, {0D} {Mshort} {YYYY} {h24}:{m}:{s} GMT") + Timex.lformat!(date, "{WDshort}, {0D} {Mshort} {YYYY} {h24}:{m}:{s} GMT", "en") end end diff --git a/lib/pleroma/stats.ex b/lib/pleroma/stats.ex index 3e3f24c2c..c47a0f9de 100644 --- a/lib/pleroma/stats.ex +++ b/lib/pleroma/stats.ex @@ -11,7 +11,7 @@ defmodule Pleroma.Stats do alias Pleroma.Repo alias Pleroma.User - @interval :timer.seconds(60) + @interval :timer.seconds(300) def start_link(_) do GenServer.start_link( @@ -85,14 +85,24 @@ def calculate_stat_data do where: not u.invisible ) + remote_users_query = + from(u in User, + where: u.is_active == true, + where: u.local == false, + where: not is_nil(u.nickname), + where: not u.invisible + ) + user_count = Repo.aggregate(users_query, :count, :id) + remote_user_count = Repo.aggregate(remote_users_query, :count, :id) %{ peers: peers, stats: %{ domain_count: domain_count, status_count: status_count || 0, - user_count: user_count + user_count: user_count, + remote_user_count: remote_user_count } } end diff --git a/lib/pleroma/tesla/middleware/connection_pool.ex b/lib/pleroma/tesla/middleware/connection_pool.ex deleted file mode 100644 index 906706d39..000000000 --- a/lib/pleroma/tesla/middleware/connection_pool.ex +++ /dev/null @@ -1,50 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2021 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Tesla.Middleware.ConnectionPool do - @moduledoc """ - Middleware to get/release connections from `Pleroma.Gun.ConnectionPool` - """ - - @behaviour Tesla.Middleware - - alias Pleroma.Gun.ConnectionPool - - @impl Tesla.Middleware - def call(%Tesla.Env{url: url, opts: opts} = env, next, _) do - uri = URI.parse(url) - - # Avoid leaking connections when the middleware is called twice - # with body_as: :chunks. We assume only the middleware can set - # opts[:adapter][:conn] - if opts[:adapter][:conn] do - ConnectionPool.release_conn(opts[:adapter][:conn]) - end - - case ConnectionPool.get_conn(uri, opts[:adapter]) do - {:ok, conn_pid} -> - adapter_opts = Keyword.merge(opts[:adapter], conn: conn_pid, close_conn: false) - opts = Keyword.put(opts, :adapter, adapter_opts) - env = %{env | opts: opts} - - case Tesla.run(env, next) do - {:ok, env} -> - unless opts[:adapter][:body_as] == :chunks do - ConnectionPool.release_conn(conn_pid) - {_, res} = pop_in(env.opts[:adapter][:conn]) - {:ok, res} - else - {:ok, env} - end - - err -> - ConnectionPool.release_conn(conn_pid) - err - end - - err -> - err - end - end -end diff --git a/lib/pleroma/upload.ex b/lib/pleroma/upload.ex index 7ea7f0e2c..ad5c2c655 100644 --- a/lib/pleroma/upload.ex +++ b/lib/pleroma/upload.ex @@ -64,6 +64,9 @@ defmodule Pleroma.Upload do description: String.t(), path: String.t() } + + @always_enabled_filters [Pleroma.Upload.Filter.Dedupe] + defstruct [ :id, :name, @@ -76,15 +79,6 @@ defmodule Pleroma.Upload do :path ] - defp get_description(upload) do - case {upload.description, Pleroma.Config.get([Pleroma.Upload, :default_description])} do - {description, _} when is_binary(description) -> description - {_, :filename} -> upload.name - {_, str} when is_binary(str) -> str - _ -> "" - end - end - @spec store(source, options :: [option()]) :: {:ok, Map.t()} | {: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 @@ -93,7 +87,7 @@ def store(upload, opts \\ []) do with {:ok, upload} <- prepare_upload(upload, opts), upload = %__MODULE__{upload | path: upload.path || "#{upload.id}/#{upload.name}"}, {:ok, upload} <- Pleroma.Upload.Filter.filter(opts.filters, upload), - description = get_description(upload), + description = Map.get(upload, :description) || "", {_, true} <- {:description_limit, String.length(description) <= Pleroma.Config.get([:instance, :description_limit])}, @@ -152,7 +146,11 @@ defp get_opts(opts) do activity_type: Keyword.get(opts, :activity_type, activity_type), size_limit: Keyword.get(opts, :size_limit, size_limit), uploader: Keyword.get(opts, :uploader, Pleroma.Config.get([__MODULE__, :uploader])), - filters: Keyword.get(opts, :filters, Pleroma.Config.get([__MODULE__, :filters])), + filters: + Enum.uniq( + Keyword.get(opts, :filters, Pleroma.Config.get([__MODULE__, :filters])) ++ + @always_enabled_filters + ), description: Keyword.get(opts, :description), base_url: base_url() } @@ -174,7 +172,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: :lower) with :ok <- check_binary_size(data, opts.size_limit), tmp_path <- tempfile_for_image(data), @@ -250,7 +248,7 @@ def base_url do case uploader do Pleroma.Uploaders.Local -> - upload_base_url || Pleroma.Web.Endpoint.url() <> "/media/" + upload_base_url Pleroma.Uploaders.S3 -> bucket = Config.get([Pleroma.Uploaders.S3, :bucket]) @@ -276,7 +274,7 @@ def base_url do end _ -> - public_endpoint || upload_base_url || Pleroma.Web.Endpoint.url() <> "/media/" + public_endpoint || upload_base_url end end end diff --git a/lib/pleroma/upload/filter.ex b/lib/pleroma/upload/filter.ex index e5db2fb20..50e0f2c27 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 c89c30fc1..e2dbacf6a 100644 --- a/lib/pleroma/upload/filter/analyze_metadata.ex +++ b/lib/pleroma/upload/filter/analyze_metadata.ex @@ -28,7 +28,7 @@ def filter(%Pleroma.Upload{tempfile: file, content_type: "image" <> _} = upload) {:ok, :filtered, upload} rescue e in ErlangError -> - Logger.warn("#{__MODULE__}: #{inspect(e)}") + Logger.warning("#{__MODULE__}: #{inspect(e)}") {:ok, :noop} end end @@ -45,7 +45,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 @@ -77,7 +77,6 @@ defp media_dimensions(file) do %{width: width, height: height} else nil -> {:error, {:ffprobe, :command_not_found}} - {:error, _} = error -> error end end end diff --git a/lib/pleroma/upload/filter/exiftool/strip_location.ex b/lib/pleroma/upload/filter/exiftool/strip_location.ex index 0a9ec4fb2..9bb39f68c 100644 --- a/lib/pleroma/upload/filter/exiftool/strip_location.ex +++ b/lib/pleroma/upload/filter/exiftool/strip_location.ex @@ -9,11 +9,13 @@ defmodule Pleroma.Upload.Filter.Exiftool.StripLocation do """ @behaviour Pleroma.Upload.Filter - @spec filter(Pleroma.Upload.t()) :: {:ok, any()} | {:error, String.t()} + @spec filter(Pleroma.Upload.t()) :: {:ok, :noop} | {:ok, :filtered} | {:error, String.t()} # Formats not compatible with exiftool at this time def filter(%Pleroma.Upload{content_type: "image/heic"}), do: {:ok, :noop} def filter(%Pleroma.Upload{content_type: "image/webp"}), do: {:ok, :noop} + def filter(%Pleroma.Upload{content_type: "image/svg+xml"}), do: {:ok, :noop} + def filter(%Pleroma.Upload{content_type: "image/jxl"}), do: {:ok, :noop} def filter(%Pleroma.Upload{tempfile: file, content_type: "image" <> _}) do try do diff --git a/lib/pleroma/upload/filter/mogrifun.ex b/lib/pleroma/upload/filter/mogrifun.ex index 01126aaeb..69885a0bd 100644 --- a/lib/pleroma/upload/filter/mogrifun.ex +++ b/lib/pleroma/upload/filter/mogrifun.ex @@ -38,7 +38,7 @@ defmodule Pleroma.Upload.Filter.Mogrifun do [{"fill", "yellow"}, {"tint", "40"}] ] - @spec filter(Pleroma.Upload.t()) :: {:ok, atom()} | {:error, String.t()} + @spec filter(Pleroma.Upload.t()) :: {:ok, :filtered | :noop} | {:error, String.t()} def filter(%Pleroma.Upload{tempfile: file, content_type: "image" <> _}) do try do Filter.Mogrify.do_filter(file, [Enum.random(@filters)]) 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/user.ex b/lib/pleroma/user.ex index 700cab2b5..14414adc4 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -3,6 +3,10 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.User do + @moduledoc """ + A user, local or remote + """ + use Ecto.Schema import Ecto.Changeset @@ -18,6 +22,8 @@ defmodule Pleroma.User do alias Pleroma.Emoji alias Pleroma.FollowingRelationship alias Pleroma.Formatter + alias Pleroma.Hashtag + alias Pleroma.User.HashtagFollow alias Pleroma.HTML alias Pleroma.Keys alias Pleroma.MFA @@ -38,6 +44,8 @@ defmodule Pleroma.User do alias Pleroma.Web.RelMe alias Pleroma.Workers.BackgroundWorker + use Pleroma.Web, :verified_routes + require Logger @type t :: %__MODULE__{} @@ -151,6 +159,13 @@ defmodule Pleroma.User do field(:is_suggested, :boolean, default: false) field(:last_status_at, :naive_datetime) field(:language, :string) + field(:status_ttl_days, :integer, default: nil) + field(:permit_followback, :boolean, default: false) + + field(:accepts_direct_messages_from, Ecto.Enum, + values: [:everybody, :people_i_follow, :nobody], + default: :everybody + ) embeds_one( :notification_settings, @@ -167,6 +182,12 @@ defmodule Pleroma.User do has_many(:frontend_profiles, Pleroma.Akkoma.FrontendSettingsProfile) + many_to_many(:followed_hashtags, Hashtag, + on_replace: :delete, + on_delete: :delete_all, + join_through: HashtagFollow + ) + for {relationship_type, [ {outgoing_relation, outgoing_relation_target}, @@ -260,7 +281,13 @@ def cached_muted_users_ap_ids(user) do defdelegate following(user), to: FollowingRelationship defdelegate following?(follower, followed), to: FollowingRelationship defdelegate following_ap_ids(user), to: FollowingRelationship - defdelegate get_follow_requests(user), to: FollowingRelationship + defdelegate get_follow_requests_query(user), to: FollowingRelationship + + def get_follow_requests(user) do + get_follow_requests_query(user) + |> Repo.all() + end + defdelegate search(query, opts \\ []), to: User.Search @doc """ @@ -347,21 +374,25 @@ def invisible?(%User{invisible: true}), do: true def invisible?(_), do: false def avatar_url(user, options \\ []) do - case user.avatar do - %{"url" => [%{"href" => href} | _]} -> - href - - _ -> - unless options[:no_default] do - Config.get([:assets, :default_user_avatar], "#{Endpoint.url()}/images/avi.png") - end - end + default = Config.get([:assets, :default_user_avatar], "#{Endpoint.url()}/images/avi.png") + do_optional_url(user.avatar, default, options) end def banner_url(user, options \\ []) do - case user.banner do - %{"url" => [%{"href" => href} | _]} -> href - _ -> !options[:no_default] && "#{Endpoint.url()}/images/banner.png" + do_optional_url(user.banner, "#{Endpoint.url()}/images/banner.png", options) + end + + def background_url(user) do + do_optional_url(user.background, nil, no_default: true) + end + + defp do_optional_url(field, default, options) do + case field do + %{"url" => [%{"href" => href} | _]} when is_binary(href) -> + href + + _ -> + unless options[:no_default], do: default end end @@ -439,6 +470,7 @@ def remote_user_changeset(struct \\ %User{local: false}, params) do :avatar, :ap_enabled, :banner, + :background, :is_locked, :last_refreshed_at, :uri, @@ -466,7 +498,7 @@ def remote_user_changeset(struct \\ %User{local: false}, params) do |> validate_format(:nickname, @email_regex) |> validate_length(:bio, max: bio_limit) |> validate_length(:name, max: name_limit) - |> validate_fields(true) + |> validate_fields(true, struct) |> validate_non_local() end @@ -516,7 +548,10 @@ def update_changeset(struct, params \\ %{}) do :pleroma_settings_store, :is_discoverable, :actor_type, - :disclose_client + :disclose_client, + :status_ttl_days, + :permit_followback, + :accepts_direct_messages_from ] ) |> unique_constraint(:nickname) @@ -524,6 +559,7 @@ def update_changeset(struct, params \\ %{}) do |> validate_length(:bio, max: bio_limit) |> validate_length(:name, min: 1, max: name_limit) |> validate_inclusion(:actor_type, ["Person", "Service"]) + |> validate_number(:status_ttl_days, greater_than: 0) |> put_fields() |> put_emoji() |> put_change_if_present(:bio, &{:ok, parse_bio(&1, struct)}) @@ -534,13 +570,21 @@ def update_changeset(struct, params \\ %{}) do :pleroma_settings_store, &{:ok, Map.merge(struct.pleroma_settings_store, &1)} ) - |> validate_fields(false) + |> validate_fields(false, struct) end defp put_fields(changeset) do + # These fields are inconsistent in tests when it comes to binary/atom keys if raw_fields = get_change(changeset, :raw_fields) do raw_fields = raw_fields + |> Enum.map(fn + %{name: name, value: value} -> + %{"name" => name, "value" => value} + + %{"name" => _} = field -> + field + end) |> Enum.filter(fn %{"name" => n} -> n != "" end) fields = @@ -588,7 +632,13 @@ defp put_change_if_present(changeset, map_field, value_function) do {:ok, new_value} <- value_function.(value) do put_change(changeset, map_field, new_value) else - _ -> changeset + {:error, :file_too_large} -> + Ecto.Changeset.validate_change(changeset, map_field, fn map_field, _value -> + [{map_field, "file is too large"}] + end) + + _ -> + changeset end end @@ -688,7 +738,8 @@ def register_changeset_ldap(struct, params = %{password: password}) |> put_private_key() end - def register_changeset(struct, params \\ %{}, opts \\ []) do + @spec register_changeset(User.t(), map(), keyword()) :: Changeset.t() + def register_changeset(%User{} = struct, params \\ %{}, opts \\ []) do bio_limit = Config.get([:instance, :user_bio_length], 5000) name_limit = Config.get([:instance, :user_name_length], 100) reason_limit = Config.get([:instance, :registration_reason_length], 500) @@ -802,12 +853,14 @@ defp autofollowing_users(user) do end @doc "Inserts provided changeset, performs post-registration actions (confirmation email sending etc.)" + @spec register(Changeset.t()) :: {:ok, User.t()} | {:error, any} | nil def register(%Ecto.Changeset{} = changeset) do with {:ok, user} <- Repo.insert(changeset) do post_register_action(user) end end + @spec post_register_action(User.t()) :: {:error, any} | {:ok, User.t()} def post_register_action(%User{is_confirmed: false} = user) do with {:ok, _} <- maybe_send_confirmation_email(user) do {:ok, user} @@ -832,7 +885,7 @@ def post_register_action(%User{is_approved: true, is_confirmed: true} = user) do end end - defp send_user_approval_email(user) do + defp send_user_approval_email(%User{email: email} = user) when is_binary(email) do user |> Pleroma.Emails.UserEmail.approval_pending_email() |> Pleroma.Emails.Mailer.deliver_async() @@ -840,6 +893,10 @@ defp send_user_approval_email(user) do {:ok, :enqueued} end + defp send_user_approval_email(_user) do + {:ok, :skipped} + end + defp send_admin_approval_emails(user) do all_superusers() |> Enum.filter(fn user -> not is_nil(user.email) end) @@ -912,25 +969,32 @@ defp maybe_send_registration_email(%User{email: email} = user) when is_binary(em defp maybe_send_registration_email(_), do: {:ok, :noop} - def needs_update?(%User{local: true}), do: false + def needs_update?(user, options \\ []) + def needs_update?(%User{local: true}, _options), do: false + def needs_update?(%User{local: false, last_refreshed_at: nil}, _options), do: true - def needs_update?(%User{local: false, last_refreshed_at: nil}), do: true - - def needs_update?(%User{local: false} = user) do - NaiveDateTime.diff(NaiveDateTime.utc_now(), user.last_refreshed_at) >= 86_400 + def needs_update?(%User{local: false} = user, options) do + NaiveDateTime.diff(NaiveDateTime.utc_now(), user.last_refreshed_at) >= + Keyword.get(options, :maximum_age, 86_400) end - def needs_update?(_), do: true - - @spec maybe_direct_follow(User.t(), User.t()) :: {:ok, User.t()} | {:error, String.t()} + def needs_update?(_, _options), do: true # "Locked" (self-locked) users demand explicit authorization of follow requests - def maybe_direct_follow(%User{} = follower, %User{local: true, is_locked: true} = followed) do - follow(follower, followed, :follow_pending) + @spec can_direct_follow_local(User.t(), User.t()) :: true | false + def can_direct_follow_local(%User{} = follower, %User{local: true} = followed) do + !followed.is_locked || (followed.permit_followback and is_friend_of(follower, followed)) end + @spec maybe_direct_follow(User.t(), User.t()) :: + {:ok, User.t(), User.t()} | {:error, String.t()} + def maybe_direct_follow(%User{} = follower, %User{local: true} = followed) do - follow(follower, followed) + if can_direct_follow_local(follower, followed) do + follow(follower, followed) + else + follow(follower, followed, :follow_pending) + end end def maybe_direct_follow(%User{} = follower, %User{} = followed) do @@ -1055,6 +1119,11 @@ def get_by_guessed_nickname(ap_id) do get_cached_by_nickname(nickname) end + @spec set_cache( + {:error, any} + | {:ok, User.t()} + | User.t() + ) :: {:ok, User.t()} | {:error, any} def set_cache({:ok, user}), do: set_cache(user) def set_cache({:error, err}), do: {:error, err} @@ -1065,12 +1134,14 @@ def set_cache(%User{} = user) do {:ok, user} end + @spec update_and_set_cache(User.t(), map()) :: {:ok, User.t()} | {:error, any} def update_and_set_cache(struct, params) do struct |> update_changeset(params) |> update_and_set_cache() end + @spec update_and_set_cache(Changeset.t()) :: {:ok, User.t()} | {:error, any} def update_and_set_cache(%{data: %Pleroma.User{} = user} = changeset) do was_superuser_before_update = User.superuser?(user) @@ -1125,6 +1196,7 @@ def get_cached_by_ap_id(ap_id) do end end + @spec get_cached_by_id(String.t()) :: nil | Pleroma.User.t() def get_cached_by_id(id) do key = "id:#{id}" @@ -1272,6 +1344,13 @@ def get_friends_ids(%User{} = user, page \\ nil) do |> Repo.all() end + def is_friend_of(%User{} = potential_friend, %User{local: true} = user) do + user + |> get_friends_query() + |> where(id: ^potential_friend.id) + |> Repo.exists?() + end + def increase_note_count(%User{} = user) do User |> where(id: ^user.id) @@ -1436,7 +1515,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}" ) @@ -1546,9 +1625,13 @@ def blocks_user?(%User{} = user, %User{} = target) do def blocks_user?(_, _), do: false def blocks_domain?(%User{} = user, %User{} = target) do - domain_blocks = Pleroma.Web.ActivityPub.MRF.subdomains_regex(user.domain_blocks) %{host: host} = URI.parse(target.ap_id) - Pleroma.Web.ActivityPub.MRF.subdomain_match?(domain_blocks, host) + Enum.member?(user.domain_blocks, host) + # TODO: functionality should probably be changed such that subdomains block as well, + # but as it stands, this just hecks up the relationships endpoint + # domain_blocks = Pleroma.Web.ActivityPub.MRF.subdomains_regex(user.domain_blocks) + # %{host: host} = URI.parse(target.ap_id) + # Pleroma.Web.ActivityPub.MRF.subdomain_match?(domain_blocks, host) end def blocks_domain?(_, _), do: false @@ -1898,10 +1981,10 @@ def html_filter_policy(_), do: Config.get([:markup, :scrub_policy]) def fetch_by_ap_id(ap_id), do: ActivityPub.make_user_from_ap_id(ap_id) - def get_or_fetch_by_ap_id(ap_id) do + def get_or_fetch_by_ap_id(ap_id, options \\ []) do cached_user = get_cached_by_ap_id(ap_id) - maybe_fetched_user = needs_update?(cached_user) && fetch_by_ap_id(ap_id) + maybe_fetched_user = needs_update?(cached_user, options) && fetch_by_ap_id(ap_id) case {cached_user, maybe_fetched_user} do {_, {:ok, %User{} = user}} -> @@ -1910,7 +1993,8 @@ def get_or_fetch_by_ap_id(ap_id) do {%User{} = user, _} -> {:ok, user} - _ -> + e -> + Logger.error("Could not fetch user #{ap_id}, #{inspect(e)}") {:error, :not_found} end end @@ -2028,10 +2112,14 @@ def parse_bio(bio, user) when is_binary(bio) and bio != "" do # TODO: get profile URLs other than user.ap_id profile_urls = [user.ap_id] - bio - |> CommonUtils.format_input("text/plain", + CommonUtils.format_input(bio, "text/plain", mentions_format: :full, - rel: &RelMe.maybe_put_rel_me(&1, profile_urls) + rel: fn link -> + case RelMe.maybe_put_rel_me(link, profile_urls) do + "me" -> "me" + _ -> nil + end + end ) |> elem(0) end @@ -2235,7 +2323,7 @@ def get_ap_ids_by_nicknames(nicknames) do defp put_password_hash( %Ecto.Changeset{valid?: true, changes: %{password: password}} = changeset ) do - change(changeset, password_hash: Pleroma.Password.Pbkdf2.hash_pwd_salt(password)) + change(changeset, password_hash: Pleroma.Password.hash_pwd_salt(password)) end defp put_password_hash(changeset), do: changeset @@ -2284,6 +2372,7 @@ def add_alias(user, new_alias_user) do end end + @spec delete_alias(User.t(), User.t()) :: {:error, :no_such_alias} def delete_alias(user, alias_user) do current_aliases = user.also_known_as || [] alias_ap_id = alias_user.ap_id @@ -2316,7 +2405,8 @@ def update_background(user, background) do |> update_and_set_cache() end - def validate_fields(changeset, remote? \\ false) do + @spec validate_fields(Ecto.Changeset.t(), Boolean.t(), User.t()) :: Ecto.Changeset.t() + def validate_fields(changeset, remote? \\ false, struct) do limit_name = if remote?, do: :max_remote_account_fields, else: :max_account_fields limit = Config.get([:instance, limit_name], 0) @@ -2329,6 +2419,7 @@ def validate_fields(changeset, remote? \\ false) do [fields: "invalid"] end end) + |> maybe_validate_rel_me_field(struct) end defp valid_field?(%{"name" => name, "value" => value}) do @@ -2341,6 +2432,70 @@ defp valid_field?(%{"name" => name, "value" => value}) do defp valid_field?(_), do: false + defp is_url(nil), do: nil + + defp is_url(uri) do + case URI.parse(uri) do + %URI{host: nil} -> false + %URI{scheme: nil} -> false + _ -> true + end + end + + @spec maybe_validate_rel_me_field(Changeset.t(), User.t()) :: Changeset.t() + defp maybe_validate_rel_me_field(changeset, %User{ap_id: _ap_id} = struct) do + fields = get_change(changeset, :fields) + raw_fields = get_change(changeset, :raw_fields) + + if is_nil(fields) do + changeset + else + validate_rel_me_field(changeset, fields, raw_fields, struct) + end + end + + defp maybe_validate_rel_me_field(changeset, _), do: changeset + + @spec validate_rel_me_field(Changeset.t(), [Map.t()], [Map.t()], User.t()) :: Changeset.t() + defp validate_rel_me_field(changeset, fields, raw_fields, %User{ + nickname: nickname, + ap_id: ap_id + }) do + fields = + fields + |> Enum.with_index() + |> Enum.map(fn {%{"name" => name, "value" => value}, index} -> + raw_value = + if is_nil(raw_fields) do + nil + else + Enum.at(raw_fields, index)["value"] + end + + if is_url(raw_value) do + frontend_url = url(~p[/#{nickname}]) + + possible_urls = [ap_id, frontend_url] + + with "me" <- RelMe.maybe_put_rel_me(raw_value, possible_urls) do + %{ + "name" => name, + "value" => value, + "verified_at" => DateTime.to_iso8601(DateTime.utc_now()) + } + else + e -> + Logger.error("Could not check for rel=me, #{inspect(e)}") + %{"name" => name, "value" => value} + end + else + %{"name" => name, "value" => value} + end + end) + + put_change(changeset, :fields, fields) + end + defp truncate_field(%{"name" => name, "value" => value}) do {name, _chopped} = String.split_at(name, Config.get([:instance, :account_field_name_length], 255)) @@ -2399,7 +2554,7 @@ def confirmation_changeset(user, set_confirmation: confirmed?) do cast(user, params, [:is_confirmed, :confirmation_token]) end - @spec approval_changeset(User.t(), keyword()) :: Changeset.t() + @spec approval_changeset(Changeset.t(), keyword()) :: Changeset.t() def approval_changeset(user, set_approval: approved?) do cast(user, %{is_approved: approved?}, [:is_approved]) end @@ -2474,15 +2629,19 @@ defp add_to_block(%User{} = user, %User{} = blocked) do with {:ok, relationship} <- UserRelationship.create_block(user, blocked) do @cachex.del(:user_cache, "blocked_users_ap_ids:#{user.ap_id}") {:ok, relationship} + else + err -> err end end - @spec add_to_block(User.t(), User.t()) :: + @spec remove_from_block(User.t(), User.t()) :: {:ok, UserRelationship.t()} | {:ok, nil} | {:error, Ecto.Changeset.t()} defp remove_from_block(%User{} = user, %User{} = blocked) do with {:ok, relationship} <- UserRelationship.delete_block(user, blocked) do @cachex.del(:user_cache, "blocked_users_ap_ids:#{user.ap_id}") {:ok, relationship} + else + err -> err end end @@ -2504,11 +2663,8 @@ def sanitize_html(%User{} = user) do # - display name def sanitize_html(%User{} = user, filter) do fields = - Enum.map(user.fields, fn %{"name" => name, "value" => value} -> - %{ - "name" => name, - "value" => HTML.filter_tags(value, Pleroma.HTML.Scrubber.LinksOnly) - } + Enum.map(user.fields, fn %{"value" => value} = field -> + Map.put(field, "value", HTML.filter_tags(value, Pleroma.HTML.Scrubber.LinksOnly)) end) user @@ -2546,4 +2702,66 @@ def update_last_status_at(user) do _ -> {:error, user} end end + + defp maybe_load_followed_hashtags(%User{followed_hashtags: follows} = user) + when is_list(follows), + do: user + + defp maybe_load_followed_hashtags(%User{} = user) do + followed_hashtags = HashtagFollow.get_by_user(user) + %{user | followed_hashtags: followed_hashtags} + end + + def followed_hashtags(%User{followed_hashtags: follows}) + when is_list(follows), + do: follows + + def followed_hashtags(%User{} = user) do + {:ok, user} = + user + |> maybe_load_followed_hashtags() + |> set_cache() + + user.followed_hashtags + end + + def follow_hashtag(%User{} = user, %Hashtag{} = hashtag) do + Logger.debug("Follow hashtag #{hashtag.name} for user #{user.nickname}") + user = maybe_load_followed_hashtags(user) + + with {:ok, _} <- HashtagFollow.new(user, hashtag), + follows <- HashtagFollow.get_by_user(user), + %User{} = user <- user |> Map.put(:followed_hashtags, follows) do + user + |> set_cache() + end + end + + def unfollow_hashtag(%User{} = user, %Hashtag{} = hashtag) do + Logger.debug("Unfollow hashtag #{hashtag.name} for user #{user.nickname}") + user = maybe_load_followed_hashtags(user) + + with {:ok, _} <- HashtagFollow.delete(user, hashtag), + follows <- HashtagFollow.get_by_user(user), + %User{} = user <- user |> Map.put(:followed_hashtags, follows) do + user + |> set_cache() + end + end + + def following_hashtag?(%User{} = user, %Hashtag{} = hashtag) do + not is_nil(HashtagFollow.get(user, hashtag)) + end + + def accepts_direct_messages?( + %User{accepts_direct_messages_from: :people_i_follow} = receiver, + %User{} = sender + ) do + User.following?(receiver, sender) + end + + def accepts_direct_messages?(%User{accepts_direct_messages_from: :everybody}, _), do: true + + def accepts_direct_messages?(%User{accepts_direct_messages_from: :nobody}, _), + do: false end diff --git a/lib/pleroma/user/backup.ex b/lib/pleroma/user/backup.ex index 2c6378265..de967abe3 100644 --- a/lib/pleroma/user/backup.ex +++ b/lib/pleroma/user/backup.ex @@ -119,7 +119,7 @@ def process(%__MODULE__{} = backup) do end end - @files ['actor.json', 'outbox.json', 'likes.json', 'bookmarks.json'] + @files [~c"actor.json", ~c"outbox.json", ~c"likes.json", ~c"bookmarks.json"] def export(%__MODULE__{} = backup) do backup = Repo.preload(backup, :user) name = String.trim_trailing(backup.file_name, ".zip") @@ -130,7 +130,8 @@ def export(%__MODULE__{} = backup) do :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, zip_path} <- + :zip.create(String.to_charlist(dir <> ".zip"), @files, cwd: String.to_charlist(dir)), {:ok, _} <- File.rm_rf(dir) do {:ok, to_string(zip_path)} end diff --git a/lib/pleroma/user/hashtag_follow.ex b/lib/pleroma/user/hashtag_follow.ex new file mode 100644 index 000000000..dd0254ef4 --- /dev/null +++ b/lib/pleroma/user/hashtag_follow.ex @@ -0,0 +1,55 @@ +defmodule Pleroma.User.HashtagFollow do + use Ecto.Schema + import Ecto.Query + import Ecto.Changeset + + alias Pleroma.User + alias Pleroma.Hashtag + alias Pleroma.Repo + + schema "user_follows_hashtag" do + belongs_to(:user, User, type: FlakeId.Ecto.CompatType) + belongs_to(:hashtag, Hashtag) + end + + def changeset(%__MODULE__{} = user_hashtag_follow, attrs) do + user_hashtag_follow + |> cast(attrs, [:user_id, :hashtag_id]) + |> unique_constraint(:hashtag_id, + name: :user_hashtag_follows_user_id_hashtag_id_index, + message: "already following" + ) + |> validate_required([:user_id, :hashtag_id]) + end + + def new(%User{} = user, %Hashtag{} = hashtag) do + %__MODULE__{} + |> changeset(%{user_id: user.id, hashtag_id: hashtag.id}) + |> Repo.insert(on_conflict: :nothing) + end + + def delete(%User{} = user, %Hashtag{} = hashtag) do + with %__MODULE__{} = user_hashtag_follow <- get(user, hashtag) do + Repo.delete(user_hashtag_follow) + else + _ -> {:ok, nil} + end + end + + def get(%User{} = user, %Hashtag{} = hashtag) do + from(hf in __MODULE__) + |> where([hf], hf.user_id == ^user.id and hf.hashtag_id == ^hashtag.id) + |> Repo.one() + end + + def get_by_user(%User{} = user) do + user + |> followed_hashtags_query() + |> Repo.all() + end + + def followed_hashtags_query(%User{} = user) do + Ecto.assoc(user, :followed_hashtags) + |> Ecto.Query.order_by([h], desc: h.id) + end +end diff --git a/lib/pleroma/user/import.ex b/lib/pleroma/user/import.ex index 60cd18041..95c4ef34e 100644 --- a/lib/pleroma/user/import.ex +++ b/lib/pleroma/user/import.ex @@ -12,47 +12,32 @@ defmodule Pleroma.User.Import do require Logger @spec perform(atom(), User.t(), list()) :: :ok | list() | {:error, any()} - def perform(:mutes_import, %User{} = user, [_ | _] = identifiers) do - Enum.map( - identifiers, - fn identifier -> - with {:ok, %User{} = muted_user} <- User.get_or_fetch(identifier), - {:ok, _} <- User.mute(user, muted_user) do - muted_user - else - error -> handle_error(:mutes_import, identifier, error) - end - end - ) + def perform(:mutes_import, %User{} = user, identifier) do + with {:ok, %User{} = muted_user} <- User.get_or_fetch(identifier), + {:ok, _} <- User.mute(user, muted_user) do + muted_user + else + error -> handle_error(:mutes_import, identifier, error) + end end - def perform(:blocks_import, %User{} = blocker, [_ | _] = identifiers) do - Enum.map( - identifiers, - fn identifier -> - with {:ok, %User{} = blocked} <- User.get_or_fetch(identifier), - {:ok, _block} <- CommonAPI.block(blocker, blocked) do - blocked - else - error -> handle_error(:blocks_import, identifier, error) - end - end - ) + def perform(:blocks_import, %User{} = blocker, identifier) do + with {:ok, %User{} = blocked} <- User.get_or_fetch(identifier), + {:ok, _block} <- CommonAPI.block(blocker, blocked) do + blocked + else + error -> handle_error(:blocks_import, identifier, error) + end end - def perform(:follow_import, %User{} = follower, [_ | _] = identifiers) do - Enum.map( - identifiers, - fn identifier -> - with {:ok, %User{} = followed} <- User.get_or_fetch(identifier), - {:ok, follower, followed} <- User.maybe_direct_follow(follower, followed), - {:ok, _, _, _} <- CommonAPI.follow(follower, followed) do - followed - else - error -> handle_error(:follow_import, identifier, error) - end - end - ) + def perform(:follow_import, %User{} = follower, identifier) do + with {:ok, %User{} = followed} <- User.get_or_fetch(identifier), + {:ok, follower, followed} <- User.maybe_direct_follow(follower, followed), + {:ok, _, _, _} <- CommonAPI.follow(follower, followed) do + followed + else + error -> handle_error(:follow_import, identifier, error) + end end def perform(_, _, _), do: :ok @@ -62,24 +47,24 @@ defp handle_error(op, user_id, error) do error end - def blocks_import(%User{} = blocker, [_ | _] = identifiers) do - BackgroundWorker.enqueue( - "blocks_import", - %{"user_id" => blocker.id, "identifiers" => identifiers} + defp enqueue_many(op, user, identifiers) do + Enum.map( + identifiers, + fn identifier -> + BackgroundWorker.enqueue(op, %{"user_id" => user.id, "identifier" => identifier}) + end ) end + def blocks_import(%User{} = blocker, [_ | _] = identifiers) do + enqueue_many("blocks_import", blocker, identifiers) + end + def follow_import(%User{} = follower, [_ | _] = identifiers) do - BackgroundWorker.enqueue( - "follow_import", - %{"user_id" => follower.id, "identifiers" => identifiers} - ) + enqueue_many("follow_import", follower, identifiers) end def mutes_import(%User{} = user, [_ | _] = identifiers) do - BackgroundWorker.enqueue( - "mutes_import", - %{"user_id" => user.id, "identifiers" => identifiers} - ) + enqueue_many("mutes_import", user, identifiers) end end diff --git a/lib/pleroma/user/search.ex b/lib/pleroma/user/search.ex index 6b3f58999..ddce51775 100644 --- a/lib/pleroma/user/search.ex +++ b/lib/pleroma/user/search.ex @@ -62,6 +62,11 @@ defp maybe_add_uri_match(list, query) do end end + def sanitise_domain(domain) do + domain + |> String.replace(~r/[!-\,|@|?|<|>|[-`|{-~|\/|:|\s]+/, "") + end + defp format_query(query_string) do # Strip the beginning @ off if there is a query query_string = String.trim_leading(query_string, "@") @@ -69,7 +74,7 @@ defp format_query(query_string) do with [name, domain] <- String.split(query_string, "@") do encoded_domain = domain - |> String.replace(~r/[!-\-|@|[-`|{-~|\/|:|\s]+/, "") + |> sanitise_domain() |> String.to_charlist() |> :idna.encode() |> to_string() diff --git a/lib/pleroma/user_note.ex b/lib/pleroma/user_note.ex index 5e82d359f..ff4981cb7 100644 --- a/lib/pleroma/user_note.ex +++ b/lib/pleroma/user_note.ex @@ -31,7 +31,7 @@ def show(%User{} = source, %User{} = target) do UserNote |> where(source_id: ^source.id, target_id: ^target.id) |> Repo.one() do - note.comment + note.comment || "" else _ -> "" end diff --git a/lib/pleroma/user_relationship.ex b/lib/pleroma/user_relationship.ex index a467e9b65..2f77697d4 100644 --- a/lib/pleroma/user_relationship.ex +++ b/lib/pleroma/user_relationship.ex @@ -67,8 +67,9 @@ def create(relationship_type, %User{} = source, %User{} = target) do target_id: target.id }) |> Repo.insert( - on_conflict: {:replace_all_except, [:id]}, - conflict_target: [:source_id, :relationship_type, :target_id] + on_conflict: {:replace_all_except, [:id, :inserted_at]}, + conflict_target: [:source_id, :relationship_type, :target_id], + returning: true ) end diff --git a/lib/pleroma/web.ex b/lib/pleroma/web.ex index 5761e3b38..5422e7896 100644 --- a/lib/pleroma/web.ex +++ b/lib/pleroma/web.ex @@ -27,6 +27,7 @@ defmodule Pleroma.Web do alias Pleroma.Web.Plugs.ExpectPublicOrAuthenticatedCheckPlug alias Pleroma.Web.Plugs.OAuthScopesPlug alias Pleroma.Web.Plugs.PlugHelper + require Pleroma.Constants def controller do quote do @@ -37,7 +38,7 @@ def controller do import Pleroma.Web.Gettext import Pleroma.Web.TranslationHelpers - alias Pleroma.Web.Router.Helpers, as: Routes + unquote(verified_routes()) plug(:set_put_layout) @@ -56,7 +57,10 @@ defp skip_plug(conn, plug_modules) do plug_module.skip_plug(conn) rescue UndefinedFunctionError -> - raise "`#{plug_module}` is not skippable. Append `use Pleroma.Web, :plug` to its code." + reraise( + "`#{plug_module}` is not skippable. Append `use Pleroma.Web, :plug` to its code.", + __STACKTRACE__ + ) end end ) @@ -129,66 +133,6 @@ defp maybe_halt_on_missing_oauth_scopes_check(conn) do end end - def view do - quote do - use Phoenix.View, - root: "lib/pleroma/web/templates", - namespace: Pleroma.Web - - # Import convenience functions from controllers - import Phoenix.Controller, only: [get_csrf_token: 0, get_flash: 2, view_module: 1] - - import Pleroma.Web.ErrorHelpers - import Pleroma.Web.Gettext - - alias Pleroma.Web.Router.Helpers, as: Routes - - require Logger - - @doc "Same as `render/3` but wrapped in a rescue block" - def safe_render(view, template, assigns \\ %{}) do - Phoenix.View.render(view, template, assigns) - rescue - error -> - Logger.error( - "#{__MODULE__} failed to render #{inspect({view, template})}\n" <> - Exception.format(:error, error, __STACKTRACE__) - ) - - nil - end - - @doc """ - Same as `render_many/4` but wrapped in rescue block. - """ - def safe_render_many(collection, view, template, assigns \\ %{}) do - Enum.map(collection, fn resource -> - as = Map.get(assigns, :as) || view.__resource__ - assigns = Map.put(assigns, as, resource) - safe_render(view, template, assigns) - end) - |> Enum.filter(& &1) - end - end - end - - def router do - quote do - use Phoenix.Router - # credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse - import Plug.Conn - import Phoenix.Controller - end - end - - def channel do - quote do - # credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse - import Phoenix.Channel - import Pleroma.Web.Gettext - end - end - def plug do quote do @behaviour Pleroma.Web.Plug @@ -233,6 +177,100 @@ def call(%Plug.Conn{} = conn, options) do end end + def view do + quote do + use Phoenix.View, + root: "lib/pleroma/web/templates", + namespace: Pleroma.Web + + # Import convenience functions from controllers + import Phoenix.Controller, + only: [view_module: 1, view_template: 1] + + import Phoenix.Flash + alias Phoenix.Flash + + # Include shared imports and aliases for views + unquote(view_helpers()) + end + end + + def live_view do + quote do + use Phoenix.LiveView, + layout: {Pleroma.Web.LayoutView, "live.html"} + + unquote(view_helpers()) + end + end + + def live_component do + quote do + use Phoenix.LiveComponent + + unquote(view_helpers()) + end + end + + def component do + quote do + use Phoenix.Component + + unquote(view_helpers()) + end + end + + def router do + quote do + use Phoenix.Router, helpers: false + + import Plug.Conn + import Phoenix.Controller + import Phoenix.LiveView.Router + end + end + + def channel do + quote do + use Phoenix.Channel + import Pleroma.Web.Gettext + end + end + + defp view_helpers do + quote do + # Use all HTML functionality (forms, tags, etc) + use Phoenix.HTML + + # Import LiveView and .heex helpers (live_render, live_patch, <.form>, etc) + import Phoenix.LiveView.Helpers + + # Import basic rendering functionality (render, render_layout, etc) + import Phoenix.View + + import Pleroma.Web.ErrorHelpers + import Pleroma.Web.Gettext + unquote(verified_routes()) + end + end + + def static_paths, do: Pleroma.Constants.static_only_files() + + def verified_routes do + quote do + use Phoenix.VerifiedRoutes, + endpoint: Pleroma.Web.Endpoint, + router: Pleroma.Web.Router, + statics: Pleroma.Web.static_paths() + end + end + + def mailer do + quote do + unquote(verified_routes()) + end + end + @doc """ When used, dispatch to the appropriate controller/view/etc. """ diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index dcdc7085f..1e06bc809 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -22,6 +22,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do alias Pleroma.Upload alias Pleroma.User alias Pleroma.Web.ActivityPub.MRF + alias Pleroma.Web.ActivityPub.ObjectValidators.UserValidator alias Pleroma.Web.ActivityPub.Transmogrifier alias Pleroma.Web.Streamer alias Pleroma.Web.WebFinger @@ -105,6 +106,23 @@ def persist(%{"type" => type} = object, meta) when type in @object_types do end end + @unpersisted_activity_types ~w[Undo Delete Remove Accept Reject] + @impl true + def persist(%{"type" => type} = object, [local: false] = meta) + when type in @unpersisted_activity_types do + {:ok, object, meta} + {recipients, _, _} = get_recipients(object) + + unpersisted = %Activity{ + data: object, + local: false, + recipients: recipients, + actor: object["actor"] + } + + {:ok, unpersisted, meta} + end + @impl true def persist(object, meta) do with local <- Keyword.fetch!(meta, :local), @@ -722,9 +740,9 @@ defp fetch_activities_for_reading_user(reading_user, params) do |> fetch_activities(params, :offset) end - defp user_activities_recipients(%{godmode: true}), do: [] + def user_activities_recipients(%{godmode: true}), do: [] - defp user_activities_recipients(%{reading_user: reading_user}) do + def user_activities_recipients(%{reading_user: reading_user}) do if not is_nil(reading_user) and reading_user.local do [ Constants.as_public(), @@ -916,6 +934,31 @@ defp restrict_recipients(query, recipients, user) do ) end + # Essentially, either look for activities addressed to `recipients`, _OR_ ones + # that reference a hashtag that the user follows + # Firstly, two fallbacks in case there's no hashtag constraint, or the user doesn't + # follow any + defp restrict_recipients_or_hashtags(query, recipients, user, nil) do + restrict_recipients(query, recipients, user) + end + + defp restrict_recipients_or_hashtags(query, recipients, user, []) do + restrict_recipients(query, recipients, user) + end + + defp restrict_recipients_or_hashtags(query, recipients, _user, hashtag_ids) do + from([activity, object] in query) + |> join(:left, [activity, object], hto in "hashtags_objects", + on: hto.object_id == object.id, + as: :hto + ) + |> where( + [activity, object, hto: hto], + (hto.hashtag_id in ^hashtag_ids and ^Constants.as_public() in activity.recipients) or + fragment("? && ?", ^recipients, activity.recipients) + ) + end + defp restrict_local(query, %{local_only: true}) do from(activity in query, where: activity.local == true) end @@ -1247,15 +1290,15 @@ defp exclude_poll_votes(query, _) do end end + defp exclude_invisible_actors(query, %{type: "Flag"}), do: query defp exclude_invisible_actors(query, %{invisible_actors: true}), do: query defp exclude_invisible_actors(query, _opts) do - invisible_ap_ids = - User.Query.build(%{invisible: true, select: [:ap_id]}) - |> Repo.all() - |> Enum.map(fn %{ap_id: ap_id} -> ap_id end) - - from([activity] in query, where: activity.actor not in ^invisible_ap_ids) + query + |> join(:inner, [activity], u in User, + as: :u, + on: activity.actor == u.ap_id and u.invisible == false + ) end defp exclude_id(query, %{exclude_id: id}) when is_binary(id) do @@ -1363,7 +1406,7 @@ def fetch_activities_query(recipients, opts \\ %{}) do |> maybe_preload_report_notes(opts) |> maybe_set_thread_muted_field(opts) |> maybe_order(opts) - |> restrict_recipients(recipients, opts[:user]) + |> restrict_recipients_or_hashtags(recipients, opts[:user], opts[:followed_hashtags]) |> restrict_replies(opts) |> restrict_since(opts) |> restrict_local(opts) @@ -1385,7 +1428,7 @@ def fetch_activities_query(recipients, opts \\ %{}) do |> restrict_instance(opts) |> restrict_announce_object_actor(opts) |> restrict_filtered(opts) - |> Activity.restrict_deactivated_users() + |> maybe_restrict_deactivated_users(opts) |> exclude_poll_votes(opts) |> exclude_invisible_actors(opts) |> exclude_visibility(opts) @@ -1460,13 +1503,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 @@ -1489,6 +1541,10 @@ defp normalize_image(%{"url" => url}) do defp normalize_image(urls) when is_list(urls), do: urls |> List.first() |> normalize_image() defp normalize_image(_), do: nil + defp normalize_also_known_as(aka) when is_list(aka), do: aka + defp normalize_also_known_as(aka) when is_binary(aka), do: [aka] + defp normalize_also_known_as(nil), do: [] + defp object_to_user_data(data, additional) do fields = data @@ -1530,11 +1586,25 @@ defp object_to_user_data(data, additional) do # we request WebFinger here nickname = additional[:nickname_from_acct] || generate_nickname(data) + # also_known_as must be a URL + also_known_as = + data + |> Map.get("alsoKnownAs", []) + |> normalize_also_known_as() + |> Enum.filter(fn url -> + case URI.parse(url) do + %URI{scheme: "http"} -> true + %URI{scheme: "https"} -> true + _ -> false + end + end) + %{ ap_id: data["id"], uri: get_actor_url(data["url"]), ap_enabled: true, banner: normalize_image(data["image"]), + background: normalize_image(data["backgroundUrl"]), fields: fields, emoji: emojis, is_locked: is_locked, @@ -1547,7 +1617,7 @@ defp object_to_user_data(data, additional) do featured_address: featured_address, bio: data["summary"] || "", actor_type: actor_type, - also_known_as: Map.get(data, "alsoKnownAs", []), + also_known_as: also_known_as, public_key: public_key, inbox: data["inbox"], shared_inbox: shared_inbox, @@ -1653,18 +1723,23 @@ def user_data_from_user_object(data, additional \\ []) do def fetch_and_prepare_user_from_ap_id(ap_id, additional \\ []) do with {:ok, data} <- Fetcher.fetch_and_contain_remote_object_from_id(ap_id), + {:valid, {:ok, _, _}} <- {:valid, UserValidator.validate(data, [])}, {:ok, data} <- user_data_from_user_object(data, additional) do {:ok, maybe_update_follow_information(data)} else # If this has been deleted, only log a debug and not an error - {:error, "Object has been deleted" = e} -> + {:error, {"Object has been deleted", _, _} = e} -> Logger.debug("Could not decode user at fetch #{ap_id}, #{inspect(e)}") {:error, e} {:error, {:reject, reason} = e} -> - Logger.info("Rejected user #{ap_id}: #{inspect(reason)}") + Logger.debug("Rejected user #{ap_id}: #{inspect(reason)}") {:error, e} + {:valid, reason} -> + Logger.debug("Data is not a valid user #{ap_id}: #{inspect(reason)}") + {:error, "Not a user"} + {:error, e} -> Logger.error("Could not decode user at fetch #{ap_id}, #{inspect(e)}") {:error, e} @@ -1724,6 +1799,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 @@ -1760,6 +1840,13 @@ def make_user_from_ap_id(ap_id, additional \\ []) do with {:ok, data} <- fetch_and_prepare_user_from_ap_id(ap_id, additional) do {:ok, _pid} = Task.start(fn -> pinned_fetch_task(data) end) + user = + if data.ap_id != ap_id do + User.get_cached_by_ap_id(data.ap_id) + else + user + end + if user do user |> User.remote_user_changeset(data) @@ -1801,4 +1888,9 @@ def fetch_direct_messages_query do |> restrict_visibility(%{visibility: "direct"}) |> order_by([activity], asc: activity.id) end + + defp maybe_restrict_deactivated_users(activity, %{type: "Flag"}), do: activity + + defp maybe_restrict_deactivated_users(activity, _opts), + do: Activity.restrict_deactivated_users(activity) end diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index c07f91b2e..793b08f3d 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -8,7 +8,6 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do alias Pleroma.Activity alias Pleroma.Delivery alias Pleroma.Object - alias Pleroma.Object.Fetcher alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.InternalFetchActor @@ -293,33 +292,12 @@ def inbox(%{assigns: %{valid_signature: false}} = conn, _params) do |> json("Invalid HTTP Signature") end - # POST /relay/inbox -or- POST /internal/fetch/inbox - def inbox(conn, %{"type" => "Create"} = params) do - if FederatingPlug.federating?() do - post_inbox_relayed_create(conn, params) - else - conn - |> put_status(:bad_request) - |> json("Not federating") - end - end - def inbox(conn, _params) do conn |> put_status(:bad_request) |> json("error, missing HTTP Signature") end - defp post_inbox_relayed_create(conn, params) do - Logger.debug( - "Signature missing or not from author, relayed Create message, fetching object from source" - ) - - Fetcher.fetch_object_from_id(params["object"]["id"]) - - json(conn, "ok") - end - defp represent_service_actor(%User{} = user, conn) do conn |> put_resp_content_type("application/activity+json") @@ -476,7 +454,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 6d39ad3a8..e67a14b58 100644 --- a/lib/pleroma/web/activity_pub/builder.ex +++ b/lib/pleroma/web/activity_pub/builder.ex @@ -18,6 +18,8 @@ defmodule Pleroma.Web.ActivityPub.Builder do alias Pleroma.Web.CommonAPI.ActivityDraft alias Pleroma.Web.Endpoint + use Pleroma.Web, :verified_routes + require Pleroma.Constants def accept_or_reject(actor, activity, type) do @@ -402,6 +404,6 @@ def unpin(%User{} = user, object) do end defp pinned_url(nickname) when is_binary(nickname) do - Pleroma.Web.Router.Helpers.activity_pub_url(Pleroma.Web.Endpoint, :pinned, nickname) + url(~p[/users/#{nickname}/collections/featured]) end end diff --git a/lib/pleroma/web/activity_pub/mrf.ex b/lib/pleroma/web/activity_pub/mrf.ex index 4df226e80..d03568444 100644 --- a/lib/pleroma/web/activity_pub/mrf.ex +++ b/lib/pleroma/web/activity_pub/mrf.ex @@ -63,7 +63,15 @@ defmodule Pleroma.Web.ActivityPub.MRF do @required_description_keys [:key, :related_policy] + def filter_one(policy, %{"type" => type} = message) + when type in ["Undo", "Block", "Delete"] and + policy != Pleroma.Web.ActivityPub.MRF.SimplePolicy do + {:ok, message} + end + def filter_one(policy, message) do + Code.ensure_loaded!(policy) + should_plug_history? = if function_exported?(policy, :history_awareness, 0) do policy.history_awareness() @@ -140,7 +148,9 @@ def get_policies do |> get_policies() |> Enum.concat([ Pleroma.Web.ActivityPub.MRF.HashtagPolicy, - Pleroma.Web.ActivityPub.MRF.InlineQuotePolicy + Pleroma.Web.ActivityPub.MRF.InlineQuotePolicy, + Pleroma.Web.ActivityPub.MRF.NormalizeMarkup, + Pleroma.Web.ActivityPub.MRF.DirectMessageDisabledPolicy ]) |> Enum.uniq() end @@ -149,9 +159,27 @@ defp get_policies(policy) when is_atom(policy), do: [policy] defp get_policies(policies) when is_list(policies), do: policies defp get_policies(_), do: [] + # Matches the following: + # - https://baddomain.net + # - https://extra.baddomain.net/ + # Does NOT match the following: + # - https://maybebaddomain.net/ + + # *.baddomain.net + def subdomain_regex("*." <> domain), do: subdomain_regex(domain) + + # baddomain.net + def subdomain_regex(domain) do + if String.ends_with?(domain, ".*") do + ~r/^(.+\.)?#{Regex.escape(String.replace_suffix(domain, ".*", ""))}\.(.+)$/i + else + ~r/^(.+\.)?#{Regex.escape(domain)}$/i + end + end + @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 + Enum.map(domains, &subdomain_regex/1) end @spec subdomain_match?([Regex.t()], String.t()) :: boolean() @@ -213,7 +241,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/activity_expiration_policy.ex b/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex index e78254280..5f412566d 100644 --- a/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicy do - @moduledoc "Adds expiration to all local Create activities" + @moduledoc "Adds expiration to all local Create/Update activities" @behaviour Pleroma.Web.ActivityPub.MRF.Policy @impl true @@ -25,8 +25,13 @@ defp local?(%{"actor" => actor}) do String.starts_with?(actor, Pleroma.Web.Endpoint.url()) end - defp note?(activity) do - match?(%{"type" => "Create", "object" => %{"type" => "Note"}}, activity) + defp note?(%{"type" => type, "object" => %{"type" => "Note"}}) + when type in ["Create", "Update"] do + true + end + + defp note?(_) do + false end defp maybe_add_expiration(activity) do diff --git a/lib/pleroma/web/activity_pub/mrf/anti_link_spam_policy.ex b/lib/pleroma/web/activity_pub/mrf/anti_link_spam_policy.ex index ba7c8400b..6885df863 100644 --- a/lib/pleroma/web/activity_pub/mrf/anti_link_spam_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/anti_link_spam_policy.ex @@ -29,7 +29,8 @@ defp contains_links?(%{"content" => content} = _object) do defp contains_links?(_), do: false @impl true - def filter(%{"type" => "Create", "actor" => actor, "object" => object} = message) do + def filter(%{"type" => type, "actor" => actor, "object" => object} = message) + when type in ["Create", "Update"] do with {:ok, %User{local: false} = u} <- User.get_or_fetch_by_ap_id(actor), {:contains_links, true} <- {:contains_links, contains_links?(object)}, {:old_user, true} <- {:old_user, old_user?(u)} do diff --git a/lib/pleroma/web/activity_pub/mrf/direct_message_disabled_policy.ex b/lib/pleroma/web/activity_pub/mrf/direct_message_disabled_policy.ex new file mode 100644 index 000000000..7a834f6ae --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/direct_message_disabled_policy.ex @@ -0,0 +1,65 @@ +defmodule Pleroma.Web.ActivityPub.MRF.DirectMessageDisabledPolicy do + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + + alias Pleroma.User + require Pleroma.Constants + + @moduledoc """ + Removes entries from the "To" field from direct messages if the user has requested to not + allow direct messages + """ + + @impl true + def filter( + %{ + "type" => "Create", + "actor" => actor, + "object" => %{ + "type" => "Note" + } + } = activity + ) do + with recipients <- Map.get(activity, "to", []), + cc <- Map.get(activity, "cc", []), + true <- is_direct?(recipients, cc), + sender <- User.get_cached_by_ap_id(actor) do + new_to = + Enum.filter(recipients, fn recv -> + should_include?(sender, recv) + end) + + {:ok, + activity + |> Map.put("to", new_to) + |> maybe_replace_object_to(new_to)} + else + _ -> + {:ok, activity} + end + end + + @impl true + def filter(object), do: {:ok, object} + + @impl true + def describe, do: {:ok, %{}} + + defp should_include?(sender, receiver_ap_id) do + with %User{local: true} = receiver <- User.get_cached_by_ap_id(receiver_ap_id) do + User.accepts_direct_messages?(receiver, sender) + else + _ -> true + end + end + + defp maybe_replace_object_to(%{"object" => %{"to" => _}} = activity, to) do + Kernel.put_in(activity, ["object", "to"], to) + end + + defp maybe_replace_object_to(other, _), do: other + + defp is_direct?(to, cc) do + !(Enum.member?(to, Pleroma.Constants.as_public()) || + Enum.member?(cc, Pleroma.Constants.as_public())) + 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 deleted file mode 100644 index 7cf7de068..000000000 --- a/lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex +++ /dev/null @@ -1,59 +0,0 @@ -defmodule Pleroma.Web.ActivityPub.MRF.FollowBotPolicy do - @behaviour Pleroma.Web.ActivityPub.MRF.Policy - alias Pleroma.Config - alias Pleroma.User - alias Pleroma.Web.CommonAPI - - require Logger - - @impl true - def filter(message) do - with follower_nickname <- Config.get([:mrf_follow_bot, :follower_nickname]), - %User{actor_type: "Service"} = follower <- - User.get_cached_by_nickname(follower_nickname), - %{"type" => "Create", "object" => %{"type" => "Note"}} <- message do - try_follow(follower, message) - else - nil -> - Logger.warn( - "#{__MODULE__} skipped because of missing `:mrf_follow_bot, :follower_nickname` configuration, the :follower_nickname - account does not exist, or the account is not correctly configured as a bot." - ) - - {:ok, message} - - _ -> - {:ok, message} - end - end - - defp try_follow(follower, message) do - to = Map.get(message, "to", []) - cc = Map.get(message, "cc", []) - actor = [message["actor"]] - - Enum.concat([to, cc, actor]) - |> List.flatten() - |> Enum.uniq() - |> User.get_all_by_ap_id() - |> Enum.each(fn user -> - with false <- user.local, - false <- User.following?(follower, user), - false <- User.locked?(user), - false <- (user.bio || "") |> String.downcase() |> String.contains?("nobot") do - Logger.debug( - "#{__MODULE__}: Follow request from #{follower.nickname} to #{user.nickname}" - ) - - CommonAPI.follow(follower, user) - end - end) - - {:ok, message} - end - - @impl true - def describe do - {:ok, %{}} - end -end diff --git a/lib/pleroma/web/activity_pub/mrf/force_bot_unlisted_policy.ex b/lib/pleroma/web/activity_pub/mrf/force_bot_unlisted_policy.ex index 11871375e..fa6b93333 100644 --- a/lib/pleroma/web/activity_pub/mrf/force_bot_unlisted_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/force_bot_unlisted_policy.ex @@ -17,13 +17,14 @@ defp check_if_bot(user), do: check_by_actor_type(user) or check_by_nickname(user @impl true def filter( %{ - "type" => "Create", + "type" => type, "to" => to, "cc" => cc, "actor" => actor, "object" => object } = message - ) do + ) + when type in ["Create", "Update"] do user = User.get_cached_by_ap_id(actor) isbot = check_if_bot(user) diff --git a/lib/pleroma/web/activity_pub/mrf/hellthread_policy.ex b/lib/pleroma/web/activity_pub/mrf/hellthread_policy.ex index 504bd4d57..18704fc2c 100644 --- a/lib/pleroma/web/activity_pub/mrf/hellthread_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/hellthread_policy.ex @@ -73,8 +73,8 @@ defp get_recipient_count(message) do end @impl true - def filter(%{"type" => "Create", "object" => %{"type" => object_type}} = message) - when object_type in ~w{Note Article} do + def filter(%{"type" => type, "object" => %{"type" => object_type}} = message) + when type in ~w{Create Update} and object_type in ~w{Note Article} do reject_threshold = Pleroma.Config.get( [:mrf_hellthread, :reject_threshold], diff --git a/lib/pleroma/web/activity_pub/mrf/inline_quote_policy.ex b/lib/pleroma/web/activity_pub/mrf/inline_quote_policy.ex index 20432410b..35682f994 100644 --- a/lib/pleroma/web/activity_pub/mrf/inline_quote_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/inline_quote_policy.ex @@ -6,14 +6,29 @@ defmodule Pleroma.Web.ActivityPub.MRF.InlineQuotePolicy do @moduledoc "Force a quote line into the message content." @behaviour Pleroma.Web.ActivityPub.MRF.Policy + alias Pleroma.Object + defp build_inline_quote(prefix, url) do "

    #{prefix}: #{url}
    " end - defp has_inline_quote?(content, quote_url) do + defp resolve_urls(quote_url) do + # Fetching here can cause infinite recursion as we run this logic on inbound objects too + # This is probably not a problem - its an exceptional corner case for a local user to quote + # a post which doesn't exist + with %Object{} = obj <- Object.normalize(quote_url, fetch: false) do + id = obj.data["id"] + url = Map.get(obj.data, "url", id) + {id, url, [id, url, quote_url]} + else + _ -> {quote_url, quote_url, [quote_url]} + end + end + + defp has_inline_quote?(content, urls) do cond do # Does the quote URL exist in the content? - content =~ quote_url -> true + Enum.any?(urls, fn url -> content =~ url end) -> true # Does the content already have a .quote-inline span? content =~ "" -> true # No inline quote found @@ -22,18 +37,22 @@ defp has_inline_quote?(content, quote_url) do end defp filter_object(%{"quoteUri" => quote_url} = object) do + {id, preferred_url, all_urls} = resolve_urls(quote_url) + object = Map.put(object, "quoteUri", id) + content = object["content"] || "" - if has_inline_quote?(content, quote_url) do + if has_inline_quote?(content, all_urls) do object else prefix = Pleroma.Config.get([:mrf_inline_quote, :prefix]) content = if String.ends_with?(content, "

    ") do - String.trim_trailing(content, "

    ") <> build_inline_quote(prefix, quote_url) <> "

    " + String.trim_trailing(content, "

    ") <> + build_inline_quote(prefix, preferred_url) <> "

    " else - content <> build_inline_quote(prefix, quote_url) + content <> build_inline_quote(prefix, preferred_url) end Map.put(object, "content", content) diff --git a/lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex b/lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex index 72455afd0..e5449b576 100644 --- a/lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex @@ -12,7 +12,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy do require Logger @adapter_options [ - recv_timeout: 10_000 + receive_timeout: 10_000 ] @impl true diff --git a/lib/pleroma/web/activity_pub/mrf/mention_policy.ex b/lib/pleroma/web/activity_pub/mrf/mention_policy.ex index 05b28e4f5..11e9bd478 100644 --- a/lib/pleroma/web/activity_pub/mrf/mention_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/mention_policy.ex @@ -8,7 +8,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.MentionPolicy do @behaviour Pleroma.Web.ActivityPub.MRF.Policy @impl true - def filter(%{"type" => "Create"} = message) do + def filter(%{"type" => type} = message) when type in ["Create", "Update"] do reject_actors = Pleroma.Config.get([:mrf_mention, :actors], []) recipients = (message["to"] || []) ++ (message["cc"] || []) diff --git a/lib/pleroma/web/activity_pub/mrf/reject_newly_created_account_note_policy.ex b/lib/pleroma/web/activity_pub/mrf/reject_newly_created_account_note_policy.ex new file mode 100644 index 000000000..01a846831 --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/reject_newly_created_account_note_policy.ex @@ -0,0 +1,50 @@ +defmodule Pleroma.Web.ActivityPub.MRF.RejectNewlyCreatedAccountNotesPolicy do + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + + alias Pleroma.User + + @moduledoc """ + Rejects notes from accounts that were created below a certain threshold of time ago + """ + @impl true + def filter( + %{ + "type" => type, + "actor" => actor + } = activity + ) + when type in ["Note", "Create"] do + min_age = Pleroma.Config.get([:mrf_reject_newly_created_account_notes, :age]) + + with %User{local: false} = user <- Pleroma.User.get_cached_by_ap_id(actor), + true <- Timex.diff(Timex.now(), user.inserted_at, :seconds) < min_age do + {:reject, "[RejectNewlyCreatedAccountNotesPolicy] Account created too recently"} + else + _ -> {:ok, activity} + end + end + + @impl true + def filter(object), do: {:ok, object} + + @impl true + def describe, do: {:ok, %{}} + + @impl true + def config_description do + %{ + key: :mrf_reject_newly_created_account_notes, + related_policy: "Pleroma.Web.ActivityPub.MRF.RejectNewlyCreatedAccountNotesPolicy", + label: "MRF Reject New Accounts", + description: "Reject notes from accounts created too recently", + children: [ + %{ + key: :age, + type: :integer, + description: "Time below which to reject (in seconds)", + suggestions: [86_400] + } + ] + } + end +end diff --git a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex index 415c5d2dd..0b8b846ec 100644 --- a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex @@ -13,20 +13,20 @@ defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicy do require Pleroma.Constants - defp check_accept(%{host: actor_host} = _actor_info, object) do + def check_accept(%{host: actor_host} = _actor_info) do accepts = instance_list(:accept) |> MRF.subdomains_regex() cond do - accepts == [] -> {:ok, object} - actor_host == Config.get([Pleroma.Web.Endpoint, :url, :host]) -> {:ok, object} - MRF.subdomain_match?(accepts, actor_host) -> {:ok, object} + accepts == [] -> {:ok, nil} + actor_host == Config.get([Pleroma.Web.Endpoint, :url, :host]) -> {:ok, nil} + MRF.subdomain_match?(accepts, actor_host) -> {:ok, nil} true -> {:reject, "[SimplePolicy] host not in accept list"} end end - defp check_reject(%{host: actor_host} = _actor_info, object) do + def check_reject(%{host: actor_host} = _actor_info) do rejects = instance_list(:reject) |> MRF.subdomains_regex() @@ -34,15 +34,15 @@ defp check_reject(%{host: actor_host} = _actor_info, object) do if MRF.subdomain_match?(rejects, actor_host) do {:reject, "[SimplePolicy] host in reject list"} else - {:ok, object} + {:ok, nil} end end defp check_media_removal( %{host: actor_host} = _actor_info, - %{"type" => "Create", "object" => %{"attachment" => child_attachment}} = object + %{"type" => type, "object" => %{"attachment" => child_attachment}} = object ) - when length(child_attachment) > 0 do + when type in ["Create", "Update"] and length(child_attachment) > 0 do media_removal = instance_list(:media_removal) |> MRF.subdomains_regex() @@ -63,10 +63,11 @@ defp check_media_removal(_actor_info, object), do: {:ok, object} defp check_media_nsfw( %{host: actor_host} = _actor_info, %{ - "type" => "Create", + "type" => type, "object" => %{} = _child_object } = object - ) do + ) + when type in ["Create", "Update"] do media_nsfw = instance_list(:media_nsfw) |> MRF.subdomains_regex() @@ -177,6 +178,72 @@ defp check_banner_removal(%{host: actor_host} = _actor_info, %{"image" => _image defp check_banner_removal(_actor_info, object), do: {:ok, object} + defp check_background_removal( + %{host: actor_host} = _actor_info, + %{"backgroundUrl" => _bg} = object + ) do + background_removal = + instance_list(:background_removal) + |> MRF.subdomains_regex() + + if MRF.subdomain_match?(background_removal, actor_host) do + {:ok, Map.delete(object, "backgroundUrl")} + else + {:ok, object} + end + end + + defp check_background_removal(_actor_info, object), do: {:ok, object} + + defp extract_context_uri(%{"conversation" => "tag:" <> rest}) do + rest + |> String.split(",", parts: 2, trim: true) + |> hd() + |> case do + nil -> nil + hostname -> URI.parse("//" <> hostname) + end + end + + defp extract_context_uri(%{"context" => "http" <> _ = context}), do: URI.parse(context) + + defp extract_context_uri(_), do: nil + + defp check_context(activity) do + uri = extract_context_uri(activity) + + with {:uri, true} <- {:uri, Kernel.match?(%URI{}, uri)}, + {:ok, _} <- check_accept(uri), + {:ok, _} <- check_reject(uri) do + {:ok, activity} + else + # Can't check. + {:uri, false} -> {:ok, activity} + {:reject, nil} -> {:reject, "[SimplePolicy]"} + {:reject, _} = e -> e + _ -> {:reject, "[SimplePolicy]"} + end + end + + defp check_reply_to(%{"object" => %{"inReplyTo" => in_reply_to}} = activity) do + with {:ok, _} <- filter(in_reply_to) do + {:ok, activity} + end + end + + defp check_reply_to(activity), do: {:ok, activity} + + defp maybe_check_thread(activity) do + if Config.get([:mrf_simple, :handle_threads], true) do + with {:ok, _} <- check_context(activity), + {:ok, _} <- check_reply_to(activity) do + {:ok, activity} + end + else + {:ok, activity} + end + end + defp check_object(%{"object" => object} = activity) do with {:ok, _object} <- filter(object) do {:ok, activity} @@ -209,13 +276,14 @@ def filter(%{"type" => "Delete", "actor" => actor} = object) do def filter(%{"actor" => actor} = object) do actor_info = URI.parse(actor) - with {:ok, object} <- check_accept(actor_info, object), - {:ok, object} <- check_reject(actor_info, object), + with {:ok, _} <- check_accept(actor_info), + {:ok, _} <- check_reject(actor_info), {:ok, object} <- check_media_removal(actor_info, object), {:ok, object} <- check_media_nsfw(actor_info, object), {:ok, object} <- check_ftl_removal(actor_info, object), {:ok, object} <- check_followers_only(actor_info, object), {:ok, object} <- check_report_removal(actor_info, object), + {:ok, object} <- maybe_check_thread(object), {:ok, object} <- check_object(object) do {:ok, object} else @@ -229,10 +297,11 @@ def filter(%{"id" => actor, "type" => obj_type} = object) when obj_type in ["Application", "Group", "Organization", "Person", "Service"] do actor_info = URI.parse(actor) - with {:ok, object} <- check_accept(actor_info, object), - {:ok, object} <- check_reject(actor_info, object), + with {:ok, _} <- check_accept(actor_info), + {:ok, _} <- check_reject(actor_info), {:ok, object} <- check_avatar_removal(actor_info, object), - {:ok, object} <- check_banner_removal(actor_info, object) do + {:ok, object} <- check_banner_removal(actor_info, object), + {:ok, object} <- check_background_removal(actor_info, object) do {:ok, object} else {:reject, nil} -> {:reject, "[SimplePolicy]"} @@ -241,11 +310,17 @@ def filter(%{"id" => actor, "type" => obj_type} = object) end end + def filter(%{"id" => id} = object) do + with {:ok, _} <- filter(id) do + {:ok, object} + end + end + def filter(object) when is_binary(object) do uri = URI.parse(object) - with {:ok, object} <- check_accept(uri, object), - {:ok, object} <- check_reject(uri, object) do + with {:ok, _} <- check_accept(uri), + {:ok, _} <- check_reject(uri) do {:ok, object} else {:reject, nil} -> {:reject, "[SimplePolicy]"} @@ -257,6 +332,20 @@ def filter(object) when is_binary(object) do def filter(object), do: {:ok, object} defp obfuscate(string) when is_binary(string) do + # Want to strip at least two neighbouring chars + # to ensure at least one non-dot char is in the obfuscation area + stripped = String.length(string) - 6 + + {keepstart, keepend} = + if stripped > 1 do + {3, 3} + else + { + 2 - div(1 - stripped, 2), + 2 + div(stripped, 2) + } + end + string |> to_charlist() |> Enum.with_index() @@ -265,7 +354,7 @@ defp obfuscate(string) when is_binary(string) do ?. {char, index} -> - if 3 <= index && index < String.length(string) - 3, do: ?*, else: char + if keepstart <= index && index < String.length(string) - keepend, do: ?*, else: char end) |> to_string() end @@ -287,6 +376,7 @@ def describe do mrf_simple_excluded = Config.get(:mrf_simple) + |> Enum.filter(fn {_, v} -> is_list(v) end) |> Enum.map(fn {rule, instances} -> {rule, Enum.reject(instances, fn {host, _} -> host in exclusions end)} end) @@ -331,66 +421,83 @@ def config_description do label: "MRF Simple", description: "Simple ingress policies", children: - [ - %{ - key: :media_removal, - description: - "List of instances to strip media attachments from and the reason for doing so" - }, - %{ - key: :media_nsfw, - label: "Media NSFW", - description: - "List of instances to tag all media as NSFW (sensitive) from and the reason for doing so" - }, - %{ - key: :federated_timeline_removal, - description: - "List of instances to remove from the Federated (aka The Whole Known Network) Timeline and the reason for doing so" - }, - %{ - key: :reject, - description: - "List of instances to reject activities from (except deletes) and the reason for doing so" - }, - %{ - key: :accept, - description: - "List of instances to only accept activities from (except deletes) and the reason for doing so" - }, - %{ - key: :followers_only, - description: - "Force posts from the given instances to be visible by followers only and the reason for doing so" - }, - %{ - key: :report_removal, - description: "List of instances to reject reports from and the reason for doing so" - }, - %{ - key: :avatar_removal, - description: "List of instances to strip avatars from and the reason for doing so" - }, - %{ - key: :banner_removal, - description: "List of instances to strip banners from and the reason for doing so" - }, - %{ - key: :reject_deletes, - description: "List of instances to reject deletions from and the reason for doing so" - } - ] - |> Enum.map(fn setting -> - Map.merge( - setting, + ([ + %{ + key: :media_removal, + description: + "List of instances to strip media attachments from and the reason for doing so" + }, + %{ + key: :media_nsfw, + label: "Media NSFW", + description: + "List of instances to tag all media as NSFW (sensitive) from and the reason for doing so" + }, + %{ + key: :federated_timeline_removal, + description: + "List of instances to remove from the Federated (aka The Whole Known Network) Timeline and the reason for doing so" + }, + %{ + key: :reject, + description: + "List of instances to reject activities from (except deletes) and the reason for doing so" + }, + %{ + key: :accept, + description: + "List of instances to only accept activities from (except deletes) and the reason for doing so" + }, + %{ + key: :followers_only, + description: + "Force posts from the given instances to be visible by followers only and the reason for doing so" + }, + %{ + key: :report_removal, + description: "List of instances to reject reports from and the reason for doing so" + }, + %{ + key: :avatar_removal, + description: "List of instances to strip avatars from and the reason for doing so" + }, + %{ + key: :banner_removal, + description: "List of instances to strip banners from and the reason for doing so" + }, + %{ + key: :background_removal, + description: + "List of instances to strip user backgrounds from and the reason for doing so" + }, + %{ + key: :reject_deletes, + description: "List of instances to reject deletions from and the reason for doing so" + } + ] + |> Enum.map(fn setting -> + Map.merge( + setting, + %{ + type: {:list, :tuple}, + key_placeholder: "instance", + value_placeholder: "reason", + suggestions: [ + {"example.com", "Some reason"}, + {"*.example.com", "Another reason"} + ] + } + ) + end)) ++ + [ %{ - type: {:list, :tuple}, - key_placeholder: "instance", - value_placeholder: "reason", - suggestions: [{"example.com", "Some reason"}, {"*.example.com", "Another reason"}] + key: :handle_threads, + label: "Apply to entire threads", + type: :boolean, + description: + "Enable to filter replies to threads based from their originating instance, using the reject and accept rules" } - ) - 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 61e95b49a..26d3dc592 100644 --- a/lib/pleroma/web/activity_pub/mrf/steal_emoji_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/steal_emoji_policy.ex @@ -6,10 +6,54 @@ defmodule Pleroma.Web.ActivityPub.MRF.StealEmojiPolicy do require Logger alias Pleroma.Config + alias Pleroma.Emoji.Pack @moduledoc "Detect new emojis by their shortcode and steals them" @behaviour Pleroma.Web.ActivityPub.MRF.Policy + @pack_name "stolen" + + # Config defaults + @size_limit 50_000 + @download_unknown_size false + + defp create_pack() do + with {:ok, pack} = Pack.create(@pack_name) do + Pack.save_metadata( + %{ + "description" => "Collection of emoji auto-stolen from other instances", + "homepage" => Pleroma.Web.Endpoint.url(), + "can-download" => false, + "share-files" => false + }, + pack + ) + end + end + + defp load_or_create_pack() do + case Pack.load_pack(@pack_name) do + {:ok, pack} -> {:ok, pack} + {:error, :enoent} -> create_pack() + e -> e + end + end + + defp add_emoji(shortcode, extension, filedata) do + {:ok, pack} = load_or_create_pack() + # Make final path infeasible to predict to thwart certain kinds of attacks + # (48 bits is slighty more than 8 base62 chars, thus 9 chars) + salt = + :crypto.strong_rand_bytes(6) + |> :crypto.bytes_to_integer() + |> Base62.encode() + |> String.pad_leading(9, "0") + + filename = shortcode <> "-" <> salt <> "." <> extension + + Pack.add_file(pack, shortcode, filename, filedata) + end + defp accept_host?(host), do: host in Config.get([:mrf_steal_emoji, :hosts], []) defp shortcode_matches?(shortcode, pattern) when is_binary(pattern) do @@ -20,30 +64,69 @@ defp shortcode_matches?(shortcode, pattern) do String.match?(shortcode, pattern) end - defp steal_emoji({shortcode, url}, emoji_dir_path) do + defp reject_emoji?({shortcode, _url}, installed_emoji) do + valid_shortcode? = String.match?(shortcode, ~r/^[a-zA-Z0-9_-]+$/) + + rejected_shortcode? = + [:mrf_steal_emoji, :rejected_shortcodes] + |> Config.get([]) + |> Enum.any?(fn pattern -> shortcode_matches?(shortcode, pattern) end) + + emoji_installed? = Enum.member?(installed_emoji, shortcode) + + !valid_shortcode? or rejected_shortcode? or emoji_installed? + end + + defp steal_emoji(%{} = response, {shortcode, extension}) do + case add_emoji(shortcode, extension, response.body) do + {:ok, _} -> + shortcode + + e -> + Logger.warning( + "MRF.StealEmojiPolicy: Failed to add #{shortcode} as #{extension}: #{inspect(e)}" + ) + + nil + end + end + + defp get_extension_if_safe(response) do + content_type = + :proplists.get_value("content-type", response.headers, MIME.from_path(response.url)) + + case content_type do + "image/" <> _ -> List.first(MIME.extensions(content_type)) + _ -> nil + end + end + + defp is_remote_size_within_limit?(url) do + with {:ok, %{status: status, headers: headers} = _response} when status in 200..299 <- + Pleroma.HTTP.request(:head, url, nil, [], []) do + content_length = :proplists.get_value("content-length", headers, nil) + size_limit = Config.get([:mrf_steal_emoji, :size_limit], @size_limit) + + accept_unknown = + Config.get([:mrf_steal_emoji, :download_unknown_size], @download_unknown_size) + + content_length <= size_limit or + (content_length == nil and accept_unknown) + else + _ -> false + end + end + + defp maybe_steal_emoji({shortcode, url}) do url = Pleroma.Web.MediaProxy.url(url) - with {:ok, %{status: status} = response} when status in 200..299 <- Pleroma.HTTP.get(url) do - size_limit = Config.get([:mrf_steal_emoji, :size_limit], 50_000) + with {:remote_size, true} <- {:remote_size, is_remote_size_within_limit?(url)}, + {:ok, %{status: status} = response} when status in 200..299 <- Pleroma.HTTP.get(url) do + size_limit = Config.get([:mrf_steal_emoji, :size_limit], @size_limit) + extension = get_extension_if_safe(response) - if byte_size(response.body) <= size_limit do - extension = - url - |> URI.parse() - |> Map.get(:path) - |> Path.basename() - |> Path.extname() - - file_path = Path.join(emoji_dir_path, shortcode <> (extension || ".png")) - - case File.write(file_path, response.body) do - :ok -> - shortcode - - e -> - Logger.warn("MRF.StealEmojiPolicy: Failed to write to #{file_path}: #{inspect(e)}") - nil - end + if byte_size(response.body) <= size_limit and extension do + steal_emoji(response, {shortcode, extension}) else Logger.debug( "MRF.StealEmojiPolicy: :#{shortcode}: at #{url} (#{byte_size(response.body)} B) over size limit (#{size_limit} B)" @@ -53,7 +136,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 @@ -65,26 +148,10 @@ def filter(%{"object" => %{"emoji" => foreign_emojis, "actor" => actor}} = messa if host != Pleroma.Web.Endpoint.host() and accept_host?(host) do installed_emoji = Pleroma.Emoji.get_all() |> Enum.map(fn {k, _} -> k end) - emoji_dir_path = - Config.get( - [:mrf_steal_emoji, :path], - Path.join(Config.get([:instance, :static_dir]), "emoji/stolen") - ) - - File.mkdir_p(emoji_dir_path) - new_emojis = foreign_emojis - |> Enum.reject(fn {shortcode, _url} -> shortcode in installed_emoji end) - |> Enum.filter(fn {shortcode, _url} -> - reject_emoji? = - [:mrf_steal_emoji, :rejected_shortcodes] - |> Config.get([]) - |> Enum.find(false, fn pattern -> shortcode_matches?(shortcode, pattern) end) - - !reject_emoji? - end) - |> Enum.map(&steal_emoji(&1, emoji_dir_path)) + |> Enum.reject(&reject_emoji?(&1, installed_emoji)) + |> Enum.map(&maybe_steal_emoji(&1)) |> Enum.filter(& &1) if !Enum.empty?(new_emojis) do diff --git a/lib/pleroma/web/activity_pub/mrf/tag_policy.ex b/lib/pleroma/web/activity_pub/mrf/tag_policy.ex index 56ae654f2..634b6a62f 100644 --- a/lib/pleroma/web/activity_pub/mrf/tag_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/tag_policy.ex @@ -27,22 +27,22 @@ defp get_tags(_), do: [] defp process_tag( "mrf_tag:media-force-nsfw", %{ - "type" => "Create", + "type" => type, "object" => %{"attachment" => child_attachment} } = message ) - when length(child_attachment) > 0 do + when length(child_attachment) > 0 and type in ["Create", "Update"] do {:ok, Kernel.put_in(message, ["object", "sensitive"], true)} end defp process_tag( "mrf_tag:media-strip", %{ - "type" => "Create", + "type" => type, "object" => %{"attachment" => child_attachment} = object } = message ) - when length(child_attachment) > 0 do + when length(child_attachment) > 0 and type in ["Create", "Update"] do object = Map.delete(object, "attachment") message = Map.put(message, "object", object) @@ -52,13 +52,14 @@ defp process_tag( defp process_tag( "mrf_tag:force-unlisted", %{ - "type" => "Create", + "type" => type, "to" => to, "cc" => cc, "actor" => actor, "object" => object } = message - ) do + ) + when type in ["Create", "Update"] do user = User.get_cached_by_ap_id(actor) if Enum.member?(to, Pleroma.Constants.as_public()) do @@ -85,13 +86,14 @@ defp process_tag( defp process_tag( "mrf_tag:sandbox", %{ - "type" => "Create", + "type" => type, "to" => to, "cc" => cc, "actor" => actor, "object" => object } = message - ) do + ) + when type in ["Create", "Update"] do user = User.get_cached_by_ap_id(actor) if Enum.member?(to, Pleroma.Constants.as_public()) or @@ -152,7 +154,7 @@ def filter(%{"object" => target_actor, "type" => "Follow"} = message), do: filter_message(target_actor, message) @impl true - def filter(%{"actor" => actor, "type" => "Create"} = message), + def filter(%{"actor" => actor, "type" => type} = message) when type in ["Create", "Update"], do: filter_message(actor, message) @impl true diff --git a/lib/pleroma/web/activity_pub/object_validators/accept_reject_validator.ex b/lib/pleroma/web/activity_pub/object_validators/accept_reject_validator.ex index 7c3c8d0fa..0561a9ddf 100644 --- a/lib/pleroma/web/activity_pub/object_validators/accept_reject_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/accept_reject_validator.ex @@ -6,6 +6,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AcceptRejectValidator do use Ecto.Schema alias Pleroma.Activity + alias Pleroma.User import Ecto.Changeset import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations @@ -29,7 +30,7 @@ def cast_data(data) do defp validate_data(cng) do cng - |> validate_required([:id, :type, :actor, :to, :cc, :object]) + |> validate_required([:type, :actor, :to, :cc, :object]) |> validate_inclusion(:type, ["Accept", "Reject"]) |> validate_actor_presence() |> validate_object_presence(allowed_types: ["Follow"]) @@ -38,6 +39,7 @@ defp validate_data(cng) do def cast_and_validate(data) do data + |> maybe_fetch_object() |> cast_data |> validate_data end @@ -53,4 +55,31 @@ def validate_accept_reject_rights(cng) do |> add_error(:actor, "can't accept or reject the given activity") end end + + defp maybe_fetch_object(%{"object" => %{} = object} = activity) do + # If we don't have an ID, we may have to fetch the object + if Map.has_key?(object, "id") do + # Do nothing + activity + else + Map.put(activity, "object", fetch_transient_object(object)) + end + end + + defp maybe_fetch_object(activity), do: activity + + defp fetch_transient_object( + %{"actor" => actor, "object" => target, "type" => "Follow"} = object + ) do + with %User{} = actor <- User.get_cached_by_ap_id(actor), + %User{local: true} = target <- User.get_cached_by_ap_id(target), + %Activity{} = activity <- Activity.follow_activity(actor, target) do + activity.data + else + _e -> + object + end + end + + defp fetch_transient_object(_), do: {:error, "not a supported transient object"} end 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 0d45421e2..d1cd496db 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 @@ -14,6 +14,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ArticleNotePageValidator do import Ecto.Changeset require Logger + require Pleroma.Web.ActivityPub.Transmogrifier @primary_key false @derive Jason.Encoder @@ -30,6 +31,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ArticleNotePageValidator do field(:replies, {:array, ObjectValidators.ObjectID}, default: []) field(:source, :map) + field(:contentMap, :map) end def cast_and_apply(data) do @@ -51,6 +53,13 @@ def cast_data(data) do defp fix_url(%{"url" => url} = data) when is_bitstring(url), do: data defp fix_url(%{"url" => url} = data) when is_map(url), do: Map.put(data, "url", url["href"]) + + defp fix_url(%{"url" => url} = data) when is_list(url) do + data + |> Map.put("url", List.first(url)) + |> fix_url() + end + defp fix_url(data), do: data defp fix_tag(%{"tag" => tag} = data) when is_list(tag) do @@ -103,9 +112,9 @@ defp remote_mention_resolver( end end - # https://github.com/misskey-dev/misskey/pull/8787 - # Misskey has an awful tendency to drop all custom formatting when it sends remotely - # So this basically reprocesses their MFM source + # See https://akkoma.dev/FoundKeyGang/FoundKey/issues/343 + # Misskey/Foundkey drops some of the custom formatting when it sends remotely + # So this basically reprocesses the MFM source defp fix_misskey_content( %{"source" => %{"mediaType" => "text/x.misskeymarkdown", "content" => content}} = object ) @@ -120,6 +129,8 @@ defp fix_misskey_content( Map.put(object, "content", linked) end + # See https://github.com/misskey-dev/misskey/pull/8787 + # This is for compatibility with older Misskey instances defp fix_misskey_content(%{"_misskey_content" => content} = object) when is_binary(content) do mention_handler = fn nick, buffer, opts, acc -> remote_mention_resolver(object, nick, buffer, opts, acc) @@ -146,6 +157,21 @@ defp fix_source(%{"source" => source} = object) when is_binary(source) do defp fix_source(object), do: object + defp fix_content_map_languages(%{"contentMap" => content_map} = object) + when is_map(content_map) do + # Only allow valid languages + content_map = + content_map + |> Enum.reject(fn {lang, _content} -> + !Pleroma.ISO639.valid_alpha2?(lang) + end) + |> Enum.into(%{}) + + Map.put(object, "contentMap", content_map) + end + + defp fix_content_map_languages(object), do: object + defp fix(data) do data |> CommonFixes.fix_actor() @@ -158,6 +184,7 @@ defp fix(data) do |> Transmogrifier.fix_attachments() |> Transmogrifier.fix_emoji() |> Transmogrifier.fix_content_map() + |> fix_content_map_languages() end def changeset(struct, data) do 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 6fa2bbb99..be7df3fb0 100644 --- a/lib/pleroma/web/activity_pub/object_validators/common_fixes.ex +++ b/lib/pleroma/web/activity_pub/object_validators/common_fixes.ex @@ -22,7 +22,10 @@ def cast_and_filter_recipients(message, field, follower_collection, field_fallba end def fix_object_defaults(data) do - context = Utils.maybe_create_context(data["context"] || data["conversation"]) + context = + Utils.maybe_create_context( + data["context"] || data["conversation"] || data["inReplyTo"] || data["id"] + ) %User{follower_address: follower_collection} = User.get_cached_by_ap_id(data["attributedTo"]) 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 6109a0355..bda67feee 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 @@ -8,12 +8,12 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.EmojiReactValidator do alias Pleroma.Emoji alias Pleroma.Object alias Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes + alias Pleroma.Web.ActivityPub.Transmogrifier import Ecto.Changeset import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations @primary_key false - @emoji_regex ~r/:[A-Za-z0-9_-]+(@.+)?:/ embedded_schema do quote do @@ -53,6 +53,7 @@ def changeset(struct, data) do defp fix(data) do data = data + |> Transmogrifier.fix_tag() |> fix_emoji_qualification() |> CommonFixes.fix_actor() |> CommonFixes.fix_activity_addressing() @@ -75,9 +76,6 @@ defp fix(data) do end end - defp matches_shortcode?(nil), do: false - defp matches_shortcode?(s), do: Regex.match?(@emoji_regex, s) - defp fix_emoji_qualification(%{"content" => emoji} = data) do new_emoji = Pleroma.Emoji.fully_qualify_emoji(emoji) @@ -98,7 +96,7 @@ defp fix_emoji_qualification(data), do: data defp validate_emoji(cng) do content = get_field(cng, :content) - if Emoji.is_unicode_emoji?(content) || matches_shortcode?(content) do + if Emoji.is_unicode_emoji?(content) || Emoji.matches_shortcode?(content) do cng else cng diff --git a/lib/pleroma/web/activity_pub/object_validators/user_validator.ex b/lib/pleroma/web/activity_pub/object_validators/user_validator.ex new file mode 100644 index 000000000..90b5404f3 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/user_validator.ex @@ -0,0 +1,92 @@ +# Akkoma: Magically expressive social media +# Copyright © 2024 Akkoma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.UserValidator do + @moduledoc """ + Checks whether ActivityPub data represents a valid user + + Users don't go through the same ingest pipeline like activities or other objects. + To ensure this can only match a user and no users match in the other pipeline, + this is a separate from the generic ObjectValidator. + """ + + @behaviour Pleroma.Web.ActivityPub.ObjectValidator.Validating + + alias Pleroma.Object.Containment + alias Pleroma.Signature + + @impl true + def validate(object, meta) + + def validate(%{"type" => type, "id" => _id} = data, meta) + when type in ["Person", "Organization", "Group", "Application"] do + with :ok <- validate_pubkey(data), + :ok <- validate_inbox(data), + :ok <- contain_collection_origin(data) do + {:ok, data, meta} + else + {:error, e} -> {:error, e} + e -> {:error, e} + end + end + + def validate(_, _), do: {:error, "Not a user object"} + + defp mabye_validate_owner(nil, _actor), do: :ok + defp mabye_validate_owner(actor, actor), do: :ok + defp mabye_validate_owner(_owner, _actor), do: :error + + defp validate_pubkey( + %{"id" => id, "publicKey" => %{"id" => pk_id, "publicKeyPem" => _key}} = data + ) + when id != nil do + with {_, {:ok, kactor}} <- {:key, Signature.key_id_to_actor_id(pk_id)}, + true <- id == kactor, + :ok <- mabye_validate_owner(Map.get(data, "owner"), id) do + :ok + else + {:key, _} -> + {:error, "Unable to determine actor id from key id"} + + false -> + {:error, "Key id does not relate to user id"} + + _ -> + {:error, "Actor does not own its public key"} + end + end + + # pubkey is optional atm + defp validate_pubkey(_data), do: :ok + + defp validate_inbox(%{"id" => id, "inbox" => inbox}) do + case Containment.same_origin(id, inbox) do + :ok -> :ok + :error -> {:error, "Inbox on different doamin"} + end + end + + defp validate_inbox(_), do: {:error, "No inbox"} + + defp check_field_value(%{"id" => id} = _data, value) do + Containment.same_origin(id, value) + end + + defp maybe_check_field(data, field) do + with val when val != nil <- data[field], + :ok <- check_field_value(data, val) do + :ok + else + nil -> :ok + _ -> {:error, "#{field} on different domain"} + end + end + + defp contain_collection_origin(data) do + Enum.reduce(["followers", "following", "featured"], :ok, fn + field, :ok -> maybe_check_field(data, field) + _, error -> error + end) + end +end diff --git a/lib/pleroma/web/activity_pub/publisher.ex b/lib/pleroma/web/activity_pub/publisher.ex index b187d3a48..4fe394be6 100644 --- a/lib/pleroma/web/activity_pub/publisher.ex +++ b/lib/pleroma/web/activity_pub/publisher.ex @@ -108,15 +108,33 @@ defp blocked_instances do Config.get([:mrf_simple, :reject], []) end + defp allowed_instances do + Config.get([:mrf_simple, :accept]) + end + def should_federate?(url) do %{host: host} = URI.parse(url) - quarantined_instances = - blocked_instances() + with {nil, false} <- {nil, is_nil(host)}, + allowed <- allowed_instances(), + false <- Enum.empty?(allowed) do + allowed |> Pleroma.Web.ActivityPub.MRF.instance_list_from_tuples() |> Pleroma.Web.ActivityPub.MRF.subdomains_regex() + |> Pleroma.Web.ActivityPub.MRF.subdomain_match?(host) + else + # oi! + {nil, true} -> + false - !Pleroma.Web.ActivityPub.MRF.subdomain_match?(quarantined_instances, host) + _ -> + quarantined_instances = + blocked_instances() + |> Pleroma.Web.ActivityPub.MRF.instance_list_from_tuples() + |> Pleroma.Web.ActivityPub.MRF.subdomains_regex() + + not Pleroma.Web.ActivityPub.MRF.subdomain_match?(quarantined_instances, host) + end end @spec recipients(User.t(), Activity.t()) :: list(User.t()) | [] diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index c3258c75b..963c6d8c6 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -109,7 +109,7 @@ def handle( %User{} = followed <- User.get_cached_by_ap_id(followed_user), {_, {:ok, _, _}, _, _} <- {:following, User.follow(follower, followed, :follow_pending), follower, followed} do - if followed.local && !followed.is_locked do + if followed.local && User.can_direct_follow_local(follower, followed) do {:ok, accept_data, _} = Builder.accept(followed, object) {:ok, _activity, _} = Pipeline.common_pipeline(accept_data, local: true) end @@ -192,6 +192,7 @@ def handle(%{data: %{"type" => "Like"}} = object, meta) do # - Increase the user note count # - Increase the reply count # - Increase replies count + # - Ask for scraping of nodeinfo # - Set up ActivityExpiration # - Set up notifications # - Index incoming posts for search (if needed) @@ -209,6 +210,10 @@ def handle(%{data: %{"type" => "Create"}} = activity, meta) do reply_depth = (meta[:depth] || 0) + 1 + Pleroma.Workers.NodeInfoFetcherWorker.enqueue("process", %{ + "source_url" => activity.data["actor"] + }) + # FIXME: Force inReplyTo to replies if Pleroma.Web.Federator.allowed_thread_distance?(reply_depth) and object.data["replies"] != nil do @@ -234,7 +239,9 @@ def handle(%{data: %{"type" => "Create"}} = activity, meta) do {:ok, activity, meta} else - e -> Repo.rollback(e) + e -> + Logger.error(inspect(e)) + Repo.rollback(e) end end @@ -281,7 +288,6 @@ def handle(%{data: %{"type" => "EmojiReact"}} = object, meta) do # Tasks this handles: # - Delete and unpins the create activity - # - Replace object with Tombstone # - Set up notification # - Reduce the user note count # - Reduce the reply count diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index b9d853610..a72a431b2 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -136,7 +136,7 @@ def fix_in_reply_to(%{"inReplyTo" => in_reply_to} = object, options) |> Map.drop(["conversation", "inReplyToAtomUri"]) else e -> - Logger.warn("Couldn't fetch #{inspect(in_reply_to_id)}, error: #{inspect(e)}") + Logger.warning("Couldn't fetch reply@#{inspect(in_reply_to_id)}, error: #{inspect(e)}") object end else @@ -159,7 +159,7 @@ def fix_quote_url(%{"quoteUri" => quote_url} = object, options) |> Map.put("quoteUri", quoted_object.data["id"]) else e -> - Logger.warn("Couldn't fetch #{inspect(quote_url)}, error: #{inspect(e)}") + Logger.warning("Couldn't fetch quote@#{inspect(quote_url)}, error: #{inspect(e)}") object end else @@ -346,11 +346,16 @@ def fix_tag(%{"tag" => %{} = tag} = object) do def fix_tag(object), do: object # content map usually only has one language so this will do for now. - def fix_content_map(%{"contentMap" => content_map} = object) do + def fix_content_map(%{"contentMap" => content_map} = object) when is_map(content_map) do content_groups = Map.to_list(content_map) - {_, content} = Enum.at(content_groups, 0) - Map.put(object, "content", content) + if Enum.empty?(content_groups) do + object + else + {_, content} = Enum.at(content_groups, 0) + + Map.put(object, "content", content) + end end def fix_content_map(object), do: object @@ -414,28 +419,19 @@ def handle_incoming(%{"id" => id}, _options) when is_binary(id) and byte_size(id def handle_incoming( %{ "type" => "Like", - "_misskey_reaction" => reaction, - "tag" => _ + "content" => reaction } = data, options ) do - data - |> Map.put("type", "EmojiReact") - |> Map.put("content", reaction) - |> handle_incoming(options) - end - - def handle_incoming( - %{ - "type" => "Like", - "_misskey_reaction" => reaction - } = data, - options - ) do - data - |> Map.put("type", "EmojiReact") - |> Map.put("content", reaction) - |> handle_incoming(options) + if Pleroma.Emoji.is_unicode_emoji?(reaction) || Pleroma.Emoji.matches_shortcode?(reaction) do + data + |> Map.put("type", "EmojiReact") + |> handle_incoming(options) + else + data + |> Map.delete("content") + |> handle_incoming(options) + end end def handle_incoming( @@ -580,7 +576,12 @@ def handle_incoming( _options ) do with %User{} = origin_user <- User.get_cached_by_ap_id(origin_actor), - {:ok, %User{} = target_user} <- User.get_or_fetch_by_ap_id(target_actor), + # Use a dramatically shortened maximum age before refresh here because it is reasonable + # for a user to + # 1. Add the alias to their new account and then + # 2. Press the button on their new account + # within a very short period of time and expect it to work + {:ok, %User{} = target_user} <- User.get_or_fetch_by_ap_id(target_actor, maximum_age: 5), true <- origin_actor in target_user.also_known_as do ActivityPub.move(origin_user, target_user, false) else @@ -833,7 +834,7 @@ def maybe_fix_object_url(%{"object" => object} = data) when is_binary(object) do Map.put(data, "object", external_url) else {:fetch, e} -> - Logger.error("Couldn't fetch #{object} #{inspect(e)}") + Logger.error("Couldn't fetch fixed_object@#{object} #{inspect(e)}") data _ -> @@ -924,8 +925,13 @@ def add_attributed_to(object) do def prepare_attachments(object) do attachments = - object - |> Map.get("attachment", []) + case Map.get(object, "attachment", []) do + [_ | _] = list -> list + _ -> [] + end + + attachments = + attachments |> Enum.map(fn data -> [%{"mediaType" => media_type, "href" => href} = url | _] = data["url"] diff --git a/lib/pleroma/web/activity_pub/utils.ex b/lib/pleroma/web/activity_pub/utils.ex index 008aec475..f731b5286 100644 --- a/lib/pleroma/web/activity_pub/utils.ex +++ b/lib/pleroma/web/activity_pub/utils.ex @@ -16,10 +16,11 @@ defmodule Pleroma.Web.ActivityPub.Utils do alias Pleroma.Web.ActivityPub.Visibility alias Pleroma.Web.AdminAPI.AccountView alias Pleroma.Web.Endpoint - alias Pleroma.Web.Router.Helpers import Ecto.Query + use Pleroma.Web, :verified_routes + require Logger require Pleroma.Constants @@ -124,19 +125,15 @@ def make_date do end def generate_activity_id do - generate_id("activities") + url(~p[/activities/#{UUID.generate()}]) end def generate_context_id do - generate_id("contexts") + url(~p[/contexts/#{UUID.generate()}]) end def generate_object_id do - Helpers.o_status_url(Endpoint, :object, UUID.generate()) - end - - def generate_id(type) do - "#{Endpoint.url()}/#{type}/#{UUID.generate()}" + url(~p[/objects/#{UUID.generate()}]) end def get_notified_from_object(%{"type" => type} = object) when type in @supported_object_types do @@ -154,7 +151,7 @@ def get_notified_from_object(object) do Notification.get_notified_from_activity(%Activity{data: object}, false) end - def maybe_create_context(context), do: context || generate_id("contexts") + def maybe_create_context(context), do: context || generate_context_id() @doc """ Enqueues an activity for federation if it's local diff --git a/lib/pleroma/web/activity_pub/views/user_view.ex b/lib/pleroma/web/activity_pub/views/user_view.ex index 310f3ce3e..fe70022f1 100644 --- a/lib/pleroma/web/activity_pub/views/user_view.ex +++ b/lib/pleroma/web/activity_pub/views/user_view.ex @@ -12,22 +12,22 @@ defmodule Pleroma.Web.ActivityPub.UserView do alias Pleroma.Web.ActivityPub.ObjectView alias Pleroma.Web.ActivityPub.Transmogrifier alias Pleroma.Web.ActivityPub.Utils - alias Pleroma.Web.Endpoint - alias Pleroma.Web.Router.Helpers + + require Pleroma.Web.ActivityPub.Transmogrifier import Ecto.Query def render("endpoints.json", %{user: %User{nickname: nil, local: true} = _user}) do - %{"sharedInbox" => Helpers.activity_pub_url(Endpoint, :inbox)} + %{"sharedInbox" => url(~p"/inbox")} end def render("endpoints.json", %{user: %User{local: true} = _user}) do %{ - "oauthAuthorizationEndpoint" => Helpers.o_auth_url(Endpoint, :authorize), - "oauthRegistrationEndpoint" => Helpers.app_url(Endpoint, :create), - "oauthTokenEndpoint" => Helpers.o_auth_url(Endpoint, :token_exchange), - "sharedInbox" => Helpers.activity_pub_url(Endpoint, :inbox), - "uploadMedia" => Helpers.activity_pub_url(Endpoint, :upload_media) + "oauthAuthorizationEndpoint" => url(~p"/oauth/authorize"), + "oauthRegistrationEndpoint" => url(~p"/api/v1/apps"), + "oauthTokenEndpoint" => url(~p"/oauth/token"), + "sharedInbox" => url(~p"/inbox"), + "uploadMedia" => url(~p"/api/ap/upload_media") } end @@ -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.", @@ -111,6 +112,8 @@ def render("user.json", %{user: user}) do } |> Map.merge(maybe_make_image(&User.avatar_url/2, "icon", user)) |> Map.merge(maybe_make_image(&User.banner_url/2, "image", user)) + # Yes, the key is named ...Url eventhough it is a whole 'Image' object + |> Map.merge(maybe_insert_image("backgroundUrl", User.background_url(user))) |> Map.merge(Utils.make_json_ld_header()) end @@ -286,7 +289,12 @@ def collection(collection, iri, page, show_items \\ true, total \\ nil) do end defp maybe_make_image(func, key, user) do - if image = func.(user, no_default: true) do + image = func.(user, no_default: true) + maybe_insert_image(key, image) + end + + defp maybe_insert_image(key, image) do + if image do %{ key => %{ "type" => "Image", diff --git a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex index 1d7ac78a0..7344e1f77 100644 --- a/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/admin_api_controller.ex @@ -17,9 +17,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do alias Pleroma.Web.AdminAPI alias Pleroma.Web.AdminAPI.AccountView alias Pleroma.Web.AdminAPI.ModerationLogView - alias Pleroma.Web.Endpoint alias Pleroma.Web.Plugs.OAuthScopesPlug - alias Pleroma.Web.Router @users_page_size 50 @@ -256,7 +254,7 @@ def get_password_reset(conn, %{"nickname" => nickname}) do conn |> json(%{ token: token.token, - link: Router.Helpers.reset_password_url(Endpoint, :reset, token.token) + link: url(~p[/api/v1/pleroma/password_reset/#{token.token}]) }) end diff --git a/lib/pleroma/web/admin_api/views/status_view.ex b/lib/pleroma/web/admin_api/views/status_view.ex index 48d639b41..a252b047c 100644 --- a/lib/pleroma/web/admin_api/views/status_view.ex +++ b/lib/pleroma/web/admin_api/views/status_view.ex @@ -14,11 +14,11 @@ defmodule Pleroma.Web.AdminAPI.StatusView do defdelegate merge_account_views(user), to: AdminAPI.AccountView def render("index.json", %{total: total} = opts) do - %{total: total, activities: safe_render_many(opts.activities, __MODULE__, "show.json", opts)} + %{total: total, activities: render_many(opts.activities, __MODULE__, "show.json", opts)} end def render("index.json", opts) do - safe_render_many(opts.activities, __MODULE__, "show.json", opts) + render_many(opts.activities, __MODULE__, "show.json", opts) end def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do diff --git a/lib/pleroma/web/akkoma_api/controllers/frontend_settings_controller.ex b/lib/pleroma/web/akkoma_api/controllers/frontend_settings_controller.ex index c13ff9096..307d35643 100644 --- a/lib/pleroma/web/akkoma_api/controllers/frontend_settings_controller.ex +++ b/lib/pleroma/web/akkoma_api/controllers/frontend_settings_controller.ex @@ -5,6 +5,16 @@ defmodule Pleroma.Web.AkkomaAPI.FrontendSettingsController do alias Pleroma.Akkoma.FrontendSettingsProfile @unauthenticated_access %{fallback: :proceed_unauthenticated, scopes: []} + + plug( + OAuthScopesPlug, + @unauthenticated_access + when action in [ + :available_frontends, + :update_preferred_frontend + ] + ) + plug( OAuthScopesPlug, %{@unauthenticated_access | scopes: ["read:accounts"]} @@ -93,4 +103,22 @@ def update_profile(%{body_params: %{settings: settings, version: version}} = con |> json(profile.settings) end end + + @doc "GET /api/v1/akkoma/preferred_frontend/available" + def available_frontends(conn, _params) do + available = Pleroma.Config.get([:frontends, :pickable]) + + conn + |> json(available) + end + + @doc "PUT /api/v1/akkoma/preferred_frontend" + def update_preferred_frontend( + %{body_params: %{frontend_name: preferred_frontend}} = conn, + _params + ) do + conn + |> put_resp_cookie("preferred_frontend", preferred_frontend) + |> json(%{frontend_name: preferred_frontend}) + end end diff --git a/lib/pleroma/web/akkoma_api/controllers/frontend_switcher.ex b/lib/pleroma/web/akkoma_api/controllers/frontend_switcher.ex new file mode 100644 index 000000000..2095db4b5 --- /dev/null +++ b/lib/pleroma/web/akkoma_api/controllers/frontend_switcher.ex @@ -0,0 +1,20 @@ +defmodule Pleroma.Web.AkkomaAPI.FrontendSwitcherController do + use Pleroma.Web, :controller + alias Pleroma.Config + + @doc "GET /akkoma/frontend" + def switch(conn, _params) do + pickable = Config.get([:frontends, :pickable], []) + + conn + |> put_view(Pleroma.Web.AkkomaAPI.FrontendSwitcherView) + |> render("switch.html", choices: pickable) + end + + @doc "POST /akkoma/frontend" + def do_switch(conn, params) do + conn + |> put_resp_cookie("preferred_frontend", params["frontend"]) + |> html("") + end +end diff --git a/lib/pleroma/web/akkoma_api/controllers/metrics_controller.ex b/lib/pleroma/web/akkoma_api/controllers/metrics_controller.ex new file mode 100644 index 000000000..ab52cb64d --- /dev/null +++ b/lib/pleroma/web/akkoma_api/controllers/metrics_controller.ex @@ -0,0 +1,24 @@ +defmodule Pleroma.Web.AkkomaAPI.MetricsController do + use Pleroma.Web, :controller + + alias Pleroma.Web.Plugs.OAuthScopesPlug + alias Pleroma.Config + + plug( + OAuthScopesPlug, + %{scopes: ["admin:metrics"]} + when action in [ + :show + ] + ) + + def show(conn, _params) do + if Config.get([:instance, :export_prometheus_metrics], true) do + conn + |> text(Pleroma.PrometheusExporter.show()) + else + conn + |> send_resp(404, "Not Found") + end + end +end diff --git a/lib/pleroma/web/akkoma_api/controllers/translation_controller.ex b/lib/pleroma/web/akkoma_api/controllers/translation_controller.ex index 9983a7e39..022da3198 100644 --- a/lib/pleroma/web/akkoma_api/controllers/translation_controller.ex +++ b/lib/pleroma/web/akkoma_api/controllers/translation_controller.ex @@ -3,6 +3,8 @@ defmodule Pleroma.Web.AkkomaAPI.TranslationController do alias Pleroma.Web.Plugs.OAuthScopesPlug + require Logger + @cachex Pleroma.Config.get([:cachex, :provider], Cachex) @unauthenticated_access %{fallback: :proceed_unauthenticated, scopes: []} @@ -21,11 +23,17 @@ defmodule Pleroma.Web.AkkomaAPI.TranslationController do @doc "GET /api/v1/akkoma/translation/languages" def languages(conn, _params) do - with {:ok, source_languages, dest_languages} <- get_languages() do + with {:enabled, true} <- {:enabled, Pleroma.Config.get([:translator, :enabled])}, + {:ok, source_languages, dest_languages} <- get_languages() do conn |> json(%{source: source_languages, target: dest_languages}) else - e -> IO.inspect(e) + {:enabled, false} -> + json(conn, %{}) + + e -> + Logger.error("Translation language list error: #{inspect(e)}") + {:error, e} end end diff --git a/lib/pleroma/web/akkoma_api/views/frontend_switcher.ex b/lib/pleroma/web/akkoma_api/views/frontend_switcher.ex new file mode 100644 index 000000000..1564c9e44 --- /dev/null +++ b/lib/pleroma/web/akkoma_api/views/frontend_switcher.ex @@ -0,0 +1,3 @@ +defmodule Pleroma.Web.AkkomaAPI.FrontendSwitcherView do + use Pleroma.Web, :view +end diff --git a/lib/pleroma/web/api_spec.ex b/lib/pleroma/web/api_spec.ex index 8ac5c8b94..26fed1eef 100644 --- a/lib/pleroma/web/api_spec.ex +++ b/lib/pleroma/web/api_spec.ex @@ -23,19 +23,19 @@ def spec(opts \\ []) do [] end, info: %OpenApiSpex.Info{ - title: "Pleroma API", + title: "Akkoma API", description: """ - This is documentation for client Pleroma API. Most of the endpoints and entities come + This is documentation for the Akkoma API. Most of the endpoints and entities come from Mastodon API and have custom extensions on top. - While this document aims to be a complete guide to the client API Pleroma exposes, - the details are still being worked out. Some endpoints may have incomplete or poorly worded documentation. + While this document aims to be a complete guide to the client API Akkoma exposes, + it may not be complete. Some endpoints may have incomplete or poorly worded documentation. You might want to check the following resources if something is not clear: - [Legacy Pleroma-specific endpoint documentation](https://docs-develop.pleroma.social/backend/development/API/pleroma_api/) - [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/) + - [Differences in Mastodon API responses from vanilla Mastodon](https://docs.akkoma.dev/stable/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://akkoma.dev/AkkomaGang/akkoma). 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/\+.*$/, ""), diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index b305dc1ea..c15a246f2 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -64,7 +64,8 @@ def update_credentials_operation do requestBody: request_body("Parameters", update_credentials_request(), required: true), responses: %{ 200 => Operation.response("Account", "application/json", Account), - 403 => Operation.response("Error", "application/json", ApiError) + 403 => Operation.response("Error", "application/json", ApiError), + 413 => Operation.response("Error", "application/json", ApiError) } } end @@ -223,12 +224,12 @@ def follow_operation do type: :object, properties: %{ reblogs: %Schema{ - type: :boolean, + allOf: [BooleanLike], description: "Receive this account's reblogs in home timeline? Defaults to true.", default: true }, notify: %Schema{ - type: :boolean, + allOf: [BooleanLike], description: "Receive notifications for all statuses posted by the account? Defaults to false.", default: false @@ -409,7 +410,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()) } @@ -431,6 +432,7 @@ def lookup_operation do ], responses: %{ 200 => Operation.response("Account", "application/json", Account), + 401 => Operation.response("Error", "application/json", ApiError), 404 => Operation.response("Error", "application/json", ApiError) } } @@ -449,6 +451,20 @@ def endorsements_operation do } end + def preferences_operation do + %Operation{ + tags: ["Account Preferences"], + description: "Preferences defined by the user in their account settings.", + summary: "Preferred common behaviors to be shared across clients.", + operationId: "AccountController.preferences", + security: [%{"oAuth" => ["read:accounts"]}], + responses: %{ + 200 => Operation.response("Preferences", "application/json", Account), + 401 => Operation.response("Error", "application/json", ApiError) + } + } + end + def identity_proofs_operation do %Operation{ tags: ["Retrieve account information"], @@ -700,7 +716,29 @@ defp update_credentials_request do description: "Discovery (listing, indexing) of this account by external services (search bots etc.) is allowed." }, - actor_type: ActorType + actor_type: ActorType, + status_ttl_days: %Schema{ + type: :integer, + nullable: true, + description: + "Number of days after which statuses will be deleted. Set to -1 to disable." + }, + permit_followback: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: + "Whether follow requests from accounts the user is already following are auto-approved (when locked)." + }, + accepts_direct_messages_from: %Schema{ + type: :string, + enum: [ + "everybody", + "nobody", + "people_i_follow" + ], + nullable: true, + description: "Who to accept DMs from" + } }, example: %{ bot: false, @@ -720,7 +758,10 @@ defp update_credentials_request do allow_following_move: false, also_known_as: ["https://foo.bar/users/foo"], discoverable: false, - actor_type: "Person" + actor_type: "Person", + status_ttl_days: 30, + permit_followback: true, + accepts_direct_messages_from: "everybody" } } end @@ -747,7 +788,7 @@ defp array_of_relationships do "showing_reblogs" => true, "followed_by" => true, "blocking" => false, - "blocked_by" => true, + "blocked_by" => false, "muting" => false, "muting_notifications" => false, "note" => "", @@ -763,7 +804,7 @@ defp array_of_relationships do "showing_reblogs" => true, "followed_by" => true, "blocking" => false, - "blocked_by" => true, + "blocked_by" => false, "muting" => true, "muting_notifications" => false, "note" => "", diff --git a/lib/pleroma/web/api_spec/operations/admin/status_operation.ex b/lib/pleroma/web/api_spec/operations/admin/status_operation.ex index d25ab5247..9290ab09d 100644 --- a/lib/pleroma/web/api_spec/operations/admin/status_operation.ex +++ b/lib/pleroma/web/api_spec/operations/admin/status_operation.ex @@ -143,7 +143,7 @@ def admin_account do } }, tags: %Schema{type: :string}, - is_confirmed: %Schema{type: :string} + is_confirmed: %Schema{type: :boolean} } } end diff --git a/lib/pleroma/web/api_spec/operations/filter_operation.ex b/lib/pleroma/web/api_spec/operations/filter_operation.ex index 5102921bc..ac0444aef 100644 --- a/lib/pleroma/web/api_spec/operations/filter_operation.ex +++ b/lib/pleroma/web/api_spec/operations/filter_operation.ex @@ -225,6 +225,12 @@ defp update_request do type: :integer, description: "Number of seconds from now the filter should expire. Otherwise, null for a filter that doesn't expire." + }, + expires_at: %Schema{ + nullable: true, + type: :string, + description: + "When the filter should no longer be applied. String (ISO 8601 Datetime), or null if the filter does not expire." } }, required: [:phrase, :context], diff --git a/lib/pleroma/web/api_spec/operations/follow_request_operation.ex b/lib/pleroma/web/api_spec/operations/follow_request_operation.ex index 784019699..d6f59191b 100644 --- a/lib/pleroma/web/api_spec/operations/follow_request_operation.ex +++ b/lib/pleroma/web/api_spec/operations/follow_request_operation.ex @@ -19,6 +19,7 @@ def index_operation do summary: "Retrieve follow requests", security: [%{"oAuth" => ["read:follows", "follow"]}], operationId: "FollowRequestController.index", + parameters: pagination_params(), responses: %{ 200 => Operation.response("Array of Account", "application/json", %Schema{ @@ -62,4 +63,22 @@ defp id_param do required: true ) end + + defp pagination_params do + [ + Operation.parameter(:max_id, :query, :string, "Return items older than this ID"), + Operation.parameter( + :since_id, + :query, + :string, + "Return the oldest items newer than this ID" + ), + Operation.parameter( + :limit, + :query, + %Schema{type: :integer, default: 20}, + "Maximum number of items to return. Will be ignored if it's more than 40" + ) + ] + end end diff --git a/lib/pleroma/web/api_spec/operations/frontend_settings_operation.ex b/lib/pleroma/web/api_spec/operations/frontend_settings_operation.ex index 40e81ad55..ede66709c 100644 --- a/lib/pleroma/web/api_spec/operations/frontend_settings_operation.ex +++ b/lib/pleroma/web/api_spec/operations/frontend_settings_operation.ex @@ -12,7 +12,7 @@ def open_api_operation(action) do @spec list_profiles_operation() :: Operation.t() def list_profiles_operation() do %Operation{ - tags: ["Retrieve frontend setting profiles"], + tags: ["Frontends"], summary: "Frontend Settings Profiles", description: "List frontend setting profiles", operationId: "AkkomaAPI.FrontendSettingsController.list_profiles", @@ -37,7 +37,7 @@ def list_profiles_operation() do @spec get_profile_operation() :: Operation.t() def get_profile_operation() do %Operation{ - tags: ["Retrieve frontend setting profile"], + tags: ["Frontends"], summary: "Frontend Settings Profile", description: "Get frontend setting profile", operationId: "AkkomaAPI.FrontendSettingsController.get_profile", @@ -60,7 +60,7 @@ def get_profile_operation() do @spec delete_profile_operation() :: Operation.t() def delete_profile_operation() do %Operation{ - tags: ["Delete frontend setting profile"], + tags: ["Frontends"], summary: "Delete frontend Settings Profile", description: "Delete frontend setting profile", operationId: "AkkomaAPI.FrontendSettingsController.delete_profile", @@ -76,7 +76,7 @@ def delete_profile_operation() do @spec update_profile_operation() :: Operation.t() def update_profile_operation() do %Operation{ - tags: ["Update frontend setting profile"], + tags: ["Frontends"], summary: "Frontend Settings Profile", description: "Update frontend setting profile", operationId: "AkkomaAPI.FrontendSettingsController.update_profile_operation", @@ -90,6 +90,59 @@ def update_profile_operation() do } end + def available_frontends_operation() do + %Operation{ + tags: ["Frontends"], + summary: "Frontend Settings Profiles", + description: "List frontend setting profiles", + operationId: "AkkomaAPI.FrontendSettingsController.available_frontends", + responses: %{ + 200 => + Operation.response("Frontends", "application/json", %Schema{ + type: :array, + items: %Schema{ + type: :string + } + }) + } + } + end + + def update_preferred_frontend_operation() do + %Operation{ + tags: ["Frontends"], + summary: "Update preferred frontend setting", + description: "Store preferred frontend in cookies", + operationId: "AkkomaAPI.FrontendSettingsController.update_preferred_frontend", + requestBody: + request_body( + "Frontend", + %Schema{ + type: :object, + required: [:frontend_name], + properties: %{ + frontend_name: %Schema{ + type: :string, + description: "Frontend name" + } + } + }, + required: true + ), + responses: %{ + 200 => + Operation.response("Frontends", "application/json", %Schema{ + type: :object, + properties: %{ + frontend_name: %Schema{ + type: :string + } + } + }) + } + } + end + def frontend_name_param do Operation.parameter(:frontend_name, :path, :string, "Frontend name", example: "pleroma-fe", diff --git a/lib/pleroma/web/api_spec/operations/instance_operation.ex b/lib/pleroma/web/api_spec/operations/instance_operation.ex index 9384acc32..c72fee197 100644 --- a/lib/pleroma/web/api_spec/operations/instance_operation.ex +++ b/lib/pleroma/web/api_spec/operations/instance_operation.ex @@ -137,7 +137,7 @@ defp instance do "background_upload_limit" => 4_000_000, "background_image" => "/static/image.png", "banner_upload_limit" => 4_000_000, - "description" => "Pleroma: An efficient and flexible fediverse server", + "description" => "Akkoma: The cooler fediverse server", "email" => "lain@lain.com", "languages" => ["en"], "max_toot_chars" => 5000, @@ -160,7 +160,7 @@ defp instance do "urls" => %{ "streaming_api" => "wss://lain.com" }, - "version" => "2.7.2 (compatible; Pleroma 2.0.50-536-g25eec6d7-develop)" + "version" => "2.7.2 (compatible; Akkoma 3.9.3-232-g6fde75e1-develop)" } } end diff --git a/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex index 49247d9b6..c38349486 100644 --- a/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex +++ b/lib/pleroma/web/api_spec/operations/pleroma_emoji_pack_operation.ex @@ -231,9 +231,18 @@ defp emoji_packs_response do "application/json", %Schema{ type: :object, - additionalProperties: emoji_pack(), + properties: %{ + count: %Schema{type: :integer}, + packs: %Schema{ + type: :object, + additionalProperties: emoji_pack() + } + }, example: %{ - "emojos" => emoji_pack().example + "count" => 4, + "packs" => %{ + "emojos" => emoji_pack().example + } } } ) diff --git a/lib/pleroma/web/api_spec/operations/tag_operation.ex b/lib/pleroma/web/api_spec/operations/tag_operation.ex new file mode 100644 index 000000000..ce4f4ad5b --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/tag_operation.ex @@ -0,0 +1,103 @@ +defmodule Pleroma.Web.ApiSpec.TagOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.Tag + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def show_operation do + %Operation{ + tags: ["Tags"], + summary: "Hashtag", + description: "View a hashtag", + security: [%{"oAuth" => ["read"]}], + parameters: [id_param()], + operationId: "TagController.show", + responses: %{ + 200 => Operation.response("Hashtag", "application/json", Tag), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def follow_operation do + %Operation{ + tags: ["Tags"], + summary: "Follow a hashtag", + description: "Follow a hashtag", + security: [%{"oAuth" => ["write:follows"]}], + parameters: [id_param()], + operationId: "TagController.follow", + responses: %{ + 200 => Operation.response("Hashtag", "application/json", Tag), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def unfollow_operation do + %Operation{ + tags: ["Tags"], + summary: "Unfollow a hashtag", + description: "Unfollow a hashtag", + security: [%{"oAuth" => ["write:follows"]}], + parameters: [id_param()], + operationId: "TagController.unfollow", + responses: %{ + 200 => Operation.response("Hashtag", "application/json", Tag), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def show_followed_operation do + %Operation{ + tags: ["Tags"], + summary: "Followed hashtags", + description: "View a list of hashtags the currently authenticated user is following", + parameters: pagination_params(), + security: [%{"oAuth" => ["read:follows"]}], + operationId: "TagController.show_followed", + responses: %{ + 200 => + Operation.response("Hashtags", "application/json", %Schema{ + type: :array, + items: Tag + }), + 403 => Operation.response("Forbidden", "application/json", ApiError), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + defp id_param do + Operation.parameter( + :id, + :path, + %Schema{type: :string}, + "Name of the hashtag" + ) + end + + def pagination_params do + [ + Operation.parameter(:max_id, :query, :integer, "Return items older than this ID"), + Operation.parameter( + :min_id, + :query, + :integer, + "Return the oldest items newer than this ID" + ), + Operation.parameter( + :limit, + :query, + %Schema{type: :integer, default: 20}, + "Maximum number of items to return. Will be ignored if it's more than 40" + ) + ] + 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 3eb6f700b..45c97cab6 100644 --- a/lib/pleroma/web/api_spec/operations/timeline_operation.ex +++ b/lib/pleroma/web/api_spec/operations/timeline_operation.ex @@ -70,7 +70,8 @@ def public_operation do operationId: "TimelineController.public", responses: %{ 200 => Operation.response("Array of Status", "application/json", array_of_statuses()), - 401 => Operation.response("Error", "application/json", ApiError) + 401 => Operation.response("Error", "application/json", ApiError), + 404 => Operation.response("Error", "application/json", ApiError) } } end 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 c025867a2..0f202201d 100644 --- a/lib/pleroma/web/api_spec/operations/twitter_util_operation.ex +++ b/lib/pleroma/web/api_spec/operations/twitter_util_operation.ex @@ -81,7 +81,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: %{ @@ -150,7 +150,7 @@ def update_notificaton_settings_operation do "removes the contents of a message from the push notification" ) ], - requestBody: nil, + requestBody: request_body("Parameters", update_notification_settings_request()), responses: %{ 200 => Operation.response("Success", "application/json", %Schema{ @@ -432,4 +432,22 @@ defp delete_account_request do } } end + + defp update_notification_settings_request do + %Schema{ + title: "UpdateNotificationSettings", + description: "PUT paramenters (query, form or JSON) for updating notification settings", + type: :object, + properties: %{ + block_from_strangers: %Schema{ + type: :boolean, + description: "blocks notifications from accounts you do not follow" + }, + hide_notification_contents: %Schema{ + type: :boolean, + description: "removes the contents of a message from the push notification" + } + } + } + end end diff --git a/lib/pleroma/web/api_spec/schemas/account.ex b/lib/pleroma/web/api_spec/schemas/account.ex index 5d3ac9cd0..a922f6d1b 100644 --- a/lib/pleroma/web/api_spec/schemas/account.ex +++ b/lib/pleroma/web/api_spec/schemas/account.ex @@ -109,6 +109,23 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Account do } } }, + akkoma: %Schema{ + type: :object, + properties: %{ + instance: %Schema{ + type: :object, + nullable: true, + properties: %{ + name: %Schema{type: :string}, + favicon: %Schema{type: :string, format: :uri, nullable: true}, + # XXX: proper nodeinfo schema + nodeinfo: %Schema{type: :object, nullable: true} + } + }, + status_ttl_days: %Schema{type: :integer, nullable: true}, + permit_followback: %Schema{type: :boolean} + } + }, source: %Schema{ type: :object, properties: %{ @@ -199,6 +216,18 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Account do "pleroma-fe" => %{} } }, + "akkoma" => %{ + "instance" => %{ + "name" => "ihatebeinga.live", + "favicon" => "https://ihatebeinga.live/favicon.png", + "nodeinfo" => + %{ + # XXX: nodeinfo schema + } + }, + "status_ttl_days" => nil, + "permit_followback" => true + }, "source" => %{ "fields" => [], "note" => "foobar", diff --git a/lib/pleroma/web/api_spec/schemas/account_relationship.ex b/lib/pleroma/web/api_spec/schemas/account_relationship.ex index 5d9e3b56e..58751b261 100644 --- a/lib/pleroma/web/api_spec/schemas/account_relationship.ex +++ b/lib/pleroma/web/api_spec/schemas/account_relationship.ex @@ -13,7 +13,10 @@ defmodule Pleroma.Web.ApiSpec.Schemas.AccountRelationship do description: "Relationship between current account and requested account", type: :object, properties: %{ - blocked_by: %Schema{type: :boolean}, + blocked_by: %Schema{ + type: :boolean, + description: "Represents being blocked by this user. Always false." + }, blocking: %Schema{type: :boolean}, domain_blocking: %Schema{type: :boolean}, endorsed: %Schema{type: :boolean}, diff --git a/lib/pleroma/web/api_spec/schemas/status.ex b/lib/pleroma/web/api_spec/schemas/status.ex index a6df9be94..28386b096 100644 --- a/lib/pleroma/web/api_spec/schemas/status.ex +++ b/lib/pleroma/web/api_spec/schemas/status.ex @@ -232,7 +232,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do source: %Schema{ nullable: true, oneOf: [ - %Schema{type: :string, example: 'plaintext content'}, + %Schema{type: :string, example: ~c"plaintext content"}, %Schema{ type: :object, properties: %{ diff --git a/lib/pleroma/web/api_spec/schemas/tag.ex b/lib/pleroma/web/api_spec/schemas/tag.ex index 657b675e5..657fc3d2b 100644 --- a/lib/pleroma/web/api_spec/schemas/tag.ex +++ b/lib/pleroma/web/api_spec/schemas/tag.ex @@ -17,11 +17,22 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Tag do type: :string, format: :uri, description: "A link to the hashtag on the instance" + }, + following: %Schema{ + type: :boolean, + description: "Whether the authenticated user is following the hashtag" + }, + history: %Schema{ + type: :array, + items: %Schema{type: :string}, + description: + "A list of historical uses of the hashtag (not implemented, for compatibility only)" } }, example: %{ name: "cofe", - url: "https://lain.com/tag/cofe" + url: "https://lain.com/tag/cofe", + following: false } }) end diff --git a/lib/pleroma/web/auth/ldap_authenticator.ex b/lib/pleroma/web/auth/ldap_authenticator.ex index f77e8d203..44b50d126 100644 --- a/lib/pleroma/web/auth/ldap_authenticator.ex +++ b/lib/pleroma/web/auth/ldap_authenticator.ex @@ -102,7 +102,7 @@ defp register_user(connection, base, uid, name) do {:scope, :eldap.wholeSubtree()}, {:timeout, @search_timeout} ]) do - {:ok, {:eldap_search_result, [{:eldap_entry, _, attributes}], _}} -> + {:ok, {:eldap_search_result, [{:eldap_entry, _, attributes}], _, _}} -> params = %{ name: name, nickname: name, @@ -110,7 +110,7 @@ defp register_user(connection, base, uid, name) do } params = - case List.keyfind(attributes, 'mail', 0) do + case List.keyfind(attributes, ~c"mail", 0) do {_, [mail]} -> Map.put_new(params, :email, :erlang.list_to_binary(mail)) _ -> params end diff --git a/lib/pleroma/web/auth/pleroma_authenticator.ex b/lib/pleroma/web/auth/pleroma_authenticator.ex index 68472e75f..01b54037c 100644 --- a/lib/pleroma/web/auth/pleroma_authenticator.ex +++ b/lib/pleroma/web/auth/pleroma_authenticator.ex @@ -6,7 +6,6 @@ defmodule Pleroma.Web.Auth.PleromaAuthenticator do alias Pleroma.Registration alias Pleroma.Repo alias Pleroma.User - alias Pleroma.Web.Plugs.AuthenticationPlug import Pleroma.Web.Auth.Helpers, only: [fetch_credentials: 1, fetch_user: 1] @@ -15,8 +14,8 @@ defmodule Pleroma.Web.Auth.PleromaAuthenticator do def get_user(%Plug.Conn{} = conn) do with {:ok, {name, password}} <- fetch_credentials(conn), {_, %User{} = user} <- {:user, fetch_user(name)}, - {_, true} <- {:checkpw, AuthenticationPlug.checkpw(password, user.password_hash)}, - {:ok, user} <- AuthenticationPlug.maybe_update_password(user, password) do + {_, true} <- {:checkpw, Pleroma.Password.checkpw(password, user.password_hash)}, + {:ok, user} <- Pleroma.Password.maybe_update_password(user, password) do {:ok, user} else {:error, _reason} = error -> error @@ -60,6 +59,8 @@ def get_registration(%Plug.Conn{ def get_registration(%Plug.Conn{} = _conn), do: {:error, :missing_credentials} @doc "Creates Pleroma.User record basing on params and Pleroma.Registration record." + @spec create_from_registration(Plug.Conn.t(), Registration.t()) :: + {:ok, User.t()} | {:error, any()} def create_from_registration( %Plug.Conn{params: %{"authorization" => registration_attrs}}, %Registration{} = registration @@ -89,6 +90,8 @@ def create_from_registration( {:ok, _} <- Registration.changeset(registration, %{user_id: new_user.id}) |> Repo.update() do {:ok, new_user} + else + err -> err end end diff --git a/lib/pleroma/web/auth/totp_authenticator.ex b/lib/pleroma/web/auth/totp_authenticator.ex index 5947cd8c9..e6f839e6e 100644 --- a/lib/pleroma/web/auth/totp_authenticator.ex +++ b/lib/pleroma/web/auth/totp_authenticator.ex @@ -6,7 +6,6 @@ defmodule Pleroma.Web.Auth.TOTPAuthenticator do alias Pleroma.MFA alias Pleroma.MFA.TOTP alias Pleroma.User - alias Pleroma.Web.Plugs.AuthenticationPlug @doc "Verify code or check backup code." @spec verify(String.t(), User.t()) :: @@ -31,7 +30,7 @@ def verify_recovery_code( code ) when is_list(codes) and is_binary(code) do - hash_code = Enum.find(codes, fn hash -> AuthenticationPlug.checkpw(code, hash) end) + hash_code = Enum.find(codes, fn hash -> Pleroma.Password.checkpw(code, hash) end) if hash_code do MFA.invalidate_backup_code(user, hash_code) diff --git a/lib/pleroma/web/common_api.ex b/lib/pleroma/web/common_api.ex index f1f51acf5..6b62ef2d1 100644 --- a/lib/pleroma/web/common_api.ex +++ b/lib/pleroma/web/common_api.ex @@ -88,7 +88,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.superuser?(user) || user.ap_id == object.data["actor"], @@ -467,7 +467,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}" ) diff --git a/lib/pleroma/web/common_api/activity_draft.ex b/lib/pleroma/web/common_api/activity_draft.ex index b3a49de44..ced6371d6 100644 --- a/lib/pleroma/web/common_api/activity_draft.ex +++ b/lib/pleroma/web/common_api/activity_draft.ex @@ -22,6 +22,8 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do attachments: [], in_reply_to: nil, in_reply_to_conversation: nil, + language: nil, + content_map: %{}, quote_id: nil, quote: nil, visibility: nil, @@ -58,6 +60,7 @@ def create(user, params) do |> with_valid(&visibility/1) |> with_valid("e_id/1) |> content() + |> with_valid(&language/1) |> with_valid(&to_and_cc/1) |> with_valid(&context/1) |> sensitive() @@ -133,6 +136,20 @@ defp quote_id(%{params: %{quote_id: %Activity{} = quote}} = draft) do defp quote_id(draft), do: draft + defp language(%{params: %{language: language}, content_html: content} = draft) + when is_binary(language) do + if Pleroma.ISO639.valid_alpha2?(language) do + %__MODULE__{draft | content_map: %{language => content}} + else + add_error(draft, dgettext("errors", "Invalid language")) + end + end + + defp language(%{content_html: content} = draft) do + # Use a default language if no language is specified + %__MODULE__{draft | content_map: %{"en" => content}} + end + defp visibility(%{params: params} = draft) do case CommonAPI.get_visibility(params, draft.in_reply_to, draft.in_reply_to_conversation) do {visibility, "direct"} when visibility != "direct" -> @@ -177,7 +194,7 @@ defp to_and_cc(draft) do end defp context(draft) do - context = Utils.make_context(draft.in_reply_to, draft.in_reply_to_conversation) + context = Utils.make_context(draft) %__MODULE__{draft | context: context} end @@ -224,6 +241,7 @@ defp object(draft) do "mediaType" => Utils.get_content_type(draft.params[:content_type]) }) |> Map.put("generator", draft.params[:generator]) + |> Map.put("contentMap", draft.content_map) %__MODULE__{draft | object: object} end diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index bf03b0a82..e79b12fc9 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -17,7 +17,6 @@ defmodule Pleroma.Web.CommonAPI.Utils do alias Pleroma.Web.ActivityPub.Visibility alias Pleroma.Web.CommonAPI.ActivityDraft alias Pleroma.Web.MediaProxy - alias Pleroma.Web.Plugs.AuthenticationPlug alias Pleroma.Web.Utils.Params require Logger @@ -145,6 +144,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 +181,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 @@ -231,12 +237,13 @@ def get_content_type(content_type) do end end - def make_context(_, %Participation{} = participation) do + def make_context(%{in_reply_to_conversation: %Participation{} = participation}) do Repo.preload(participation, :conversation).conversation.ap_id end - def make_context(%Activity{data: %{"context" => context}}, _), do: context - def make_context(_, _), do: Utils.generate_context_id() + def make_context(%{in_reply_to: %Activity{data: %{"context" => context}}}), do: context + def make_context(%{quote: %Activity{data: %{"context" => context}}}), do: context + def make_context(_), do: Utils.generate_context_id() def maybe_add_attachments(parsed, _attachments, false = _no_links), do: parsed @@ -289,7 +296,7 @@ def format_input(text, "text/html", options) do def format_input(text, "text/x.misskeymarkdown", options) do text - |> Formatter.markdown_to_html() + |> Formatter.markdown_to_html(%{breaks: true}) |> MfmParser.Parser.parse() |> MfmParser.Encoder.to_html() |> Formatter.linkify(options) @@ -317,31 +324,38 @@ 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 def to_masto_date(%NaiveDateTime{} = date) do - date - |> NaiveDateTime.to_iso8601() - |> String.replace(~r/(\.\d+)?$/, ".000Z", global: false) + # NOTE: Elixir’s ISO 8601 format is a superset of the real standard + # It supports negative years for example. + # ISO8601 only supports years before 1583 with mutual agreement + if date.year < 1583 do + "1970-01-01T00:00:00Z" + else + date + |> NaiveDateTime.to_iso8601() + |> String.replace(~r/(\.\d+)?$/, ".000Z", global: false) + end end def to_masto_date(date) when is_binary(date) do with {:ok, date} <- NaiveDateTime.from_iso8601(date) do to_masto_date(date) else - _ -> "" + _ -> "1970-01-01T00:00:00Z" end end - def to_masto_date(_), do: "" + def to_masto_date(_), do: "1970-01-01T00:00:00Z" defp shortname(name) do with max_length when max_length > 0 <- @@ -356,7 +370,7 @@ defp shortname(name) do @spec confirm_current_password(User.t(), String.t()) :: {:ok, User.t()} | {:error, String.t()} def confirm_current_password(user, password) do with %User{local: true} = db_user <- User.get_cached_by_id(user.id), - true <- AuthenticationPlug.checkpw(password, db_user.password_hash) do + true <- Pleroma.Password.checkpw(password, db_user.password_hash) do {:ok, db_user} else _ -> {:error, dgettext("errors", "Invalid password.")} diff --git a/lib/pleroma/web/controller_helper.ex b/lib/pleroma/web/controller_helper.ex index 7b84b43e4..6acc8f078 100644 --- a/lib/pleroma/web/controller_helper.ex +++ b/lib/pleroma/web/controller_helper.ex @@ -87,16 +87,18 @@ def get_pagination_fields(conn, entries, extra_params \\ %{}) do def assign_account_by_id(conn, _) do case Pleroma.User.get_cached_by_id(conn.params.id) do - %Pleroma.User{} = account -> assign(conn, :account, account) - nil -> Pleroma.Web.MastodonAPI.FallbackController.call(conn, {:error, :not_found}) |> halt() + %Pleroma.User{} = account -> + assign(conn, :account, account) + + nil -> + Pleroma.Web.MastodonAPI.FallbackController.call(conn, {:error, :not_found}) + |> halt() end end + @spec try_render(Plug.Conn.t(), any, any) :: Plug.Conn.t() 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 c7912bb1f..cffd6e29f 100644 --- a/lib/pleroma/web/embed_controller.ex +++ b/lib/pleroma/web/embed_controller.ex @@ -11,22 +11,31 @@ defmodule Pleroma.Web.EmbedController do alias Pleroma.Web.ActivityPub.Visibility - plug(:put_layout, :embed) - 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 + with {:activity, %Activity{} = activity} <- + {:activity, Activity.get_by_id_with_object(id)}, + {:local, true} <- {:local, activity.local}, + {:visible, true} <- {:visible, Visibility.visible_for_user?(activity, nil)} do {:ok, author} = User.get_or_fetch(activity.object.data["actor"]) conn |> delete_resp_header("x-frame-options") |> delete_resp_header("content-security-policy") + |> put_view(Pleroma.Web.EmbedView) |> render("show.html", activity: activity, author: User.sanitize_html(author), counts: get_counts(activity) ) + else + {:activity, _} -> + render_error(conn, :not_found, "Post not found") + + {:local, false} -> + render_error(conn, :unauthorized, "Federated posts cannot be embedded") + + {:visible, false} -> + render_error(conn, :unauthorized, "Not authorized to view this post") end end diff --git a/lib/pleroma/web/endpoint.ex b/lib/pleroma/web/endpoint.ex index 6dd66a424..6628fcaf3 100644 --- a/lib/pleroma/web/endpoint.ex +++ b/lib/pleroma/web/endpoint.ex @@ -13,6 +13,7 @@ defmodule Pleroma.Web.Endpoint do plug(Pleroma.Web.Plugs.SetLocalePlug) plug(CORSPlug) + plug(Pleroma.Web.Plugs.CSPNoncePlug) plug(Pleroma.Web.Plugs.HTTPSecurityPlug) plug(Pleroma.Web.Plugs.UploadedMedia) @@ -96,7 +97,11 @@ defmodule Pleroma.Web.Endpoint do Plug.Static, at: "/", from: :pleroma, - only: Pleroma.Constants.static_only_files(), + only: Pleroma.Web.static_paths(), + # JSON-LD is accepted by some servers for AP objects and activities, + # thus only enable it here instead of a global extension mapping + # (it's our only *.jsonld file anyway) + content_types: %{"litepub-0.1.jsonld" => "application/ld+json"}, # credo:disable-for-previous-line Credo.Check.Readability.MaxLineLength gzip: true, cache_control_for_etags: @static_cache_control, @@ -123,7 +128,7 @@ defmodule Pleroma.Web.Endpoint do plug(Plug.Parsers, parsers: [ :urlencoded, - {:multipart, length: {Config, :get, [[:instance, :upload_limit]]}}, + Pleroma.Web.Plugs.Parsers.Multipart, :json ], pass: ["*/*"], diff --git a/lib/pleroma/web/fallback/legacy_pleroma_api_rerouter_plug.ex b/lib/pleroma/web/fallback/legacy_pleroma_api_rerouter_plug.ex deleted file mode 100644 index f86d6b52b..000000000 --- a/lib/pleroma/web/fallback/legacy_pleroma_api_rerouter_plug.ex +++ /dev/null @@ -1,26 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2021 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.Fallback.LegacyPleromaApiRerouterPlug do - alias Pleroma.Web.Endpoint - alias Pleroma.Web.Fallback.RedirectController - - def init(opts), do: opts - - def call(%{path_info: ["api", "pleroma" | path_info_rest]} = conn, _opts) do - new_path_info = ["api", "v1", "pleroma" | path_info_rest] - new_request_path = Enum.join(new_path_info, "/") - - conn - |> Map.merge(%{ - path_info: new_path_info, - request_path: new_request_path - }) - |> Endpoint.call(conn.params) - end - - def call(conn, _opts) do - RedirectController.api_not_implemented(conn, %{}) - end -end diff --git a/lib/pleroma/web/fallback/redirect_controller.ex b/lib/pleroma/web/fallback/redirect_controller.ex index 49f659cf0..2e57fa426 100644 --- a/lib/pleroma/web/fallback/redirect_controller.ex +++ b/lib/pleroma/web/fallback/redirect_controller.ex @@ -20,7 +20,7 @@ def api_not_implemented(conn, _params) do def redirector(conn, _params, code \\ 200) do conn |> put_resp_content_type("text/html") - |> send_file(code, index_file_path()) + |> send_file(code, index_file_path(conn)) end def redirector_with_meta(conn, %{"maybe_nickname_or_id" => maybe_nickname_or_id} = params) do @@ -33,7 +33,7 @@ def redirector_with_meta(conn, %{"maybe_nickname_or_id" => maybe_nickname_or_id} end def redirector_with_meta(conn, params) do - {:ok, index_content} = File.read(index_file_path()) + {:ok, index_content} = File.read(index_file_path(conn)) tags = build_tags(conn, params) preloads = preload_data(conn, params) @@ -53,7 +53,7 @@ def redirector_with_preload(conn, %{"path" => ["pleroma", "admin"]}) do end def redirector_with_preload(conn, params) do - {:ok, index_content} = File.read(index_file_path()) + {:ok, index_content} = File.read(index_file_path(conn)) preloads = preload_data(conn, params) tags = Metadata.build_static_tags(params) title = "#{Pleroma.Config.get([:instance, :name])}" @@ -77,8 +77,9 @@ def empty(conn, _params) do |> text("") end - defp index_file_path do - Pleroma.Web.Plugs.InstanceStatic.file_path("index.html") + defp index_file_path(conn) do + frontend_type = Pleroma.Web.Plugs.FrontendStatic.preferred_or_fallback(conn, :primary) + Pleroma.Web.Plugs.InstanceStatic.file_path("index.html", frontend_type) end defp build_tags(conn, params) do diff --git a/lib/pleroma/web/federator.ex b/lib/pleroma/web/federator.ex index 770044de2..3a00424c6 100644 --- a/lib/pleroma/web/federator.ex +++ b/lib/pleroma/web/federator.ex @@ -48,7 +48,9 @@ def publish(%{id: "pleroma:fakeid"} = activity) do @impl true def publish(%{data: %{"object" => object}} = activity) when is_binary(object) do - PublisherWorker.enqueue("publish", %{"activity_id" => activity.id, "object_data" => nil}) + PublisherWorker.enqueue("publish", %{"activity_id" => activity.id, "object_data" => nil}, + priority: publish_priority(activity) + ) end @impl true @@ -63,7 +65,7 @@ def publish(%{data: %{"object" => object}} = activity) when is_map(object) or is ) end - defp publish_priority(%{type: "Delete"}), do: 3 + defp publish_priority(%{data: %{"type" => "Delete"}}), do: 3 defp publish_priority(_), do: 0 # Job Worker Callbacks diff --git a/lib/pleroma/web/feed/user_controller.ex b/lib/pleroma/web/feed/user_controller.ex index dc3b1f94b..b320c9224 100644 --- a/lib/pleroma/web/feed/user_controller.ex +++ b/lib/pleroma/web/feed/user_controller.ex @@ -30,7 +30,7 @@ def feed_redirect(%{assigns: %{format: format}} = conn, _params) def feed_redirect(conn, %{"nickname" => nickname}) do with {_, %User{} = user} <- {:fetch_user, User.get_cached_by_nickname(nickname)} do - redirect(conn, external: "#{Routes.user_feed_url(conn, :feed, user.nickname)}.atom") + redirect(conn, external: "#{url(~p"/users/#{user.nickname}/feed")}.atom") end end diff --git a/lib/pleroma/web/masto_fe_controller.ex b/lib/pleroma/web/masto_fe_controller.ex index 7b6e01aad..b24f00620 100644 --- a/lib/pleroma/web/masto_fe_controller.ex +++ b/lib/pleroma/web/masto_fe_controller.ex @@ -34,9 +34,9 @@ def index(conn, _params) do index = if flavour == "fedibird-fe" do - "fedibird.index.html" + "fedibird.html" else - "glitchsoc.index.html" + "glitchsoc.html" end conn diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 946c8544f..2c7d6a893 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -32,14 +32,14 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do plug(Pleroma.Web.ApiSpec.CastAndValidate) - plug(:skip_auth when action in [:create, :lookup]) + plug(:skip_auth when action in [:create]) plug(:skip_public_check when action in [:show, :statuses]) plug( OAuthScopesPlug, %{fallback: :proceed_unauthenticated, scopes: ["read:accounts"]} - when action in [:show, :followers, :following] + when action in [:show, :followers, :following, :lookup] ) plug( @@ -51,7 +51,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do plug( OAuthScopesPlug, %{scopes: ["read:accounts"]} - when action in [:verify_credentials, :endorsements, :identity_proofs] + when action in [:verify_credentials, :endorsements, :identity_proofs, :preferences] ) plug( @@ -175,6 +175,11 @@ def update_credentials(%{assigns: %{user: user}, body_params: params} = conn, _p value -> {:ok, value} end + status_ttl_days_value = fn + -1 -> {:ok, nil} + value -> {:ok, value} + end + user_params = [ :no_rich_text, @@ -215,6 +220,9 @@ def update_credentials(%{assigns: %{user: user}, body_params: params} = conn, _p # Note: param name is indeed :discoverable (not an error) |> Maps.put_if_present(:is_discoverable, params[:discoverable]) |> Maps.put_if_present(:language, Pleroma.Web.Gettext.normalize_locale(params[:language])) + |> Maps.put_if_present(:status_ttl_days, params[:status_ttl_days], status_ttl_days_value) + |> Maps.put_if_present(:accepts_direct_messages_from, params[:accepts_direct_messages_from]) + |> Maps.put_if_present(:permit_followback, params[:permit_followback]) # What happens here: # @@ -245,7 +253,17 @@ def update_credentials(%{assigns: %{user: user}, body_params: params} = conn, _p with_pleroma_settings: true ) else - _e -> render_error(conn, :forbidden, "Invalid request") + {:error, %Ecto.Changeset{errors: [avatar: {"file is too large", _}]}} -> + render_error(conn, :request_entity_too_large, "File is too large") + + {:error, %Ecto.Changeset{errors: [banner: {"file is too large", _}]}} -> + render_error(conn, :request_entity_too_large, "File is too large") + + {:error, %Ecto.Changeset{errors: [background: {"file is too large", _}]}} -> + render_error(conn, :request_entity_too_large, "File is too large") + + _e -> + render_error(conn, :forbidden, "Invalid request") end end @@ -480,7 +498,7 @@ def mutes(%{assigns: %{user: user}} = conn, params) do users = user |> User.muted_users_relation(_restrict_deactivated = true) - |> Pleroma.Pagination.fetch_paginated(Map.put(params, :skip_order, true)) + |> Pleroma.Pagination.fetch_paginated(params) conn |> add_link_headers(users) @@ -497,16 +515,22 @@ def blocks(%{assigns: %{user: user}} = conn, params) do users = user |> User.blocked_users_relation(_restrict_deactivated = true) - |> Pleroma.Pagination.fetch_paginated(Map.put(params, :skip_order, true)) + |> Pleroma.Pagination.fetch_paginated(params) 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 - with %User{} = user <- User.get_by_nickname(nickname) do + def lookup(%{assigns: %{user: for_user}} = conn, %{acct: nickname} = _params) do + with %User{} = user <- User.get_by_nickname(nickname), + :visible <- User.visible_for(user, for_user) do render(conn, "show.json", user: user, skip_visibility_check: true @@ -521,4 +545,9 @@ def endorsements(conn, params), do: MastodonAPIController.empty_array(conn, para @doc "GET /api/v1/identity_proofs" def identity_proofs(conn, params), do: MastodonAPIController.empty_array(conn, params) + + @doc "GET /api/v1/preferences" + def preferences(%{assigns: %{user: user}} = conn, _params) do + render(conn, "preferences.json", user: user) + end end diff --git a/lib/pleroma/web/mastodon_api/controllers/auth_controller.ex b/lib/pleroma/web/mastodon_api/controllers/auth_controller.ex index a9ccaa982..30e40ac42 100644 --- a/lib/pleroma/web/mastodon_api/controllers/auth_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/auth_controller.ex @@ -54,12 +54,7 @@ def login(conn, params) do defp redirect_to_oauth_form(conn, _params) do with {:ok, app} <- local_mastofe_app() do path = - Routes.o_auth_path(conn, :authorize, - response_type: "code", - client_id: app.client_id, - redirect_uri: ".", - scope: Enum.join(app.scopes, " ") - ) + ~p[/oauth/authorize?#{[response_type: "code", client_id: app.client_id, redirect_uri: ".", scope: Enum.join(app.scopes, " ")]}] redirect(conn, to: path) end @@ -91,7 +86,7 @@ def password_reset(conn, params) do defp local_mastodon_post_login_path(conn) do case get_session(conn, :return_to) do nil -> - Routes.masto_fe_path(conn, :index, ["getting-started"]) + ~p"/web/getting-started" return_to -> delete_session(conn, :return_to) 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 d915298f1..e534d0388 100644 --- a/lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex @@ -5,9 +5,13 @@ defmodule Pleroma.Web.MastodonAPI.FollowRequestController do use Pleroma.Web, :controller + import Pleroma.Web.ControllerHelper, + only: [add_link_headers: 2] + alias Pleroma.User alias Pleroma.Web.CommonAPI alias Pleroma.Web.Plugs.OAuthScopesPlug + alias Pleroma.Pagination plug(Pleroma.Web.ApiSpec.CastAndValidate) plug(:assign_follower when action != :index) @@ -24,10 +28,15 @@ defmodule Pleroma.Web.MastodonAPI.FollowRequestController do defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.FollowRequestOperation @doc "GET /api/v1/follow_requests" - def index(%{assigns: %{user: followed}} = conn, _params) do - follow_requests = User.get_follow_requests(followed) + def index(%{assigns: %{user: followed}} = conn, params) do + follow_requests = + followed + |> User.get_follow_requests_query() + |> Pagination.fetch_paginated(params, :keyset, :follower) - render(conn, "index.json", for: followed, users: follow_requests, as: :user) + conn + |> add_link_headers(follow_requests) + |> render("index.json", for: followed, users: follow_requests, as: :user) end @doc "POST /api/v1/follow_requests/:id/authorize" diff --git a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex index 31f3b3a8d..338a35052 100644 --- a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex @@ -171,6 +171,16 @@ def create(%{assigns: %{user: user}, body_params: %{status: _} = params} = conn, Map.put(params, :in_reply_to_status_id, params[:in_reply_to_id]) |> put_application(conn) + expires_in_seconds = + if is_nil(user.status_ttl_days), + do: nil, + else: 60 * 60 * 24 * user.status_ttl_days + + params = + if is_nil(expires_in_seconds), + do: params, + else: Map.put(params, :expires_in, expires_in_seconds) + with {:ok, activity} <- CommonAPI.post(user, params) do try_render(conn, "show.json", activity: activity, diff --git a/lib/pleroma/web/mastodon_api/controllers/tag_controller.ex b/lib/pleroma/web/mastodon_api/controllers/tag_controller.ex new file mode 100644 index 000000000..ca5ee48ac --- /dev/null +++ b/lib/pleroma/web/mastodon_api/controllers/tag_controller.ex @@ -0,0 +1,77 @@ +defmodule Pleroma.Web.MastodonAPI.TagController do + @moduledoc "Hashtag routes for mastodon API" + use Pleroma.Web, :controller + + alias Pleroma.User + alias Pleroma.Hashtag + alias Pleroma.Pagination + + import Pleroma.Web.ControllerHelper, + only: [ + add_link_headers: 2 + ] + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + + plug( + Pleroma.Web.Plugs.OAuthScopesPlug, + %{scopes: ["read"]} when action in [:show] + ) + + plug( + Pleroma.Web.Plugs.OAuthScopesPlug, + %{scopes: ["read:follows"]} when action in [:show_followed] + ) + + plug( + Pleroma.Web.Plugs.OAuthScopesPlug, + %{scopes: ["write:follows"]} when action in [:follow, :unfollow] + ) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.TagOperation + + def show(conn, %{id: id}) do + with %Hashtag{} = hashtag <- Hashtag.get_by_name(id) do + render(conn, "show.json", tag: hashtag, for_user: conn.assigns.user) + else + _ -> conn |> render_error(:not_found, "Hashtag not found") + end + end + + def follow(conn, %{id: id}) do + with %Hashtag{} = hashtag <- Hashtag.get_by_name(id), + %User{} = user <- conn.assigns.user, + {:ok, _} <- + User.follow_hashtag(user, hashtag) do + render(conn, "show.json", tag: hashtag, for_user: user) + else + _ -> render_error(conn, :not_found, "Hashtag not found") + end + end + + def unfollow(conn, %{id: id}) do + with %Hashtag{} = hashtag <- Hashtag.get_by_name(id), + %User{} = user <- conn.assigns.user, + {:ok, _} <- + User.unfollow_hashtag(user, hashtag) do + render(conn, "show.json", tag: hashtag, for_user: user) + else + _ -> render_error(conn, :not_found, "Hashtag not found") + end + end + + def show_followed(conn, params) do + with %{assigns: %{user: %User{} = user}} <- conn do + params = Map.put(params, :id_type, :integer) + + hashtags = + user + |> User.HashtagFollow.followed_hashtags_query() + |> Pagination.fetch_paginated(params) + + conn + |> add_link_headers(hashtags) + |> render("index.json", tags: hashtags, for_user: user) + end + end +end diff --git a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex index 5f8acb2df..1d4e734a4 100644 --- a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex @@ -16,7 +16,7 @@ defmodule Pleroma.Web.MastodonAPI.TimelineController do alias Pleroma.Web.Plugs.RateLimiter plug(Pleroma.Web.ApiSpec.CastAndValidate) - plug(:skip_public_check when action in [:public, :hashtag]) + plug(:skip_public_check when action in [:public, :hashtag, :bubble]) # TODO: Replace with a macro when there is a Phoenix release with the following commit in it: # https://github.com/phoenixframework/phoenix/commit/2e8c63c01fec4dde5467dbbbf9705ff9e780735e @@ -28,19 +28,30 @@ defmodule Pleroma.Web.MastodonAPI.TimelineController do plug(RateLimiter, [name: :timeline, bucket_name: :list_timeline] when action == :list) plug(RateLimiter, [name: :timeline, bucket_name: :bubble_timeline] when action == :bubble) - plug(OAuthScopesPlug, %{scopes: ["read:statuses"]} when action in [:home, :direct, :bubble]) + plug(OAuthScopesPlug, %{scopes: ["read:statuses"]} when action in [:home, :direct]) plug(OAuthScopesPlug, %{scopes: ["read:lists"]} when action == :list) plug( OAuthScopesPlug, %{scopes: ["read:statuses"], fallback: :proceed_unauthenticated} - when action in [:public, :hashtag] + when action in [:public, :hashtag, :bubble] ) + require Logger + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.TimelineOperation # GET /api/v1/timelines/home def home(%{assigns: %{user: user}} = conn, params) do + %{nickname: nickname} = user + + Logger.debug("TimelineController.home: #{nickname}") + + followed_hashtags = + user + |> User.followed_hashtags() + |> Enum.map(& &1.id) + params = params |> Map.put(:type, ["Create", "Announce"]) @@ -50,13 +61,18 @@ def home(%{assigns: %{user: user}} = conn, params) do |> Map.put(:announce_filtering_user, user) |> Map.put(:user, user) |> Map.put(:local_only, params[:local]) + |> Map.put(:followed_hashtags, followed_hashtags) |> Map.delete(:local) + Logger.debug("TimelineController.home: #{nickname} - fetching activities") + activities = [user.ap_id | User.following(user)] |> ActivityPub.fetch_activities(params) |> Enum.reverse() + Logger.debug("TimelineController.home: #{nickname} - rendering") + conn |> add_link_headers(activities) |> render("index.json", @@ -69,6 +85,8 @@ def home(%{assigns: %{user: user}} = conn, params) do # GET /api/v1/timelines/direct def direct(%{assigns: %{user: user}} = conn, params) do + Logger.debug("TimelineController.direct: #{user.nickname}") + params = params |> Map.put(:type, "Create") @@ -76,11 +94,15 @@ def direct(%{assigns: %{user: user}} = conn, params) do |> Map.put(:user, user) |> Map.put(:visibility, "direct") + Logger.debug("TimelineController.direct: #{user.nickname} - fetching activities") + activities = [user.ap_id] |> ActivityPub.fetch_activities_query(params) |> Pagination.fetch_paginated(params) + Logger.debug("TimelineController.direct: #{user.nickname} - rendering") + conn |> add_link_headers(activities) |> render("index.json", @@ -90,21 +112,22 @@ def direct(%{assigns: %{user: user}} = conn, params) do ) end - defp restrict_unauthenticated?(true = _local_only) do - Config.restrict_unauthenticated_access?(:timelines, :local) - end - - defp restrict_unauthenticated?(_) do - Config.restrict_unauthenticated_access?(:timelines, :federated) + defp restrict_unauthenticated?(type) do + Config.restrict_unauthenticated_access?(:timelines, type) end # GET /api/v1/timelines/public def public(%{assigns: %{user: user}} = conn, params) do + Logger.debug("TimelineController.public") local_only = params[:local] + timeline_type = if local_only, do: :local, else: :federated + + with {:enabled, true} <- + {:enabled, local_only || Config.get([:instance, :federated_timeline_available], true)}, + {:authenticated, true} <- + {:authenticated, !(is_nil(user) and restrict_unauthenticated?(timeline_type))} do + Logger.debug("TimelineController.public: fetching activities") - if is_nil(user) and restrict_unauthenticated?(local_only) do - fail_on_bad_auth(conn) - else activities = params |> Map.put(:type, ["Create"]) @@ -117,6 +140,8 @@ def public(%{assigns: %{user: user}} = conn, params) do |> Map.put(:includes_local_public, not is_nil(user)) |> ActivityPub.fetch_public_activities() + Logger.debug("TimelineController.public: rendering") + conn |> add_link_headers(activities, %{"local" => local_only}) |> render("index.json", @@ -125,20 +150,32 @@ def public(%{assigns: %{user: user}} = conn, params) do as: :activity, with_muted: Map.get(params, :with_muted, false) ) + else + {:enabled, false} -> + conn + |> put_status(404) + |> json(%{error: "Federated timeline is disabled"}) + + {:authenticated, false} -> + fail_on_bad_auth(conn) end end # GET /api/v1/timelines/bubble def bubble(%{assigns: %{user: user}} = conn, params) do - bubble_instances = - Enum.uniq( - Config.get([:instance, :local_bubble], []) ++ - [Pleroma.Web.Endpoint.host()] - ) + Logger.debug("TimelineController.bubble") - if is_nil(user) do + if is_nil(user) and restrict_unauthenticated?(:bubble) do fail_on_bad_auth(conn) else + bubble_instances = + Enum.uniq( + Config.get([:instance, :local_bubble], []) ++ + [Pleroma.Web.Endpoint.host()] + ) + + Logger.debug("TimelineController.bubble: fetching activities") + activities = params |> Map.put(:type, ["Create"]) @@ -148,6 +185,8 @@ def bubble(%{assigns: %{user: user}} = conn, params) do |> Map.put(:instance, bubble_instances) |> ActivityPub.fetch_public_activities() + Logger.debug("TimelineController.bubble: rendering") + conn |> add_link_headers(activities) |> render("index.json", @@ -189,7 +228,7 @@ defp hashtag_fetching(params, user, local_only) do def hashtag(%{assigns: %{user: user}} = conn, params) do local_only = params[:local] - if is_nil(user) and restrict_unauthenticated?(local_only) do + if is_nil(user) and restrict_unauthenticated?(if local_only, do: :local, else: :federated) do fail_on_bad_auth(conn) else activities = hashtag_fetching(params, user, local_only) diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index 06acf0a26..e9c04e927 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -94,12 +94,12 @@ def render( followed_by = if following_relationships do - case FollowingRelationship.find(following_relationships, target, reading_user) do - %{state: :follow_accept} -> true - _ -> false - end + target_to_user_following_relation = + FollowingRelationship.find(following_relationships, target, reading_user) + + User.get_follow_state(target, reading_user, target_to_user_following_relation) else - User.following?(target, reading_user) + User.get_follow_state(target, reading_user) end subscribing = @@ -115,7 +115,7 @@ def render( %{ id: to_string(target.id), following: follow_state == :follow_accept, - followed_by: followed_by, + followed_by: followed_by == :follow_accept, blocking: UserRelationship.exists?( user_relationships, @@ -124,14 +124,7 @@ def render( target, &User.blocks_user?(&1, &2) ), - blocked_by: - UserRelationship.exists?( - user_relationships, - :block, - target, - reading_user, - &User.blocks_user?(&1, &2) - ), + blocked_by: false, muting: UserRelationship.exists?( user_relationships, @@ -151,6 +144,7 @@ def render( subscribing: subscribing, notifying: subscribing, requested: follow_state == :follow_pending, + requested_by: followed_by == :follow_pending, domain_blocking: User.blocks_domain?(reading_user, target), showing_reblogs: not UserRelationship.exists?( @@ -186,6 +180,27 @@ def render("relationships.json", %{user: user, targets: targets} = opts) do render_many(targets, AccountView, "relationship.json", render_opts) end + def render("instance.json", %{instance: %Pleroma.Instances.Instance{} = instance}) do + %{ + name: instance.host, + favicon: instance.favicon |> MediaProxy.url(), + nodeinfo: instance.nodeinfo + } + end + + def render("instance.json", _), do: nil + + def render("preferences.json", %{user: user} = _opts) do + # TODO: Do we expose more settings that make sense to plug in here? + %{ + "posting:default:visibility": user.default_scope, + "posting:default:sensitive": false, + "posting:default:language": nil, + "reading:expand:media": "default", + "reading:expand:spoilers": false + } + end + defp do_render("show.json", %{user: user} = opts) do user = User.sanitize_html(user, User.html_filter_policy(opts[:for])) display_name = user.name || user.nickname @@ -230,18 +245,25 @@ defp do_render("show.json", %{user: user} = opts) do %{} end - favicon = - if Pleroma.Config.get([:instances_favicons, :enabled]) do - user - |> Map.get(:ap_id, "") - |> URI.parse() - |> URI.merge("/") - |> Pleroma.Instances.Instance.get_or_update_favicon() - |> MediaProxy.url() + instance = + with {:ok, instance} <- Pleroma.Instances.Instance.get_cached_by_url(user.ap_id) do + instance else - nil + _ -> + nil end + favicon = + if is_nil(instance) do + nil + else + instance.favicon + |> MediaProxy.url() + end + + last_status_at = + if is_nil(user.last_status_at), do: nil, else: NaiveDateTime.to_date(user.last_status_at) + %{ id: to_string(user.id), username: username_from_nickname(user.nickname), @@ -270,8 +292,12 @@ 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, + akkoma: %{ + instance: render("instance.json", %{instance: instance}), + status_ttl_days: user.status_ttl_days, + permit_followback: user.permit_followback + }, # Pleroma extensions # Note: it's insecure to output :email but fully-qualified nickname may serve as safe stub fqn: User.full_nickname(user), @@ -316,7 +342,8 @@ defp maybe_put_follow_requests_count( %User{id: user_id} ) do count = - User.get_follow_requests(user) + user + |> User.get_follow_requests() |> length() data @@ -335,6 +362,7 @@ defp maybe_put_settings( |> Kernel.put_in([:source, :privacy], user.default_scope) |> Kernel.put_in([:source, :pleroma, :show_role], user.show_role) |> Kernel.put_in([:source, :pleroma, :no_rich_text], user.no_rich_text) + |> Kernel.put_in([:accepts_direct_messages_from], user.accepts_direct_messages_from) end defp maybe_put_settings(data, _, _, _), do: data diff --git a/lib/pleroma/web/mastodon_api/views/conversation_view.ex b/lib/pleroma/web/mastodon_api/views/conversation_view.ex index 46b63b54b..9c9b49c59 100644 --- a/lib/pleroma/web/mastodon_api/views/conversation_view.ex +++ b/lib/pleroma/web/mastodon_api/views/conversation_view.ex @@ -12,7 +12,7 @@ defmodule Pleroma.Web.MastodonAPI.ConversationView do alias Pleroma.Web.MastodonAPI.StatusView def render("participations.json", %{participations: participations, for: user}) do - safe_render_many(participations, __MODULE__, "participation.json", %{ + render_many(participations, __MODULE__, "participation.json", %{ as: :participation, for: user }) diff --git a/lib/pleroma/web/mastodon_api/views/instance_view.ex b/lib/pleroma/web/mastodon_api/views/instance_view.ex index 4fed1af74..2b5354873 100644 --- a/lib/pleroma/web/mastodon_api/views/instance_view.ex +++ b/lib/pleroma/web/mastodon_api/views/instance_view.ex @@ -65,7 +65,11 @@ def features do "shareable_emoji_packs", "multifetch", "pleroma:api/v1/notifications:include_types_filter", + "quote_posting", "editing", + if !Enum.empty?(Config.get([:instance, :local_bubble], [])) do + "bubble_timeline" + end, if Config.get([:media_proxy, :enabled]) do "media_proxy" end, diff --git a/lib/pleroma/web/mastodon_api/views/notification_view.ex b/lib/pleroma/web/mastodon_api/views/notification_view.ex index 463d31d1a..e527ff608 100644 --- a/lib/pleroma/web/mastodon_api/views/notification_view.ex +++ b/lib/pleroma/web/mastodon_api/views/notification_view.ex @@ -66,7 +66,7 @@ def render("index.json", %{notifications: notifications, for: reading_user} = op |> Map.put(:parent_activities, parent_activities) |> Map.put(:relationships, relationships_opt) - safe_render_many(notifications, NotificationView, "show.json", opts) + render_many(notifications, NotificationView, "show.json", opts) end def render( diff --git a/lib/pleroma/web/mastodon_api/views/poll_view.ex b/lib/pleroma/web/mastodon_api/views/poll_view.ex index 71bc8b949..aa6443754 100644 --- a/lib/pleroma/web/mastodon_api/views/poll_view.ex +++ b/lib/pleroma/web/mastodon_api/views/poll_view.ex @@ -68,7 +68,7 @@ defp options_and_votes_count(options) do end) end - defp voters_count(%{data: %{"voters" => [_ | _] = voters}}) do + defp voters_count(%{data: %{"voters" => voters}}) when is_list(voters) do length(voters) end diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index b3a35526e..ac0955534 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -21,6 +21,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do alias Pleroma.Web.MastodonAPI.StatusView alias Pleroma.Web.MediaProxy alias Pleroma.Web.PleromaAPI.EmojiReactionController + require Logger import Pleroma.Web.ActivityPub.Visibility, only: [get_visibility: 1, visible_for_user?: 2] @@ -65,7 +66,7 @@ defp get_replied_to_activities(activities) do # This should be removed in a future version of Pleroma. Pleroma-FE currently # depends on this field, as well. defp get_context_id(%{data: %{"context" => context}}) when is_binary(context) do - use Bitwise + import Bitwise :erlang.crc32(context) |> band(bnot(0x8000_0000)) @@ -87,6 +88,7 @@ defp reblogged?(activity, %User{ap_id: ap_id}) do defp reblogged?(_activity, _user), do: false def render("index.json", opts) do + Logger.debug("Rendering index") reading_user = opts[:for] # To do: check AdminAPIControllerTest on the reasons behind nil activities in the list activities = Enum.filter(opts.activities, & &1) @@ -131,13 +133,15 @@ def render("index.json", opts) do |> Map.put(:parent_activities, parent_activities) |> Map.put(:relationships, relationships_opt) - safe_render_many(activities, StatusView, "show.json", opts) + render_many(activities, StatusView, "show.json", opts) end def render( "show.json", - %{activity: %{data: %{"type" => "Announce", "object" => _object}} = activity} = opts + %{activity: %{id: id, data: %{"type" => "Announce", "object" => _object}} = activity} = + opts ) do + Logger.debug("Rendering reblog #{id}") user = CommonAPI.get_user(activity.data["actor"]) created_at = Utils.to_masto_date(activity.data["published"]) object = Object.normalize(activity, fetch: false) @@ -169,6 +173,7 @@ def render( |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end) {pinned?, pinned_at} = pin_data(object, user) + lang = language(object) %{ id: to_string(activity.id), @@ -182,7 +187,7 @@ def render( in_reply_to_id: nil, in_reply_to_account_id: nil, reblog: reblogged, - content: reblogged[:content] || "", + content: "", created_at: created_at, reblogs_count: 0, replies_count: 0, @@ -199,7 +204,7 @@ def render( mentions: mentions, tags: reblogged[:tags] || [], application: build_application(object.data["generator"]), - language: nil, + language: lang, emojis: [], pleroma: %{ local: activity.local, @@ -208,213 +213,220 @@ def render( } end - def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do - object = Object.normalize(activity, fetch: false) + def render("show.json", %{activity: %{id: id, data: %{"object" => _object}} = activity} = opts) do + Logger.debug("Rendering status #{id}") - user = CommonAPI.get_user(activity.data["actor"]) - user_follower_address = user.follower_address + with %Object{} = object <- Object.normalize(activity, fetch: false) do + user = CommonAPI.get_user(activity.data["actor"]) + user_follower_address = user.follower_address - like_count = object.data["like_count"] || 0 - announcement_count = object.data["announcement_count"] || 0 + like_count = object.data["like_count"] || 0 + announcement_count = object.data["announcement_count"] || 0 - hashtags = Object.hashtags(object) - sensitive = object.data["sensitive"] || Enum.member?(hashtags, "nsfw") + hashtags = Object.hashtags(object) + sensitive = object.data["sensitive"] || Enum.member?(hashtags, "nsfw") - tags = Object.tags(object) + tags = Object.tags(object) - tag_mentions = - tags - |> Enum.filter(fn tag -> is_map(tag) and tag["type"] == "Mention" end) - |> Enum.map(fn tag -> tag["href"] end) + tag_mentions = + tags + |> Enum.filter(fn tag -> is_map(tag) and tag["type"] == "Mention" end) + |> Enum.map(fn tag -> tag["href"] end) - mentions = - (object.data["to"] ++ tag_mentions) - |> Enum.uniq() - |> Enum.map(fn - Pleroma.Constants.as_public() -> nil - ^user_follower_address -> nil - ap_id -> User.get_cached_by_ap_id(ap_id) - end) - |> Enum.filter(& &1) - |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end) + to_data = if is_nil(object.data["to"]), do: [], else: object.data["to"] - favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || []) + mentions = + (to_data ++ tag_mentions) + |> Enum.uniq() + |> Enum.map(fn + Pleroma.Constants.as_public() -> nil + ^user_follower_address -> nil + ap_id -> User.get_cached_by_ap_id(ap_id) + end) + |> Enum.filter(& &1) + |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end) - bookmarked = Activity.get_bookmark(activity, opts[:for]) != nil + favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || []) - client_posted_this_activity = opts[:for] && user.id == opts[:for].id + bookmarked = Activity.get_bookmark(activity, opts[:for]) != nil - expires_at = - with true <- client_posted_this_activity, - %Oban.Job{scheduled_at: scheduled_at} <- - Pleroma.Workers.PurgeExpiredActivity.get_expiration(activity.id) do - scheduled_at - else - _ -> nil - end + client_posted_this_activity = opts[:for] && user.id == opts[:for].id - thread_muted? = - cond do - is_nil(opts[:for]) -> false - is_boolean(activity.thread_muted?) -> activity.thread_muted? - true -> CommonAPI.thread_muted?(opts[:for], activity) - end + expires_at = + with true <- client_posted_this_activity, + %Oban.Job{scheduled_at: scheduled_at} <- + Pleroma.Workers.PurgeExpiredActivity.get_expiration(activity.id) do + scheduled_at + else + _ -> nil + end - attachment_data = object.data["attachment"] || [] - attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment) + thread_muted? = + cond do + is_nil(opts[:for]) -> false + is_boolean(activity.thread_muted?) -> activity.thread_muted? + true -> CommonAPI.thread_muted?(opts[:for], activity) + end - created_at = Utils.to_masto_date(object.data["published"]) + attachment_data = object.data["attachment"] || [] + attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment) - edited_at = - with %{"updated" => updated} <- object.data, - date <- Utils.to_masto_date(updated), - true <- date != "" do - date - else - _ -> - nil - end + created_at = Utils.to_masto_date(object.data["published"]) - reply_to = get_reply_to(activity, opts) + edited_at = + with %{"updated" => updated} <- object.data, + date <- Utils.to_masto_date(updated), + true <- date != "" do + date + else + _ -> + nil + end - reply_to_user = reply_to && CommonAPI.get_user(reply_to.data["actor"]) + reply_to = get_reply_to(activity, opts) - history_len = - 1 + - (Object.Updater.history_for(object.data) - |> Map.get("orderedItems") - |> length()) + reply_to_user = reply_to && CommonAPI.get_user(reply_to.data["actor"]) - # See render("history.json", ...) for more details - # Here the implicit index of the current content is 0 - chrono_order = history_len - 1 + history_len = + 1 + + (Object.Updater.history_for(object.data) + |> Map.get("orderedItems") + |> length()) - content = - object - |> render_content() + # See render("history.json", ...) for more details + # Here the implicit index of the current content is 0 + chrono_order = history_len - 1 - content_html = - content - |> Activity.HTML.get_cached_scrubbed_html_for_activity( - User.html_filter_policy(opts[:for]), - activity, - "mastoapi:content:#{chrono_order}" - ) + content = + object + |> render_content() - content_plaintext = - content - |> Activity.HTML.get_cached_stripped_html_for_activity( - activity, - "mastoapi:content:#{chrono_order}" - ) - - summary = object.data["summary"] || "" - - card = render("card.json", Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity)) - - url = - if user.local do - Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity) - else - object.data["url"] || object.data["external_url"] || object.data["id"] - end - - direct_conversation_id = - with {_, nil} <- {:direct_conversation_id, opts[:direct_conversation_id]}, - {_, true} <- {:include_id, opts[:with_direct_conversation_id]}, - {_, %User{} = for_user} <- {:for_user, opts[:for]} do - Activity.direct_conversation_id(activity, for_user) - else - {:direct_conversation_id, participation_id} when is_integer(participation_id) -> - participation_id - - _e -> - nil - end - - emoji_reactions = - object.data - |> Map.get("reactions", []) - |> EmojiReactionController.filter_allowed_users( - opts[:for], - Map.get(opts, :with_muted, false) - ) - |> Stream.map(fn {emoji, users, url} -> - build_emoji_map(emoji, users, url, opts[:for]) - end) - |> Enum.to_list() - - # Status muted state (would do 1 request per status unless user mutes are preloaded) - muted = - thread_muted? || - UserRelationship.exists?( - get_in(opts, [:relationships, :user_relationships]), - :mute, - opts[:for], - user, - fn for_user, user -> User.mutes?(for_user, user) end + content_html = + content + |> Activity.HTML.get_cached_scrubbed_html_for_activity( + User.html_filter_policy(opts[:for]), + activity, + "mastoapi:content:#{chrono_order}" ) - {pinned?, pinned_at} = pin_data(object, user) + content_plaintext = + content + |> Activity.HTML.get_cached_stripped_html_for_activity( + activity, + "mastoapi:content:#{chrono_order}" + ) - quote = Activity.get_quoted_activity_from_object(object) + summary = object.data["summary"] || "" - %{ - id: to_string(activity.id), - uri: object.data["id"], - url: url, - account: - AccountView.render("show.json", %{ - user: user, - for: opts[:for] - }), - in_reply_to_id: reply_to && to_string(reply_to.id), - in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id), - reblog: nil, - card: card, - content: content_html, - text: opts[:with_source] && get_source_text(object.data["source"]), - created_at: created_at, - edited_at: edited_at, - reblogs_count: announcement_count, - replies_count: object.data["repliesCount"] || 0, - favourites_count: like_count, - reblogged: reblogged?(activity, opts[:for]), - favourited: present?(favorited), - bookmarked: present?(bookmarked), - muted: muted, - pinned: pinned?, - sensitive: sensitive, - spoiler_text: summary, - visibility: get_visibility(object), - media_attachments: attachments, - poll: render(PollView, "show.json", object: object, for: opts[:for]), - mentions: mentions, - tags: build_tags(tags), - application: build_application(object.data["generator"]), - language: nil, - emojis: build_emojis(object.data["emoji"]), - quote_id: if(quote, do: quote.id, else: nil), - quote: maybe_render_quote(quote, opts), - emoji_reactions: emoji_reactions, - pleroma: %{ - local: activity.local, - conversation_id: get_context_id(activity), - context: object.data["context"], - in_reply_to_account_acct: reply_to_user && reply_to_user.nickname, - content: %{"text/plain" => content_plaintext}, - spoiler_text: %{"text/plain" => summary}, - expires_at: expires_at, - direct_conversation_id: direct_conversation_id, - thread_muted: thread_muted?, + card = render("card.json", Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity)) + + url = + if user.local do + url(~p[/notice/#{activity}]) + else + object.data["url"] || object.data["external_url"] || object.data["id"] + end + + direct_conversation_id = + with {_, nil} <- {:direct_conversation_id, opts[:direct_conversation_id]}, + {_, true} <- {:include_id, opts[:with_direct_conversation_id]}, + {_, %User{} = for_user} <- {:for_user, opts[:for]} do + Activity.direct_conversation_id(activity, for_user) + else + {:direct_conversation_id, participation_id} when is_integer(participation_id) -> + participation_id + + _e -> + nil + end + + emoji_reactions = + object.data + |> Map.get("reactions", []) + |> EmojiReactionController.filter_allowed_users( + opts[:for], + Map.get(opts, :with_muted, false) + ) + |> Stream.map(fn {emoji, users, url} -> + build_emoji_map(emoji, users, url, opts[:for]) + end) + |> Enum.to_list() + + # Status muted state (would do 1 request per status unless user mutes are preloaded) + muted = + thread_muted? || + UserRelationship.exists?( + get_in(opts, [:relationships, :user_relationships]), + :mute, + opts[:for], + user, + fn for_user, user -> User.mutes?(for_user, user) end + ) + + {pinned?, pinned_at} = pin_data(object, user) + + quote = Activity.get_quoted_activity_from_object(object) + lang = language(object) + + %{ + id: to_string(activity.id), + uri: object.data["id"], + url: url, + account: + AccountView.render("show.json", %{ + user: user, + for: opts[:for] + }), + in_reply_to_id: reply_to && to_string(reply_to.id), + in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id), + reblog: nil, + card: card, + content: content_html, + text: opts[:with_source] && get_source_text(object.data["source"]), + created_at: created_at, + edited_at: edited_at, + reblogs_count: announcement_count, + replies_count: object.data["repliesCount"] || 0, + favourites_count: like_count, + reblogged: reblogged?(activity, opts[:for]), + favourited: present?(favorited), + bookmarked: present?(bookmarked), + muted: muted, + pinned: pinned?, + sensitive: sensitive, + spoiler_text: summary, + visibility: get_visibility(object), + media_attachments: attachments, + poll: render(PollView, "show.json", object: object, for: opts[:for]), + mentions: mentions, + tags: build_tags(tags), + application: build_application(object.data["generator"]), + language: lang, + emojis: build_emojis(object.data["emoji"]), + quote_id: if(quote, do: quote.id, else: nil), + quote: maybe_render_quote(quote, opts), emoji_reactions: emoji_reactions, - parent_visible: visible_for_user?(reply_to, opts[:for]), - pinned_at: pinned_at - }, - akkoma: %{ - source: object.data["source"] + pleroma: %{ + local: activity.local, + conversation_id: get_context_id(activity), + context: object.data["context"], + in_reply_to_account_acct: reply_to_user && reply_to_user.nickname, + content: %{"text/plain" => content_plaintext}, + spoiler_text: %{"text/plain" => summary}, + expires_at: expires_at, + direct_conversation_id: direct_conversation_id, + thread_muted: thread_muted?, + emoji_reactions: emoji_reactions, + parent_visible: visible_for_user?(reply_to, opts[:for]), + pinned_at: pinned_at + }, + akkoma: %{ + source: object.data["source"] + } } - } + else + nil -> nil + end end def render("show.json", _) do @@ -422,6 +434,7 @@ def render("show.json", _) do end def render("history.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do + Logger.debug("Rendering history for #{activity.id}") object = Object.normalize(activity, fetch: false) hashtags = Object.hashtags(object) @@ -608,6 +621,8 @@ def render("attachment_meta.json", %{ def render("attachment_meta.json", _), do: nil def render("context.json", %{activity: activity, activities: activities, user: user}) do + Logger.debug("Rendering context for #{activity.id}") + %{ancestors: ancestors, descendants: descendants} = activities |> Enum.reverse() @@ -782,4 +797,12 @@ defp get_source_content_type(%{"mediaType" => type} = _source) do defp get_source_content_type(_source) do Utils.get_content_type(nil) end + + defp language(%Object{data: %{"contentMap" => contentMap}}) when is_map(contentMap) do + contentMap + |> Map.keys() + |> Enum.at(0) + end + + defp language(_), do: nil end diff --git a/lib/pleroma/web/mastodon_api/views/tag_view.ex b/lib/pleroma/web/mastodon_api/views/tag_view.ex new file mode 100644 index 000000000..6d3ea3c1a --- /dev/null +++ b/lib/pleroma/web/mastodon_api/views/tag_view.ex @@ -0,0 +1,24 @@ +defmodule Pleroma.Web.MastodonAPI.TagView do + use Pleroma.Web, :view + alias Pleroma.User + + def render("index.json", %{tags: tags, for_user: user}) do + render_many(tags, __MODULE__, "show.json", %{for_user: user}) + end + + def render("show.json", %{tag: tag, for_user: user}) do + following = + with %User{} <- user do + User.following_hashtag?(user, tag) + else + _ -> false + end + + %{ + name: tag.name, + url: url(~p[/tags/#{tag.name}]), + history: [], + following: following + } + end +end diff --git a/lib/pleroma/web/media_proxy.ex b/lib/pleroma/web/media_proxy.ex index 0b232f14b..61b6f2a62 100644 --- a/lib/pleroma/web/media_proxy.ex +++ b/lib/pleroma/web/media_proxy.ex @@ -52,7 +52,7 @@ def url(url) do @spec url_proxiable?(String.t()) :: boolean() def url_proxiable?(url) do - not local?(url) and not whitelisted?(url) + not local?(url) and not whitelisted?(url) and not blocked?(url) end def preview_url(url, preview_params \\ []) do @@ -76,13 +76,23 @@ def whitelisted?(url) do mediaproxy_whitelist_domains = [:media_proxy, :whitelist] - |> Config.get() + |> Config.get([]) |> Kernel.++(["#{Upload.base_url()}"]) |> Enum.map(&maybe_get_domain_from_url/1) domain in mediaproxy_whitelist_domains end + def blocked?(url) do + %{scheme: scheme, host: domain} = URI.parse(url) + # Block either the bare domain or the scheme-domain combo + scheme_domain = "#{scheme}://#{domain}" + blocklist = Config.get([:media_proxy, :blocklist]) + + Enum.member?(blocklist, domain) || + Enum.member?(blocklist, scheme_domain) + end + defp maybe_get_domain_from_url("http" <> _ = url) do URI.parse(url).host end @@ -135,7 +145,7 @@ def filename(url_or_path) do end def base_url do - Config.get([:media_proxy, :base_url], Endpoint.url()) + Config.get!([:media_proxy, :base_url]) end defp proxy_url(path, sig_base64, url_base64, filename) do diff --git a/lib/pleroma/web/metadata/providers/feed.ex b/lib/pleroma/web/metadata/providers/feed.ex index d0ab5c19e..15f47b843 100644 --- a/lib/pleroma/web/metadata/providers/feed.ex +++ b/lib/pleroma/web/metadata/providers/feed.ex @@ -3,9 +3,9 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Metadata.Providers.Feed do - alias Pleroma.Web.Endpoint alias Pleroma.Web.Metadata.Providers.Provider - alias Pleroma.Web.Router.Helpers + + use Pleroma.Web, :verified_routes @behaviour Provider @@ -16,7 +16,7 @@ def build_tags(%{user: user}) do [ rel: "alternate", type: "application/atom+xml", - href: Helpers.user_feed_path(Endpoint, :feed, user.nickname) <> ".atom" + href: ~p[/users/#{user.nickname}/feed.atom] ], []} ] end diff --git a/lib/pleroma/web/metadata/providers/open_graph.ex b/lib/pleroma/web/metadata/providers/open_graph.ex index df0cca74a..27e761bc2 100644 --- a/lib/pleroma/web/metadata/providers/open_graph.ex +++ b/lib/pleroma/web/metadata/providers/open_graph.ex @@ -12,14 +12,38 @@ defmodule Pleroma.Web.Metadata.Providers.OpenGraph do @behaviour Provider @media_types ["image", "audio", "video"] + defp user_avatar_tags(user) do + if Utils.visible?(user) do + [ + {:meta, [property: "og:image", content: MediaProxy.preview_url(User.avatar_url(user))], + []}, + {:meta, [property: "og:image:width", content: 150], []}, + {:meta, [property: "og:image:height", content: 150], []} + ] + else + [] + end + end + @impl Provider def build_tags(%{ object: object, url: url, user: user }) do - attachments = build_attachments(object) - scrubbed_content = Utils.scrub_html_and_truncate(object) + attachments = + if Utils.visible?(object) do + build_attachments(object) + else + [] + end + + scrubbed_content = + if Utils.visible?(object) do + Utils.scrub_html_and_truncate(object) + else + "Content cannot be displayed." + end [ {:meta, @@ -36,12 +60,7 @@ def build_tags(%{ {:meta, [property: "og:type", content: "article"], []} ] ++ if attachments == [] or Metadata.activity_nsfw?(object) do - [ - {:meta, [property: "og:image", content: MediaProxy.preview_url(User.avatar_url(user))], - []}, - {:meta, [property: "og:image:width", content: 150], []}, - {:meta, [property: "og:image:height", content: 150], []} - ] + user_avatar_tags(user) else attachments end @@ -49,7 +68,9 @@ def build_tags(%{ @impl Provider def build_tags(%{user: user}) do - with truncated_bio = Utils.scrub_html_and_truncate(user.bio) do + if Utils.visible?(user) do + truncated_bio = Utils.scrub_html_and_truncate(user.bio) + [ {:meta, [ @@ -58,12 +79,10 @@ def build_tags(%{user: user}) do ], []}, {:meta, [property: "og:url", content: user.uri || user.ap_id], []}, {:meta, [property: "og:description", content: truncated_bio], []}, - {:meta, [property: "og:type", content: "article"], []}, - {:meta, [property: "og:image", content: MediaProxy.preview_url(User.avatar_url(user))], - []}, - {:meta, [property: "og:image:width", content: 150], []}, - {:meta, [property: "og:image:height", content: 150], []} - ] + {:meta, [property: "og:type", content: "article"], []} + ] ++ user_avatar_tags(user) + else + [] end end diff --git a/lib/pleroma/web/metadata/providers/rel_me.ex b/lib/pleroma/web/metadata/providers/rel_me.ex index f013def51..cf6a3a3ab 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 79183df86..c6d8464e7 100644 --- a/lib/pleroma/web/metadata/providers/twitter_card.ex +++ b/lib/pleroma/web/metadata/providers/twitter_card.ex @@ -10,22 +10,35 @@ defmodule Pleroma.Web.Metadata.Providers.TwitterCard do alias Pleroma.Web.Metadata.Providers.Provider alias Pleroma.Web.Metadata.Utils + use Pleroma.Web, :verified_routes + @behaviour Provider @media_types ["image", "audio", "video"] @impl Provider def build_tags(%{activity_id: id, object: object, user: user}) do - attachments = build_attachments(id, object) - scrubbed_content = Utils.scrub_html_and_truncate(object) + attachments = + if Utils.visible?(object) do + build_attachments(id, object) + else + [] + end + + scrubbed_content = + if Utils.visible?(object) do + Utils.scrub_html_and_truncate(object) + else + "Content cannot be displayed." + end [ title_tag(user), - {:meta, [property: "twitter:description", content: scrubbed_content], []} + {:meta, [name: "twitter:description", content: scrubbed_content], []} ] ++ if attachments == [] or Metadata.activity_nsfw?(object) do [ image_tag(user), - {:meta, [property: "twitter:card", content: "summary"], []} + {:meta, [name: "twitter:card", content: "summary"], []} ] else attachments @@ -34,23 +47,30 @@ def build_tags(%{activity_id: id, object: object, user: user}) do @impl Provider def build_tags(%{user: user}) do - with truncated_bio = Utils.scrub_html_and_truncate(user.bio) do - [ - title_tag(user), - {:meta, [property: "twitter:description", content: truncated_bio], []}, - image_tag(user), - {:meta, [property: "twitter:card", content: "summary"], []} - ] + if Utils.visible?(user) do + with truncated_bio = Utils.scrub_html_and_truncate(user.bio) do + [ + title_tag(user), + {:meta, [name: "twitter:description", content: truncated_bio], []}, + image_tag(user), + {:meta, [name: "twitter:card", content: "summary"], []} + ] + end + else + [] end end defp title_tag(user) do - {:meta, [property: "twitter:title", content: Utils.user_name_string(user)], []} + {:meta, [name: "twitter:title", content: Utils.user_name_string(user)], []} end def image_tag(user) do - {:meta, [property: "twitter:image", content: MediaProxy.preview_url(User.avatar_url(user))], - []} + if Utils.visible?(user) do + {:meta, [name: "twitter:image", content: MediaProxy.preview_url(User.avatar_url(user))], []} + else + {:meta, [name: "twitter:image", content: ""], []} + end end defp build_attachments(id, %{data: %{"attachment" => attachments}}) do @@ -60,10 +80,10 @@ defp build_attachments(id, %{data: %{"attachment" => attachments}}) do case Utils.fetch_media_type(@media_types, url["mediaType"]) do "audio" -> [ - {:meta, [property: "twitter:card", content: "player"], []}, - {:meta, [property: "twitter:player:width", content: "480"], []}, - {:meta, [property: "twitter:player:height", content: "80"], []}, - {:meta, [property: "twitter:player", content: player_url(id)], []} + {:meta, [name: "twitter:card", content: "player"], []}, + {:meta, [name: "twitter:player:width", content: "480"], []}, + {:meta, [name: "twitter:player:height", content: "80"], []}, + {:meta, [name: "twitter:player", content: player_url(id)], []} | acc ] @@ -74,10 +94,10 @@ defp build_attachments(id, %{data: %{"attachment" => attachments}}) do # workaround. "image" -> [ - {:meta, [property: "twitter:card", content: "summary_large_image"], []}, + {:meta, [name: "twitter:card", content: "summary_large_image"], []}, {:meta, [ - property: "twitter:player", + name: "twitter:player", content: MediaProxy.url(url["href"]) ], []} | acc @@ -90,14 +110,14 @@ defp build_attachments(id, %{data: %{"attachment" => attachments}}) do width = url["width"] || 480 [ - {:meta, [property: "twitter:card", content: "player"], []}, - {:meta, [property: "twitter:player", content: player_url(id)], []}, - {:meta, [property: "twitter:player:width", content: "#{width}"], []}, - {:meta, [property: "twitter:player:height", content: "#{height}"], []}, - {:meta, [property: "twitter:player:stream", content: MediaProxy.url(url["href"])], + {:meta, [name: "twitter:card", content: "player"], []}, + {:meta, [name: "twitter:player", content: player_url(id)], []}, + {:meta, [name: "twitter:player:width", content: "#{width}"], []}, + {:meta, [name: "twitter:player:height", content: "#{height}"], []}, + {:meta, [name: "twitter:player:stream", content: MediaProxy.url(url["href"])], []}, - {:meta, - [property: "twitter:player:stream:content_type", content: url["mediaType"]], []} + {:meta, [name: "twitter:player:stream:content_type", content: url["mediaType"]], + []} | acc ] @@ -113,7 +133,7 @@ defp build_attachments(id, %{data: %{"attachment" => attachments}}) do defp build_attachments(_id, _object), do: [] defp player_url(id) do - Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice_player, id) + url(~p[/notice/#{id}/embed_player]) end # Videos have problems without dimensions, but we used to not provide WxH for images. @@ -123,8 +143,8 @@ defp maybe_add_dimensions(metadata, url) do !is_nil(url["height"]) && !is_nil(url["width"]) -> metadata ++ [ - {:meta, [property: "twitter:player:width", content: "#{url["width"]}"], []}, - {:meta, [property: "twitter:player:height", content: "#{url["height"]}"], []} + {:meta, [name: "twitter:player:width", content: "#{url["width"]}"], []}, + {:meta, [name: "twitter:player:height", content: "#{url["height"]}"], []} ] true -> diff --git a/lib/pleroma/web/metadata/utils.ex b/lib/pleroma/web/metadata/utils.ex index 8990bef54..55855139b 100644 --- a/lib/pleroma/web/metadata/utils.ex +++ b/lib/pleroma/web/metadata/utils.ex @@ -7,6 +7,15 @@ defmodule Pleroma.Web.Metadata.Utils do alias Pleroma.Emoji alias Pleroma.Formatter alias Pleroma.HTML + alias Pleroma.Web.ActivityPub.Visibility + + def visible?(%Pleroma.User{} = object) do + Visibility.restrict_unauthenticated_access?(object) == :visible + end + + def visible?(object) do + Visibility.visible_for_user?(object, nil) + end defp scrub_html_and_truncate_object_field(field, object) do field @@ -30,6 +39,10 @@ def scrub_html_and_truncate(%{data: %{"content" => content}} = object) do |> scrub_html_and_truncate_object_field(object) end + def scrub_html_and_truncate(%{data: _}) do + "" + end + def scrub_html_and_truncate(content, max_length \\ 200) when is_binary(content) do content |> scrub_html @@ -51,7 +64,7 @@ def scrub_html(content), do: content def user_name_string(user) do "#{user.name} " <> if user.local do - "(@#{user.nickname}@#{Pleroma.Web.Endpoint.host()})" + "(@#{user.nickname}@#{Pleroma.Web.WebFinger.domain()})" else "(@#{user.nickname})" end diff --git a/lib/pleroma/web/mongoose_im/mongoose_im_controller.ex b/lib/pleroma/web/mongoose_im/mongoose_im_controller.ex index 6ace3e0b5..85b75190b 100644 --- a/lib/pleroma/web/mongoose_im/mongoose_im_controller.ex +++ b/lib/pleroma/web/mongoose_im/mongoose_im_controller.ex @@ -7,7 +7,6 @@ defmodule Pleroma.Web.MongooseIM.MongooseIMController do alias Pleroma.Repo alias Pleroma.User - alias Pleroma.Web.Plugs.AuthenticationPlug alias Pleroma.Web.Plugs.RateLimiter plug(RateLimiter, [name: :authentication] when action in [:user_exists, :check_password]) @@ -28,7 +27,7 @@ def user_exists(conn, %{"user" => username}) do def check_password(conn, %{"user" => username, "pass" => password}) do with %User{password_hash: password_hash, is_active: true} <- Repo.get_by(User, nickname: username, local: true), - true <- AuthenticationPlug.checkpw(password, password_hash) do + true <- Pleroma.Password.checkpw(password, password_hash) do conn |> json(true) else diff --git a/lib/pleroma/web/nodeinfo/nodeinfo.ex b/lib/pleroma/web/nodeinfo/nodeinfo.ex index bf0d65f45..532ae53a7 100644 --- a/lib/pleroma/web/nodeinfo/nodeinfo.ex +++ b/lib/pleroma/web/nodeinfo/nodeinfo.ex @@ -71,7 +71,15 @@ def get_nodeinfo("2.0") do restrictedNicknames: Config.get([Pleroma.User, :restricted_nicknames]), skipThreadContainment: Config.get([:instance, :skip_thread_containment], false), privilegedStaff: Config.get([:instance, :privileged_staff]), - localBubbleInstances: Config.get([:instance, :local_bubble], []) + localBubbleInstances: Config.get([:instance, :local_bubble], []), + publicTimelineVisibility: %{ + federated: + !Config.restrict_unauthenticated_access?(:timelines, :federated) && + Config.get([:instance, :federated_timeline_available], true), + local: !Config.restrict_unauthenticated_access?(:timelines, :local), + bubble: !Config.restrict_unauthenticated_access?(:timelines, :bubble) + }, + federatedTimelineAvailable: Config.get([:instance, :federated_timeline_available], true) } } end diff --git a/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex b/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex index a0dee7c6b..ea2d86f92 100644 --- a/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex +++ b/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex @@ -5,12 +5,8 @@ defmodule Pleroma.Web.Nodeinfo.NodeinfoController do use Pleroma.Web, :controller - alias Pleroma.Config - alias Pleroma.Stats - alias Pleroma.User - alias Pleroma.Web.Federator.Publisher - alias Pleroma.Web.MastodonAPI.InstanceView alias Pleroma.Web.Endpoint + alias Pleroma.Web.Nodeinfo.Nodeinfo def schemas(conn, _params) do response = %{ @@ -29,101 +25,15 @@ def schemas(conn, _params) do json(conn, response) end - # returns a nodeinfo 2.0 map, since 2.1 just adds a repository field - # under software. - def raw_nodeinfo do - stats = Stats.get_stats() - - staff_accounts = - User.all_superusers() - |> Enum.map(fn u -> u.ap_id end) - |> Enum.filter(fn u -> not Enum.member?(Config.get([:instance, :staff_transparency]), u) end) - - features = InstanceView.features() - federation = InstanceView.federation() - - %{ - version: "2.0", - software: %{ - name: Pleroma.Application.name() |> String.downcase(), - version: Pleroma.Application.version() - }, - protocols: Publisher.gather_nodeinfo_protocol_names(), - services: %{ - inbound: [], - outbound: [] - }, - openRegistrations: Config.get([:instance, :registrations_open]), - usage: %{ - users: %{ - total: Map.get(stats, :user_count, 0) - }, - localPosts: Map.get(stats, :status_count, 0) - }, - metadata: %{ - nodeName: Config.get([:instance, :name]), - nodeDescription: Config.get([:instance, :description]), - private: !Config.get([:instance, :public], true), - suggestions: %{ - enabled: false - }, - staffAccounts: staff_accounts, - federation: federation, - pollLimits: Config.get([:instance, :poll_limits]), - postFormats: Config.get([:instance, :allowed_post_formats]), - uploadLimits: %{ - general: Config.get([:instance, :upload_limit]), - avatar: Config.get([:instance, :avatar_upload_limit]), - banner: Config.get([:instance, :banner_upload_limit]), - background: Config.get([:instance, :background_upload_limit]) - }, - fieldsLimits: %{ - maxFields: Config.get([:instance, :max_account_fields]), - maxRemoteFields: Config.get([:instance, :max_remote_account_fields]), - nameLength: Config.get([:instance, :account_field_name_length]), - valueLength: Config.get([:instance, :account_field_value_length]) - }, - accountActivationRequired: Config.get([:instance, :account_activation_required], false), - invitesEnabled: Config.get([:instance, :invites_enabled], false), - mailerEnabled: Config.get([Pleroma.Emails.Mailer, :enabled], false), - features: features, - restrictedNicknames: Config.get([Pleroma.User, :restricted_nicknames]), - skipThreadContainment: Config.get([:instance, :skip_thread_containment], false), - localBubbleInstances: Config.get([:instance, :local_bubble], []) - } - } - end - # Schema definition: https://github.com/jhass/nodeinfo/blob/master/schemas/2.0/schema.json # and https://github.com/jhass/nodeinfo/blob/master/schemas/2.1/schema.json - def nodeinfo(conn, %{"version" => "2.0"}) do + def nodeinfo(conn, %{"version" => version}) when version in ["2.0", "2.1"] do conn |> put_resp_header( "content-type", "application/json; profile=http://nodeinfo.diaspora.software/ns/schema/2.0#; charset=utf-8" ) - |> json(raw_nodeinfo()) - end - - def nodeinfo(conn, %{"version" => "2.1"}) do - raw_response = raw_nodeinfo() - - updated_software = - raw_response - |> Map.get(:software) - |> Map.put(:repository, Pleroma.Application.repository()) - - response = - raw_response - |> Map.put(:software, updated_software) - |> Map.put(:version, "2.1") - - conn - |> put_resp_header( - "content-type", - "application/json; profile=http://nodeinfo.diaspora.software/ns/schema/2.1#; charset=utf-8" - ) - |> json(response) + |> json(Nodeinfo.get_nodeinfo(version)) end def nodeinfo(conn, _) do diff --git a/lib/pleroma/web/o_auth/o_auth_controller.ex b/lib/pleroma/web/o_auth/o_auth_controller.ex index 455af11d7..29aa8c10e 100644 --- a/lib/pleroma/web/o_auth/o_auth_controller.ex +++ b/lib/pleroma/web/o_auth/o_auth_controller.ex @@ -39,6 +39,7 @@ defmodule Pleroma.Web.OAuth.OAuthController do action_fallback(Pleroma.Web.OAuth.FallbackController) @oob_token_redirect_uri "urn:ietf:wg:oauth:2.0:oob" + @state_cookie_name "akkoma_oauth_state" # Note: this definition is only called from error-handling methods with `conn.params` as 2nd arg def authorize(%Plug.Conn{} = conn, %{"authorization" => _} = params) do @@ -211,11 +212,11 @@ defp handle_create_authorization_error( {:error, scopes_issue}, %{"authorization" => _} = params ) - when scopes_issue in [:unsupported_scopes, :missing_scopes] do + when scopes_issue in [:unsupported_scopes, :missing_scopes, :user_is_not_an_admin] do # Per https://github.com/tootsuite/mastodon/blob/ # 51e154f5e87968d6bb115e053689767ab33e80cd/app/controllers/api/base_controller.rb#L39 conn - |> put_flash(:error, dgettext("errors", "This action is outside the authorized scopes")) + |> put_flash(:error, dgettext("errors", "This action is outside of authorized scopes")) |> put_status(:unauthorized) |> authorize(params) end @@ -443,13 +444,10 @@ def prepare_request(%Plug.Conn{} = conn, %{ |> Map.put("scope", scope) |> Jason.encode!() - params = - auth_attrs - |> Map.drop(~w(scope scopes client_id redirect_uri)) - |> Map.put("state", state) - # Handing the request to Ueberauth - redirect(conn, to: Routes.o_auth_path(conn, :request, provider, params)) + conn + |> put_resp_cookie(@state_cookie_name, state) + |> redirect(to: ~p"/oauth/#{provider}") end def request(%Plug.Conn{} = conn, params) do @@ -468,20 +466,26 @@ def request(%Plug.Conn{} = conn, params) do end def callback(%Plug.Conn{assigns: %{ueberauth_failure: failure}} = conn, params) do - params = callback_params(params) + params = callback_params(conn, params) messages = for e <- Map.get(failure, :errors, []), do: e.message message = Enum.join(messages, "; ") - conn - |> put_flash( - :error, - dgettext("errors", "Failed to authenticate: %{message}.", message: message) - ) - |> redirect(external: redirect_uri(conn, params["redirect_uri"])) + error_message = dgettext("errors", "Failed to authenticate: %{message}.", message: message) + + if params["redirect_uri"] do + conn + |> put_flash( + :error, + error_message + ) + |> redirect(external: redirect_uri(conn, params["redirect_uri"])) + else + send_resp(conn, :bad_request, error_message) + end end def callback(%Plug.Conn{} = conn, params) do - params = callback_params(params) + params = callback_params(conn, params) with {:ok, registration} <- Authenticator.get_registration(conn) do auth_attrs = Map.take(params, ~w(client_id redirect_uri scope scopes state)) @@ -511,8 +515,9 @@ def callback(%Plug.Conn{} = conn, params) do end end - defp callback_params(%{"state" => state} = params) do - Map.merge(params, Jason.decode!(state)) + defp callback_params(%Plug.Conn{} = conn, params) do + fetch_cookies(conn) + Map.merge(params, Jason.decode!(Map.get(conn.req_cookies, @state_cookie_name, "{}"))) end def registration_details(%Plug.Conn{} = conn, %{"authorization" => auth_attrs}) do @@ -558,10 +563,9 @@ def register(%Plug.Conn{} = conn, %{"authorization" => _, "op" => "register"} = else {:error, changeset} -> message = - Enum.map(changeset.errors, fn {field, {error, _}} -> + Enum.map_join(changeset.errors, "; ", fn {field, {error, _}} -> "#{field} #{error}" end) - |> Enum.join("; ") message = String.replace( @@ -606,7 +610,8 @@ defp do_create_authorization( defp do_create_authorization(%User{} = user, %App{} = app, requested_scopes) when is_list(requested_scopes) do with {:account_status, :active} <- {:account_status, User.account_status(user)}, - {:ok, scopes} <- validate_scopes(app, requested_scopes), + requested_scopes <- Scopes.filter_admin_scopes(requested_scopes, user), + {:ok, scopes} <- validate_scopes(user, app, requested_scopes), {:ok, auth} <- Authorization.create_authorization(app, user, scopes) do {:ok, auth} end @@ -623,7 +628,7 @@ def login(%User{} = user, %App{} = app, requested_scopes) when is_list(requested end # Special case: Local MastodonFE - defp redirect_uri(%Plug.Conn{} = conn, "."), do: Routes.auth_url(conn, :login) + defp redirect_uri(_, "."), do: url(~p"/web/login") defp redirect_uri(%Plug.Conn{}, redirect_uri), do: redirect_uri @@ -638,15 +643,16 @@ defp build_and_response_mfa_token(user, auth) do end end - @spec validate_scopes(App.t(), map() | list()) :: + @spec validate_scopes(User.t(), App.t(), map() | list()) :: {:ok, list()} | {:error, :missing_scopes | :unsupported_scopes} - defp validate_scopes(%App{} = app, params) when is_map(params) do + defp validate_scopes(%User{} = user, %App{} = app, params) when is_map(params) do requested_scopes = Scopes.fetch_scopes(params, app.scopes) - validate_scopes(app, requested_scopes) + validate_scopes(user, app, requested_scopes) end - defp validate_scopes(%App{} = app, requested_scopes) when is_list(requested_scopes) do - Scopes.validate(requested_scopes, app.scopes) + defp validate_scopes(%User{} = user, %App{} = app, requested_scopes) + when is_list(requested_scopes) do + Scopes.validate(requested_scopes, app.scopes, user) end def default_redirect_uri(%App{} = app) do diff --git a/lib/pleroma/web/o_auth/scopes.ex b/lib/pleroma/web/o_auth/scopes.ex index ada43eae9..a170eb33b 100644 --- a/lib/pleroma/web/o_auth/scopes.ex +++ b/lib/pleroma/web/o_auth/scopes.ex @@ -56,12 +56,29 @@ def to_string(scopes), do: Enum.join(scopes, " ") @doc """ Validates scopes. """ - @spec validate(list() | nil, list()) :: - {:ok, list()} | {:error, :missing_scopes | :unsupported_scopes} - def validate(blank_scopes, _app_scopes) when blank_scopes in [nil, []], + @spec validate(list() | nil, list(), Pleroma.User.t()) :: + {:ok, list()} | {:error, :missing_scopes | :unsupported_scopes, :user_is_not_an_admin} + def validate(blank_scopes, _app_scopes, _user) when blank_scopes in [nil, []], do: {:error, :missing_scopes} - def validate(scopes, app_scopes) do + def validate(scopes, app_scopes, _user) do + validate_scopes_are_supported(scopes, app_scopes) + end + + @spec filter_admin_scopes([String.t()], Pleroma.User.t()) :: [String.t()] + @doc """ + Remove admin scopes for non-admins + """ + def filter_admin_scopes(scopes, %Pleroma.User{is_admin: true}), do: scopes + + def filter_admin_scopes(scopes, %Pleroma.User{is_moderator: true}), do: scopes + + def filter_admin_scopes(scopes, _user) do + drop_scopes = OAuthScopesPlug.filter_descendants(scopes, ["admin"]) + Enum.reject(scopes, fn scope -> Enum.member?(drop_scopes, scope) end) + end + + defp validate_scopes_are_supported(scopes, app_scopes) do case OAuthScopesPlug.filter_descendants(scopes, app_scopes) do ^scopes -> {:ok, scopes} _ -> {:error, :unsupported_scopes} diff --git a/lib/pleroma/web/o_status/o_status_controller.ex b/lib/pleroma/web/o_status/o_status_controller.ex index 7731d847f..2b2872c9a 100644 --- a/lib/pleroma/web/o_status/o_status_controller.ex +++ b/lib/pleroma/web/o_status/o_status_controller.ex @@ -14,7 +14,6 @@ defmodule Pleroma.Web.OStatus.OStatusController do alias Pleroma.Web.Fallback.RedirectController alias Pleroma.Web.Metadata.PlayerView alias Pleroma.Web.Plugs.RateLimiter - alias Pleroma.Web.Router plug( RateLimiter, @@ -36,7 +35,7 @@ def object(%{assigns: %{format: format}} = conn, _params) 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)}, + {:activity, Activity.get_local_create_by_object_ap_id(id)}, {_, true} <- {:public?, Visibility.is_public?(activity)}, {_, false} <- {:local_public?, Visibility.is_local_public?(activity)} do redirect(conn, to: "/notice/#{activity.id}") @@ -87,7 +86,7 @@ def notice(%{assigns: %{format: format}} = conn, %{"id" => id}) do %{ activity_id: activity.id, object: object, - url: Router.Helpers.o_status_url(Endpoint, :notice, activity.id), + url: url(~p[/notice/#{activity.id}]), user: user } ) diff --git a/lib/pleroma/web/pleroma_api/controllers/emoji_reaction_controller.ex b/lib/pleroma/web/pleroma_api/controllers/emoji_reaction_controller.ex index 0933363a6..e762fcad8 100644 --- a/lib/pleroma/web/pleroma_api/controllers/emoji_reaction_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/emoji_reaction_controller.ex @@ -41,6 +41,17 @@ def index(%{assigns: %{user: user}} = conn, %{id: activity_id} = params) do end end + defp filter_allowed_user_by_ap_id(ap_ids, excluded_ap_ids) do + Enum.reject(ap_ids, fn ap_id -> + with false <- ap_id in excluded_ap_ids, + %{is_active: true} <- User.get_cached_by_ap_id(ap_id) do + false + else + _ -> true + end + end) + end + def filter_allowed_users(reactions, user, with_muted) do exclude_ap_ids = if is_nil(user) do @@ -51,7 +62,7 @@ def filter_allowed_users(reactions, user, with_muted) do end filter_emoji = fn emoji, users, url -> - case Enum.reject(users, &(&1 in exclude_ap_ids)) do + case filter_allowed_user_by_ap_id(users, exclude_ap_ids) do [] -> nil users -> {emoji, users, url} end diff --git a/lib/pleroma/web/plugs/authentication_plug.ex b/lib/pleroma/web/plugs/authentication_plug.ex index 8d58169cf..894a1067e 100644 --- a/lib/pleroma/web/plugs/authentication_plug.ex +++ b/lib/pleroma/web/plugs/authentication_plug.ex @@ -7,6 +7,7 @@ defmodule Pleroma.Web.Plugs.AuthenticationPlug do alias Pleroma.Helpers.AuthHelper alias Pleroma.User + alias Pleroma.Password import Plug.Conn @@ -25,8 +26,8 @@ def call( } = conn, _ ) do - if checkpw(password, password_hash) do - {:ok, auth_user} = maybe_update_password(auth_user, password) + if Password.checkpw(password, password_hash) do + {:ok, auth_user} = Password.maybe_update_password(auth_user, password) conn |> assign(:user, auth_user) @@ -38,35 +39,6 @@ def call( def call(conn, _), do: conn - def checkpw(password, "$6" <> _ = password_hash) do - :crypt.crypt(password, password_hash) == password_hash - end - - def checkpw(password, "$2" <> _ = password_hash) do - # Handle bcrypt passwords for Mastodon migration - Bcrypt.verify_pass(password, password_hash) - end - - def checkpw(password, "$pbkdf2" <> _ = password_hash) do - Pleroma.Password.Pbkdf2.verify_pass(password, password_hash) - end - - def checkpw(_password, _password_hash) do - Logger.error("Password hash not recognized") - false - end - - def maybe_update_password(%User{password_hash: "$2" <> _} = user, password) do - do_update_password(user, password) - end - - def maybe_update_password(%User{password_hash: "$6" <> _} = user, password) do - do_update_password(user, password) - end - - def maybe_update_password(user, _), do: {:ok, user} - - defp do_update_password(user, password) do - User.reset_password(user, %{password: password, password_confirmation: password}) - end + @spec checkpw(String.t(), String.t()) :: boolean + defdelegate checkpw(password, hash), to: Password end diff --git a/lib/pleroma/web/plugs/csp_nonce_plug.ex b/lib/pleroma/web/plugs/csp_nonce_plug.ex new file mode 100644 index 000000000..bc2c6fcd8 --- /dev/null +++ b/lib/pleroma/web/plugs/csp_nonce_plug.ex @@ -0,0 +1,21 @@ +defmodule Pleroma.Web.Plugs.CSPNoncePlug do + import Plug.Conn + + def init(opts) do + opts + end + + def call(conn, _opts) do + assign_csp_nonce(conn) + end + + defp assign_csp_nonce(conn) do + nonce = + :crypto.strong_rand_bytes(128) + |> Base.url_encode64() + |> binary_part(0, 15) + + conn + |> assign(:csp_nonce, nonce) + end +end diff --git a/lib/pleroma/web/plugs/ensure_http_signature_plug.ex b/lib/pleroma/web/plugs/ensure_http_signature_plug.ex new file mode 100644 index 000000000..c75501a2d --- /dev/null +++ b/lib/pleroma/web/plugs/ensure_http_signature_plug.ex @@ -0,0 +1,31 @@ +# Akkoma: Magically expressive social media +# Copyright © 2022-2022 Akkoma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Plugs.EnsureHTTPSignaturePlug do + @moduledoc """ + Ensures HTTP signature has been validated by previous plugs on ActivityPub requests. + """ + import Plug.Conn + import Phoenix.Controller, only: [get_format: 1, text: 2] + + alias Pleroma.Config + + def init(options) do + options + end + + def call(%{assigns: %{valid_signature: true}} = conn, _), do: conn + + def call(conn, _) do + with true <- get_format(conn) in ["json", "activity+json"], + true <- Config.get([:activitypub, :authorized_fetch_mode], true) do + conn + |> put_status(:unauthorized) + |> text("Request not signed") + |> halt() + else + _ -> conn + end + end +end diff --git a/lib/pleroma/web/plugs/frontend_static.ex b/lib/pleroma/web/plugs/frontend_static.ex index 40f51e149..41b8ba46b 100644 --- a/lib/pleroma/web/plugs/frontend_static.ex +++ b/lib/pleroma/web/plugs/frontend_static.ex @@ -5,17 +5,23 @@ defmodule Pleroma.Web.Plugs.FrontendStatic do require Pleroma.Constants + @frontend_cookie_name "preferred_frontend" + @moduledoc """ This is a shim to call `Plug.Static` but with runtime `from` configuration`. It dispatches to the different frontends. """ @behaviour Plug - def file_path(path, frontend_type \\ :primary) do - if configuration = Pleroma.Config.get([:frontends, frontend_type]) do - instance_static_path = Pleroma.Config.get([:instance, :static_dir], "instance/static") + defp instance_static_path do + Pleroma.Config.get([:instance, :static_dir], "instance/static") + end + def file_path(path, frontend_type \\ :primary) + + def file_path(path, frontend_type) when is_atom(frontend_type) do + if configuration = Pleroma.Config.get([:frontends, frontend_type]) do Path.join([ - instance_static_path, + instance_static_path(), "frontends", configuration["name"], configuration["ref"], @@ -26,6 +32,15 @@ def file_path(path, frontend_type \\ :primary) do end end + def file_path(path, frontend_type) when is_binary(frontend_type) do + Path.join([ + instance_static_path(), + "frontends", + frontend_type, + path + ]) + end + def init(opts) do opts |> Keyword.put(:from, "__unconfigured_frontend_static_plug") @@ -38,7 +53,8 @@ def call(conn, opts) do with false <- api_route?(conn.path_info), false <- invalid_path?(conn.path_info), true <- enabled?(opts[:if]), - frontend_type <- Map.get(opts, :frontend_type, :primary), + fallback_frontend_type <- Map.get(opts, :frontend_type, :primary), + frontend_type <- preferred_or_fallback(conn, fallback_frontend_type), path when not is_nil(path) <- file_path("", frontend_type) do call_static(conn, opts, path) else @@ -47,6 +63,31 @@ def call(conn, opts) do end end + def preferred_frontend(conn) do + %{req_cookies: cookies} = + conn + |> Plug.Conn.fetch_cookies() + + Map.get(cookies, @frontend_cookie_name) + end + + # Only override primary frontend + def preferred_or_fallback(conn, :primary) do + case preferred_frontend(conn) do + nil -> + :primary + + frontend -> + if Enum.member?(Pleroma.Config.get([:frontends, :pickable], []), frontend) do + frontend + else + :primary + end + end + end + + def preferred_or_fallback(_conn, fallback), do: fallback + defp enabled?(if_opt) when is_function(if_opt), do: if_opt.() defp enabled?(true), do: true defp enabled?(_), do: false diff --git a/lib/pleroma/web/plugs/http_security_plug.ex b/lib/pleroma/web/plugs/http_security_plug.ex index d1e6cc9d3..3dfc7e6a3 100644 --- a/lib/pleroma/web/plugs/http_security_plug.ex +++ b/lib/pleroma/web/plugs/http_security_plug.ex @@ -8,12 +8,14 @@ defmodule Pleroma.Web.Plugs.HTTPSecurityPlug do require Logger + @mix_env Mix.env() + def init(opts), do: opts def call(conn, _options) do if Config.get([:http_security, :enabled]) do conn - |> merge_resp_headers(headers()) + |> merge_resp_headers(headers(conn)) |> maybe_send_sts_header(Config.get([:http_security, :sts])) else conn @@ -36,19 +38,19 @@ def custom_http_frontend_headers do end end - def headers do + @spec headers(Plug.Conn.t()) :: [{String.t(), String.t()}] + def headers(conn) do referrer_policy = Config.get([:http_security, :referrer_policy]) report_uri = Config.get([:http_security, :report_uri]) custom_http_frontend_headers = custom_http_frontend_headers() headers = [ - {"x-xss-protection", "1; mode=block"}, + {"x-xss-protection", "0"}, {"x-permitted-cross-domain-policies", "none"}, {"x-frame-options", "DENY"}, {"x-content-type-options", "nosniff"}, {"referrer-policy", referrer_policy}, - {"x-download-options", "noopen"}, - {"content-security-policy", csp_string()}, + {"content-security-policy", csp_string(conn)}, {"permissions-policy", "interest-cohort=()"} ] @@ -68,7 +70,7 @@ def headers do ] } - [{"reply-to", Jason.encode!(report_group)} | headers] + [{"report-to", Jason.encode!(report_group)} | headers] else headers end @@ -76,21 +78,20 @@ def headers do static_csp_rules = [ "default-src 'none'", - "base-uri 'self'", + "base-uri 'none'", "frame-ancestors 'none'", - "style-src 'self' 'unsafe-inline'", - "font-src 'self'", "manifest-src 'self'" ] @csp_start [Enum.join(static_csp_rules, ";") <> ";"] - defp csp_string do + defp csp_string(conn) do scheme = Config.get([Pleroma.Web.Endpoint, :url])[:scheme] static_url = Pleroma.Web.Endpoint.static_url() websocket_url = Pleroma.Web.Endpoint.websocket_url() report_uri = Config.get([:http_security, :report_uri]) - + %{assigns: %{csp_nonce: nonce}} = conn + nonce_tag = "nonce-" <> nonce img_src = "img-src 'self' data: blob:" media_src = "media-src 'self'" @@ -104,20 +105,24 @@ defp csp_string do {[img_src, " https:"], [media_src, " https:"]} end - connect_src = ["connect-src 'self' blob: ", static_url, ?\s, websocket_url] - connect_src = - if Config.get(:env) == :dev do - [connect_src, " http://localhost:3035/"] + if Config.get([:media_proxy, :enabled]) do + sources = build_csp_multimedia_source_list() + ["connect-src 'self' ", static_url, ?\s, websocket_url, ?\s, sources] else - connect_src + ["connect-src 'self' ", static_url, ?\s, websocket_url] end + style_src = "style-src 'self' '#{nonce_tag}'" + font_src = "font-src 'self'" + + script_src = "script-src 'self' '#{nonce_tag}' " + script_src = - if Config.get(:env) == :dev do - "script-src 'self' 'unsafe-eval'" + if @mix_env == :dev do + "script-src 'self' 'unsafe-eval' 'unsafe-inline'" else - "script-src 'self'" + script_src end report = if report_uri, do: ["report-uri ", report_uri, ";report-to csp-endpoint"] @@ -128,6 +133,8 @@ defp csp_string do |> add_csp_param(media_src) |> add_csp_param(connect_src) |> add_csp_param(script_src) + |> add_csp_param(font_src) + |> add_csp_param(style_src) |> add_csp_param(insecure) |> add_csp_param(report) |> :erlang.iolist_to_binary() @@ -193,7 +200,7 @@ defp build_csp_param(url) when is_binary(url) do def warn_if_disabled do unless Config.get([:http_security, :enabled]) do - Logger.warn(" + Logger.warning(" .i;;;;i. iYcviii;vXY: .YXi .i1c. @@ -238,11 +245,9 @@ def warn_if_disabled do defp maybe_send_sts_header(conn, true) do max_age_sts = Config.get([:http_security, :sts_max_age]) - max_age_ct = Config.get([:http_security, :ct_max_age]) merge_resp_headers(conn, [ - {"strict-transport-security", "max-age=#{max_age_sts}; includeSubDomains"}, - {"expect-ct", "enforce, max-age=#{max_age_ct}"} + {"strict-transport-security", "max-age=#{max_age_sts}; includeSubDomains; preload"} ]) end diff --git a/lib/pleroma/web/plugs/http_signature_plug.ex b/lib/pleroma/web/plugs/http_signature_plug.ex index c906a4eec..eb6a46736 100644 --- a/lib/pleroma/web/plugs/http_signature_plug.ex +++ b/lib/pleroma/web/plugs/http_signature_plug.ex @@ -4,11 +4,16 @@ defmodule Pleroma.Web.Plugs.HTTPSignaturePlug do import Plug.Conn - import Phoenix.Controller, only: [get_format: 1, text: 2] + import Phoenix.Controller, only: [get_format: 1] + + use Pleroma.Web, :verified_routes alias Pleroma.Activity - alias Pleroma.Web.Router + alias Pleroma.Signature + alias Pleroma.Instances require Logger + @cachex Pleroma.Config.get([:cachex, :provider], Cachex) + def init(options) do options end @@ -18,7 +23,7 @@ def call(%{assigns: %{valid_signature: true}} = conn, _opts) do end def call(conn, _opts) do - if get_format(conn) == "activity+json" do + if get_format(conn) in ["json", "activity+json"] do conn |> maybe_assign_valid_signature() |> maybe_require_signature() @@ -28,10 +33,10 @@ def call(conn, _opts) do end def route_aliases(%{path_info: ["objects", id], query_string: query_string}) do - ap_id = Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :object, id) + ap_id = url(~p[/objects/#{id}]) with %Activity{} = activity <- Activity.get_by_object_ap_id_with_object(ap_id) do - ["/notice/#{activity.id}", "/notice/#{activity.id}?#{query_string}"] + [~p"/notice/#{activity.id}", "/notice/#{activity.id}?#{query_string}"] else _ -> [] end @@ -57,6 +62,7 @@ defp assign_valid_signature_on_route_aliases(conn, [path | rest]) do conn |> assign(:valid_signature, HTTPSignatures.validate_conn(conn)) + |> assign(:signature_actor_id, signature_host(conn)) |> assign_valid_signature_on_route_aliases(rest) end @@ -78,16 +84,45 @@ defp has_signature_header?(conn) do conn |> get_req_header("signature") |> Enum.at(0, false) end - defp maybe_require_signature(%{assigns: %{valid_signature: true}} = conn), do: conn + defp maybe_require_signature( + %{assigns: %{valid_signature: true, signature_actor_id: actor_id}} = conn + ) do + # inboxes implicitly need http signatures for authentication + # so we don't really know if the instance will have broken federation after + # we turn on authorized_fetch_mode. + # + # to "check" this is a signed fetch, verify if method is GET + if conn.method == "GET" do + actor_host = URI.parse(actor_id).host - defp maybe_require_signature(conn) do - if Pleroma.Config.get([:activitypub, :authorized_fetch_mode], false) do - conn - |> put_status(:unauthorized) - |> text("Request not signed") - |> halt() + case @cachex.get(:request_signatures_cache, actor_host) do + {:ok, nil} -> + Logger.debug("Successful signature from #{actor_host}") + Instances.set_request_signatures(actor_host) + @cachex.put(:request_signatures_cache, actor_host, true) + + {:ok, true} -> + :noop + + any -> + Logger.warning( + "expected request signature cache to return a boolean, instead got #{inspect(any)}" + ) + end + end + + conn + end + + defp maybe_require_signature(conn), do: conn + + defp signature_host(conn) do + with %{"keyId" => kid} <- HTTPSignatures.signature_for_conn(conn), + {:ok, actor_id} <- Signature.key_id_to_actor_id(kid) do + actor_id else - conn + e -> + {:error, e} end end end diff --git a/lib/pleroma/web/plugs/instance_static.ex b/lib/pleroma/web/plugs/instance_static.ex index 723b25679..b72b604a1 100644 --- a/lib/pleroma/web/plugs/instance_static.ex +++ b/lib/pleroma/web/plugs/instance_static.ex @@ -3,8 +3,12 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Plugs.InstanceStatic do + import Plug.Conn + require Pleroma.Constants + alias Pleroma.Web.Plugs.Utils + @moduledoc """ This is a shim to call `Plug.Static` but with runtime `from` configuration. @@ -12,11 +16,11 @@ defmodule Pleroma.Web.Plugs.InstanceStatic do """ @behaviour Plug - def file_path(path) do + def file_path(path, frontend_type \\ :primary) do instance_path = Path.join(Pleroma.Config.get([:instance, :static_dir], "instance/static/"), path) - frontend_path = Pleroma.Web.Plugs.FrontendStatic.file_path(path, :primary) + frontend_path = Pleroma.Web.Plugs.FrontendStatic.file_path(path, frontend_type) (File.exists?(instance_path) && instance_path) || (frontend_path && File.exists?(frontend_path) && frontend_path) || @@ -43,11 +47,25 @@ def call(conn, _) do conn end - defp call_static(conn, opts, from) do + defp set_static_content_type(conn, "/emoji/" <> _ = request_path) do + real_mime = MIME.from_path(request_path) + safe_mime = Utils.get_safe_mime_type(%{allowed_mime_types: ["image"]}, real_mime) + + put_resp_header(conn, "content-type", safe_mime) + end + + defp set_static_content_type(conn, request_path) do + put_resp_header(conn, "content-type", MIME.from_path(request_path)) + end + + defp call_static(%{request_path: request_path} = conn, opts, from) do opts = opts |> Map.put(:from, from) + |> Map.put(:set_content_type, false) - Plug.Static.call(conn, opts) + conn + |> set_static_content_type(request_path) + |> Pleroma.Web.Plugs.StaticNoCT.call(opts) end end diff --git a/lib/pleroma/web/plugs/o_auth_scopes_plug.ex b/lib/pleroma/web/plugs/o_auth_scopes_plug.ex index f017c8bc7..e4d098a7d 100644 --- a/lib/pleroma/web/plugs/o_auth_scopes_plug.ex +++ b/lib/pleroma/web/plugs/o_auth_scopes_plug.ex @@ -34,7 +34,9 @@ def perform(%Plug.Conn{assigns: assigns} = conn, %{scopes: scopes} = options) do permissions = Enum.join(missing_scopes, " #{op} ") error_message = - dgettext("errors", "Insufficient permissions: %{permissions}.", permissions: permissions) + dgettext("errors", "Insufficient permissions: %{permissions}.", + permissions: permissions + ) conn |> put_resp_content_type("application/json") diff --git a/lib/pleroma/web/plugs/parsers/multipart.ex b/lib/pleroma/web/plugs/parsers/multipart.ex new file mode 100644 index 000000000..6b5ab6af4 --- /dev/null +++ b/lib/pleroma/web/plugs/parsers/multipart.ex @@ -0,0 +1,21 @@ +defmodule Pleroma.Web.Plugs.Parsers.Multipart do + @multipart Plug.Parsers.MULTIPART + + alias Pleroma.Config + + def init(opts) do + opts + end + + def parse(conn, "multipart", subtype, headers, opts) do + length = 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/plugs/rate_limiter.ex b/lib/pleroma/web/plugs/rate_limiter.ex index 5bebe0ad5..4bcf027d6 100644 --- a/lib/pleroma/web/plugs/rate_limiter.ex +++ b/lib/pleroma/web/plugs/rate_limiter.ex @@ -89,7 +89,7 @@ def call(conn, plug_opts) do end defp handle_disabled(conn) do - Logger.warn( + Logger.warning( "Rate limiter disabled due to forwarded IP not being found. Please ensure your reverse proxy is providing the X-Forwarded-For header or disable the RemoteIP plug/rate limiter." ) @@ -197,12 +197,18 @@ defp incorporate_conn_info(action_settings, %{params: params} = conn) do }) end - defp ip(%{remote_ip: remote_ip}) do + defp ip(%{remote_ip: remote_ip}) when is_binary(remote_ip) do + remote_ip + end + + defp ip(%{remote_ip: remote_ip}) when is_tuple(remote_ip) do remote_ip |> Tuple.to_list() |> Enum.join(".") end + defp ip(_), do: nil + defp render_throttled_error(conn) do conn |> render_error(:too_many_requests, "Throttled") diff --git a/lib/pleroma/web/plugs/remote_ip.ex b/lib/pleroma/web/plugs/remote_ip.ex index 4d7daca56..d992dea63 100644 --- a/lib/pleroma/web/plugs/remote_ip.ex +++ b/lib/pleroma/web/plugs/remote_ip.ex @@ -8,7 +8,6 @@ defmodule Pleroma.Web.Plugs.RemoteIp do """ alias Pleroma.Config - import Plug.Conn @behaviour Plug @@ -16,15 +15,21 @@ def init(_), do: nil def call(%{remote_ip: original_remote_ip} = conn, _) do if Config.get([__MODULE__, :enabled]) do - %{remote_ip: new_remote_ip} = conn = RemoteIp.call(conn, remote_ip_opts()) - assign(conn, :remote_ip_found, original_remote_ip != new_remote_ip) + {headers, proxies} = remote_ip_opts() + new_remote_ip = RemoteIp.from(conn.req_headers, headers: headers, proxies: proxies) + + if new_remote_ip != original_remote_ip do + Map.put(conn, :remote_ip, new_remote_ip) + else + conn + end else conn end end defp remote_ip_opts do - headers = Config.get([__MODULE__, :headers], []) |> MapSet.new() + headers = Config.get([__MODULE__, :headers], []) reserved = Config.get([__MODULE__, :reserved], []) proxies = @@ -36,13 +41,10 @@ defp remote_ip_opts do end defp maybe_add_cidr(proxy) when is_binary(proxy) do - proxy = - cond do - "/" in String.codepoints(proxy) -> proxy - InetCidr.v4?(InetCidr.parse_address!(proxy)) -> proxy <> "/32" - InetCidr.v6?(InetCidr.parse_address!(proxy)) -> proxy <> "/128" - end - - InetCidr.parse(proxy, true) + cond do + "/" in String.codepoints(proxy) -> proxy + InetCidr.v4?(InetCidr.parse_address!(proxy)) -> proxy <> "/32" + InetCidr.v6?(InetCidr.parse_address!(proxy)) -> proxy <> "/128" + end end end diff --git a/lib/pleroma/web/plugs/static_fe_plug.ex b/lib/pleroma/web/plugs/static_fe_plug.ex index 9ba9dc5ff..049a4ffbe 100644 --- a/lib/pleroma/web/plugs/static_fe_plug.ex +++ b/lib/pleroma/web/plugs/static_fe_plug.ex @@ -9,7 +9,7 @@ defmodule Pleroma.Web.Plugs.StaticFEPlug do def init(options), do: options def call(conn, _) do - if enabled?() and requires_html?(conn) do + if enabled?() and requires_html?(conn) and not_logged_in?(conn) do conn |> StaticFEController.call(:show) |> halt() @@ -23,4 +23,7 @@ defp enabled?, do: Pleroma.Config.get([:static_fe, :enabled], false) defp requires_html?(conn) do Phoenix.Controller.get_format(conn) == "html" end + + defp not_logged_in?(%{assigns: %{user: %Pleroma.User{}}}), do: false + defp not_logged_in?(_), do: true end diff --git a/lib/pleroma/web/plugs/static_no_content_type.ex b/lib/pleroma/web/plugs/static_no_content_type.ex new file mode 100644 index 000000000..ea00a2d5d --- /dev/null +++ b/lib/pleroma/web/plugs/static_no_content_type.ex @@ -0,0 +1,469 @@ +# This is almost identical to Plug.Static from Plug 1.15.3 (2024-01-16) +# It being copied is a temporary measure to fix an urgent bug without +# needing to wait for merge of a suitable patch upstream +# The differences are: +# - this leading comment +# - renaming of the module from 'Plug.Static' to 'Pleroma.Web.Plugs.StaticNoCT' +# - additon of set_content_type option + +defmodule Pleroma.Web.Plugs.StaticNoCT do + @moduledoc """ + A plug for serving static assets. + + It requires two options: + + * `:at` - the request path to reach for static assets. + It must be a string. + + * `:from` - the file system path to read static assets from. + It can be either: a string containing a file system path, an + atom representing the application name (where assets will + be served from `priv/static`), a tuple containing the + application name and the directory to serve assets from (besides + `priv/static`), or an MFA tuple. + + The preferred form is to use `:from` with an atom or tuple, since + it will make your application independent from the starting directory. + For example, if you pass: + + plug Plug.Static, from: "priv/app/path" + + Plug.Static will be unable to serve assets if you build releases + or if you change the current directory. Instead do: + + plug Plug.Static, from: {:app_name, "priv/app/path"} + + If a static asset cannot be found, `Plug.Static` simply forwards + the connection to the rest of the pipeline. + + ## Cache mechanisms + + `Plug.Static` uses etags for HTTP caching. This means browsers/clients + should cache assets on the first request and validate the cache on + following requests, not downloading the static asset once again if it + has not changed. The cache-control for etags is specified by the + `cache_control_for_etags` option and defaults to `"public"`. + + However, `Plug.Static` also supports direct cache control by using + versioned query strings. If the request query string starts with + "?vsn=", `Plug.Static` assumes the application is versioning assets + and does not set the `ETag` header, meaning the cache behaviour will + be specified solely by the `cache_control_for_vsn_requests` config, + which defaults to `"public, max-age=31536000"`. + + ## Options + + * `:encodings` - list of 2-ary tuples where first value is value of + the `Accept-Encoding` header and second is extension of the file to + be served if given encoding is accepted by client. Entries will be tested + in order in list, so entries higher in list will be preferred. Defaults + to: `[]`. + + In addition to setting this value directly it supports 2 additional + options for compatibility reasons: + + + `:brotli` - will append `{"br", ".br"}` to the encodings list. + + `:gzip` - will append `{"gzip", ".gz"}` to the encodings list. + + Additional options will be added in the above order (Brotli takes + preference over Gzip) to reflect older behaviour which was set due + to fact that Brotli in general provides better compression ratio than + Gzip. + + * `:cache_control_for_etags` - sets the cache header for requests + that use etags. Defaults to `"public"`. + + * `:etag_generation` - specify a `{module, function, args}` to be used + to generate an etag. The `path` of the resource will be passed to + the function, as well as the `args`. If this option is not supplied, + etags will be generated based off of file size and modification time. + Note it is [recommended for the etag value to be quoted](https://tools.ietf.org/html/rfc7232#section-2.3), + which Plug won't do automatically. + + * `:cache_control_for_vsn_requests` - sets the cache header for + requests starting with "?vsn=" in the query string. Defaults to + `"public, max-age=31536000"`. + + * `:only` - filters which requests to serve. This is useful to avoid + file system access on every request when this plug is mounted + at `"/"`. For example, if `only: ["images", "favicon.ico"]` is + specified, only files in the "images" directory and the + "favicon.ico" file will be served by `Plug.Static`. + Note that `Plug.Static` matches these filters against request + uri and not against the filesystem. When requesting + a file with name containing non-ascii or special characters, + you should use urlencoded form. For example, you should write + `only: ["file%20name"]` instead of `only: ["file name"]`. + Defaults to `nil` (no filtering). + + * `:only_matching` - a relaxed version of `:only` that will + serve any request as long as one of the given values matches the + given path. For example, `only_matching: ["images", "favicon"]` + will match any request that starts at "images" or "favicon", + be it "/images/foo.png", "/images-high/foo.png", "/favicon.ico" + or "/favicon-high.ico". Such matches are useful when serving + digested files at the root. Defaults to `nil` (no filtering). + + * `:headers` - other headers to be set when serving static assets. Specify either + an enum of key-value pairs or a `{module, function, args}` to return an enum. The + `conn` will be passed to the function, as well as the `args`. + + * `:content_types` - custom MIME type mapping. As a map with filename as key + and content type as value. For example: + `content_types: %{"apple-app-site-association" => "application/json"}`. + + * `:set_content_type` - by default Plug.Static (re)sets the content type header + using auto-detection and the `:content_types` map. But when set to `false` + no content-type header will be inserted instead retaining the original + value or lack thereof. This can be useful when custom logic for appropiate + content types is needed which cannot be reasonably expressed as a static + filename map. + + ## Examples + + This plug can be mounted in a `Plug.Builder` pipeline as follows: + + defmodule MyPlug do + use Plug.Builder + + plug Plug.Static, + at: "/public", + from: :my_app, + only: ~w(images robots.txt) + plug :not_found + + def not_found(conn, _) do + send_resp(conn, 404, "not found") + end + end + + """ + + @behaviour Plug + @allowed_methods ~w(GET HEAD) + + import Plug.Conn + alias Plug.Conn + + # In this module, the `:prim_file` Erlang module along with the `:file_info` + # record are used instead of the more common and Elixir-y `File` module and + # `File.Stat` struct, respectively. The reason behind this is performance: all + # the `File` operations pass through a single process in order to support node + # operations that we simply don't need when serving assets. + + require Record + Record.defrecordp(:file_info, Record.extract(:file_info, from_lib: "kernel/include/file.hrl")) + + defmodule InvalidPathError do + defexception message: "invalid path for static asset", plug_status: 400 + end + + @impl true + def init(opts) do + from = + case Keyword.fetch!(opts, :from) do + {_, _} = from -> from + {_, _, _} = from -> from + from when is_atom(from) -> {from, "priv/static"} + from when is_binary(from) -> from + _ -> raise ArgumentError, ":from must be an atom, a binary or a tuple" + end + + encodings = + opts + |> Keyword.get(:encodings, []) + |> maybe_add("br", ".br", Keyword.get(opts, :brotli, false)) + |> maybe_add("gzip", ".gz", Keyword.get(opts, :gzip, false)) + + %{ + encodings: encodings, + only_rules: {Keyword.get(opts, :only, []), Keyword.get(opts, :only_matching, [])}, + qs_cache: Keyword.get(opts, :cache_control_for_vsn_requests, "public, max-age=31536000"), + et_cache: Keyword.get(opts, :cache_control_for_etags, "public"), + et_generation: Keyword.get(opts, :etag_generation, nil), + headers: Keyword.get(opts, :headers, %{}), + content_types: Keyword.get(opts, :content_types, %{}), + set_content_type: Keyword.get(opts, :set_content_type, true), + from: from, + at: opts |> Keyword.fetch!(:at) |> Plug.Router.Utils.split() + } + end + + @impl true + def call( + conn = %Conn{method: meth}, + %{at: at, only_rules: only_rules, from: from, encodings: encodings} = options + ) + when meth in @allowed_methods do + segments = subset(at, conn.path_info) + + if allowed?(only_rules, segments) do + segments = Enum.map(segments, &uri_decode/1) + + if invalid_path?(segments) do + raise InvalidPathError, "invalid path for static asset: #{conn.request_path}" + end + + path = path(from, segments) + range = get_req_header(conn, "range") + encoding = file_encoding(conn, path, range, encodings) + serve_static(encoding, conn, segments, range, options) + else + conn + end + end + + def call(conn, _options) do + conn + end + + defp uri_decode(path) do + # TODO: Remove rescue as this can't fail from Elixir v1.13 + try do + URI.decode(path) + rescue + ArgumentError -> + raise InvalidPathError + end + end + + defp allowed?(_only_rules, []), do: false + defp allowed?({[], []}, _list), do: true + + defp allowed?({full, prefix}, [h | _]) do + h in full or (prefix != [] and match?({0, _}, :binary.match(h, prefix))) + end + + defp maybe_put_content_type(conn, false, _, _), do: conn + + defp maybe_put_content_type(conn, _, types, filename) do + content_type = Map.get(types, filename) || MIME.from_path(filename) + + conn + |> put_resp_header("content-type", content_type) + end + + defp serve_static({content_encoding, file_info, path}, conn, segments, range, options) do + %{ + qs_cache: qs_cache, + et_cache: et_cache, + et_generation: et_generation, + headers: headers, + content_types: types, + set_content_type: set_content_type + } = options + + case put_cache_header(conn, qs_cache, et_cache, et_generation, file_info, path) do + {:stale, conn} -> + filename = List.last(segments) + + conn + |> maybe_put_content_type(set_content_type, types, filename) + |> put_resp_header("accept-ranges", "bytes") + |> maybe_add_encoding(content_encoding) + |> merge_headers(headers) + |> serve_range(file_info, path, range, options) + + {:fresh, conn} -> + conn + |> maybe_add_vary(options) + |> send_resp(304, "") + |> halt() + end + end + + defp serve_static(:error, conn, _segments, _range, _options) do + conn + end + + defp serve_range(conn, file_info, path, [range], options) do + file_info(size: file_size) = file_info + + with %{"bytes" => bytes} <- Plug.Conn.Utils.params(range), + {range_start, range_end} <- start_and_end(bytes, file_size) do + send_range(conn, path, range_start, range_end, file_size, options) + else + _ -> send_entire_file(conn, path, options) + end + end + + defp serve_range(conn, _file_info, path, _range, options) do + send_entire_file(conn, path, options) + end + + defp start_and_end("-" <> rest, file_size) do + case Integer.parse(rest) do + {last, ""} when last > 0 and last <= file_size -> {file_size - last, file_size - 1} + _ -> :error + end + end + + defp start_and_end(range, file_size) do + case Integer.parse(range) do + {first, "-"} when first >= 0 -> + {first, file_size - 1} + + {first, "-" <> rest} when first >= 0 -> + case Integer.parse(rest) do + {last, ""} when last >= first -> {first, min(last, file_size - 1)} + _ -> :error + end + + _ -> + :error + end + end + + defp send_range(conn, path, 0, range_end, file_size, options) when range_end == file_size - 1 do + send_entire_file(conn, path, options) + end + + defp send_range(conn, path, range_start, range_end, file_size, _options) do + length = range_end - range_start + 1 + + conn + |> put_resp_header("content-range", "bytes #{range_start}-#{range_end}/#{file_size}") + |> send_file(206, path, range_start, length) + |> halt() + end + + defp send_entire_file(conn, path, options) do + conn + |> maybe_add_vary(options) + |> send_file(200, path) + |> halt() + end + + defp maybe_add_encoding(conn, nil), do: conn + defp maybe_add_encoding(conn, ce), do: put_resp_header(conn, "content-encoding", ce) + + defp maybe_add_vary(conn, %{encodings: encodings}) do + # If we serve gzip or brotli at any moment, we need to set the proper vary + # header regardless of whether we are serving gzip content right now. + # See: http://www.fastly.com/blog/best-practices-for-using-the-vary-header/ + if encodings != [] do + update_in(conn.resp_headers, &[{"vary", "Accept-Encoding"} | &1]) + else + conn + end + end + + defp put_cache_header( + %Conn{query_string: "vsn=" <> _} = conn, + qs_cache, + _et_cache, + _et_generation, + _file_info, + _path + ) + when is_binary(qs_cache) do + {:stale, put_resp_header(conn, "cache-control", qs_cache)} + end + + defp put_cache_header(conn, _qs_cache, et_cache, et_generation, file_info, path) + when is_binary(et_cache) do + etag = etag_for_path(file_info, et_generation, path) + + conn = + conn + |> put_resp_header("cache-control", et_cache) + |> put_resp_header("etag", etag) + + if etag in get_req_header(conn, "if-none-match") do + {:fresh, conn} + else + {:stale, conn} + end + end + + defp put_cache_header(conn, _, _, _, _, _) do + {:stale, conn} + end + + defp etag_for_path(file_info, et_generation, path) do + case et_generation do + {module, function, args} -> + apply(module, function, [path | args]) + + nil -> + file_info(size: size, mtime: mtime) = file_info + < :erlang.phash2() |> Integer.to_string(16)::binary, ?">> + end + end + + defp file_encoding(conn, path, [_range], _encodings) do + # We do not support compression for range queries. + file_encoding(conn, path, nil, []) + end + + defp file_encoding(conn, path, _range, encodings) do + encoded = + Enum.find_value(encodings, fn {encoding, ext} -> + if file_info = accept_encoding?(conn, encoding) && regular_file_info(path <> ext) do + {encoding, file_info, path <> ext} + end + end) + + cond do + not is_nil(encoded) -> + encoded + + file_info = regular_file_info(path) -> + {nil, file_info, path} + + true -> + :error + end + end + + defp regular_file_info(path) do + case :prim_file.read_file_info(path) do + {:ok, file_info(type: :regular) = file_info} -> + file_info + + _ -> + nil + end + end + + defp accept_encoding?(conn, encoding) do + encoding? = &String.contains?(&1, [encoding, "*"]) + + Enum.any?(get_req_header(conn, "accept-encoding"), fn accept -> + accept |> Plug.Conn.Utils.list() |> Enum.any?(encoding?) + end) + end + + defp maybe_add(list, key, value, true), do: list ++ [{key, value}] + defp maybe_add(list, _key, _value, false), do: list + + defp path({module, function, arguments}, segments) + when is_atom(module) and is_atom(function) and is_list(arguments), + do: Enum.join([apply(module, function, arguments) | segments], "/") + + defp path({app, from}, segments) when is_atom(app) and is_binary(from), + do: Enum.join([Application.app_dir(app), from | segments], "/") + + defp path(from, segments), + do: Enum.join([from | segments], "/") + + defp subset([h | expected], [h | actual]), do: subset(expected, actual) + defp subset([], actual), do: actual + defp subset(_, _), do: [] + + defp invalid_path?(list) do + invalid_path?(list, :binary.compile_pattern(["/", "\\", ":", "\0"])) + end + + defp invalid_path?([h | _], _match) when h in [".", "..", ""], do: true + defp invalid_path?([h | t], match), do: String.contains?(h, match) or invalid_path?(t) + defp invalid_path?([], _match), do: false + + defp merge_headers(conn, {module, function, args}) do + merge_headers(conn, apply(module, function, [conn | args])) + end + + defp merge_headers(conn, headers) do + merge_resp_headers(conn, headers) + end +end diff --git a/lib/pleroma/web/plugs/uploaded_media.ex b/lib/pleroma/web/plugs/uploaded_media.ex index 7b87d8f17..746203087 100644 --- a/lib/pleroma/web/plugs/uploaded_media.ex +++ b/lib/pleroma/web/plugs/uploaded_media.ex @@ -11,6 +11,7 @@ defmodule Pleroma.Web.Plugs.UploadedMedia do require Logger alias Pleroma.Web.MediaProxy + alias Pleroma.Web.Plugs.Utils @behaviour Plug # no slashes @@ -28,26 +29,34 @@ def init(_opts) do |> Keyword.put(:at, "/__unconfigured_media_plug") |> Plug.Static.init() - %{static_plug_opts: static_plug_opts} + config = Pleroma.Config.get(Pleroma.Upload) + allowed_mime_types = Keyword.fetch!(config, :allowed_mime_types) + uploader = Keyword.fetch!(config, :uploader) + + %{ + static_plug_opts: static_plug_opts, + allowed_mime_types: allowed_mime_types, + uploader: uploader + } end - def call(%{request_path: <<"/", @path, "/", file::binary>>} = conn, opts) do + def call( + %{request_path: <<"/", @path, "/", file::binary>>} = conn, + %{uploader: uploader} = opts + ) do conn = case fetch_query_params(conn) do %{query_params: %{"name" => name}} = conn -> - name = String.replace(name, "\"", "\\\"") + name = escape_header_value(name) - put_resp_header(conn, "content-disposition", "filename=\"#{name}\"") + put_resp_header(conn, "content-disposition", ~s[inline; filename="#{name}"]) conn -> conn end |> merge_resp_headers([{"content-security-policy", "sandbox"}]) - config = Pleroma.Config.get(Pleroma.Upload) - - with uploader <- Keyword.fetch!(config, :uploader), - {:ok, get_method} <- uploader.get_file(file), + with {:ok, get_method} <- uploader.get_file(file), false <- media_is_banned(conn, get_method) do get_media(conn, get_method, opts) else @@ -68,13 +77,23 @@ defp media_is_banned(_, {:url, url}), do: MediaProxy.in_banned_urls(url) defp media_is_banned(_, _), do: false + defp set_content_type(conn, opts, filepath) do + real_mime = MIME.from_path(filepath) + clean_mime = Utils.get_safe_mime_type(opts, real_mime) + put_resp_header(conn, "content-type", clean_mime) + end + defp get_media(conn, {:static_dir, directory}, opts) do static_opts = Map.get(opts, :static_plug_opts) |> Map.put(:at, [@path]) |> Map.put(:from, directory) + |> Map.put(:set_content_type, false) - conn = Plug.Static.call(conn, static_opts) + conn = + conn + |> set_content_type(opts, conn.request_path) + |> Pleroma.Web.Plugs.StaticNoCT.call(static_opts) if conn.halted do conn @@ -98,4 +117,11 @@ defp get_media(conn, unknown, _) do |> send_resp(:internal_server_error, dgettext("errors", "Internal Error")) |> halt() end + + defp escape_header_value(value) do + value + |> String.replace("\"", "\\\"") + |> String.replace("\\r", "") + |> String.replace("\\n", "") + end end diff --git a/lib/pleroma/web/plugs/utils.ex b/lib/pleroma/web/plugs/utils.ex new file mode 100644 index 000000000..770a3eeb2 --- /dev/null +++ b/lib/pleroma/web/plugs/utils.ex @@ -0,0 +1,14 @@ +# Akkoma: Magically expressive social media +# Copyright © 2024 Akkoma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Plugs.Utils do + @moduledoc """ + Some helper functions shared across several plugs + """ + + def get_safe_mime_type(%{allowed_mime_types: allowed_mime_types} = _opts, mime) do + [maintype | _] = String.split(mime, "/", parts: 2) + if maintype in allowed_mime_types, do: mime, else: "application/octet-stream" + end +end diff --git a/lib/pleroma/web/preload.ex b/lib/pleroma/web/preload.ex index 34a181e17..57705d2de 100644 --- a/lib/pleroma/web/preload.ex +++ b/lib/pleroma/web/preload.ex @@ -5,7 +5,7 @@ defmodule Pleroma.Web.Preload do alias Phoenix.HTML - def build_tags(_conn, params) do + def build_tags(%{assigns: %{csp_nonce: nonce}}, params) do preload_data = Enum.reduce(Pleroma.Config.get([__MODULE__, :providers], []), %{}, fn parser, acc -> terms = @@ -20,16 +20,17 @@ def build_tags(_conn, params) do rendered_html = preload_data |> Jason.encode!() - |> build_script_tag() + |> build_script_tag(nonce) |> HTML.safe_to_string() rendered_html end - def build_script_tag(content) do + def build_script_tag(content, nonce) do HTML.Tag.content_tag(:script, HTML.raw(content), id: "initial-results", - type: "application/json" + type: "application/json", + nonce: nonce ) end end diff --git a/lib/pleroma/web/push.ex b/lib/pleroma/web/push.ex index 154dae614..76e17f7cd 100644 --- a/lib/pleroma/web/push.ex +++ b/lib/pleroma/web/push.ex @@ -9,7 +9,7 @@ defmodule Pleroma.Web.Push do def init do unless enabled() do - Logger.warn(""" + Logger.warning(""" VAPID key pair is not found. If you wish to enabled web push, please run mix web_push.gen.keypair diff --git a/lib/pleroma/web/push/impl.ex b/lib/pleroma/web/push/impl.ex index c30a39e94..e514b4896 100644 --- a/lib/pleroma/web/push/impl.ex +++ b/lib/pleroma/web/push/impl.ex @@ -16,7 +16,7 @@ defmodule Pleroma.Web.Push.Impl do require Logger import Ecto.Query - @types ["Create", "Follow", "Announce", "Like", "Move", "EmojiReact"] + @types ["Create", "Follow", "Announce", "Like", "Move", "EmojiReact", "Update"] @doc "Performs sending notifications for user subscriptions" @spec perform(Notification.t()) :: list(any) | :error | {:error, :unknown_type} @@ -57,7 +57,7 @@ def perform( end def perform(_) do - Logger.warn("Unknown notification type") + Logger.warning("Unknown notification type") {:error, :unknown_type} end @@ -167,6 +167,15 @@ def format_body( end end + def format_body( + %{activity: %{data: %{"type" => "Update"}}}, + actor, + _object, + _mastodon_type + ) do + "@#{actor.nickname} edited a status" + end + def format_title(activity, mastodon_type \\ nil) def format_title(%{activity: %{data: %{"directMessage" => true}}}, _mastodon_type) do @@ -180,6 +189,7 @@ def format_title(%{type: type}, mastodon_type) do "follow_request" -> "New Follow Request" "reblog" -> "New Repeat" "favourite" -> "New Favorite" + "update" -> "New Update" "pleroma:emoji_reaction" -> "New Reaction" type -> "New #{String.capitalize(type || "event")}" end diff --git a/lib/pleroma/web/rel_me.ex b/lib/pleroma/web/rel_me.ex index da92b5754..afb525dbe 100644 --- a/lib/pleroma/web/rel_me.ex +++ b/lib/pleroma/web/rel_me.ex @@ -5,7 +5,7 @@ defmodule Pleroma.Web.RelMe do @options [ max_body: 2_000_000, - recv_timeout: 2_000 + receive_timeout: 2_000 ] if Pleroma.Config.get(:env) == :test do @@ -37,16 +37,18 @@ defp parse_url(url) do end def maybe_put_rel_me("http" <> _ = target_page, profile_urls) when is_list(profile_urls) do - {:ok, rel_me_hrefs} = parse(target_page) - - true = Enum.any?(rel_me_hrefs, fn x -> x in profile_urls end) - - "me" + with {:parse, {:ok, rel_me_hrefs}} <- {:parse, parse(target_page)}, + {:link_match, true} <- + {:link_match, Enum.any?(rel_me_hrefs, fn x -> x in profile_urls end)} do + "me" + else + e -> {:error, {:could_not_verify, target_page, e}} + end rescue - _ -> nil + e -> {:error, {:could_not_fetch, target_page, e}} end def maybe_put_rel_me(_, _) do - nil + {:error, :invalid_url} end end diff --git a/lib/pleroma/web/rich_media/helpers.ex b/lib/pleroma/web/rich_media/helpers.ex index ba3524307..061c1a795 100644 --- a/lib/pleroma/web/rich_media/helpers.ex +++ b/lib/pleroma/web/rich_media/helpers.ex @@ -11,7 +11,7 @@ defmodule Pleroma.Web.RichMedia.Helpers do @options [ max_body: 2_000_000, - recv_timeout: 2_000 + receive_timeout: 2_000 ] @spec validate_page_url(URI.t() | binary()) :: :ok | :error diff --git a/lib/pleroma/web/rich_media/parser.ex b/lib/pleroma/web/rich_media/parser.ex index d6b54943b..3ba0086f0 100644 --- a/lib/pleroma/web/rich_media/parser.ex +++ b/lib/pleroma/web/rich_media/parser.ex @@ -15,7 +15,7 @@ def parse(nil), do: {:error, "No URL provided"} if Pleroma.Config.get(:env) == :test do @spec parse(String.t()) :: {:ok, map()} | {:error, any()} - def parse(url), do: parse_url(url) + def parse(url), do: parse_with_timeout(url) else @spec parse(String.t()) :: {:ok, map()} | {:error, any()} def parse(url) do @@ -27,7 +27,7 @@ def parse(url) do defp get_cached_or_parse(url) do case @cachex.fetch(:rich_media_cache, url, fn -> - case parse_url(url) do + case parse_with_timeout(url) do {:ok, _} = res -> {:commit, res} @@ -75,7 +75,7 @@ defp log_error(url, {:invalid_metadata, data}) do end defp log_error(url, reason) do - Logger.warn(fn -> "Rich media error for #{url}: #{inspect(reason)}" end) + Logger.warning(fn -> "Rich media error for #{url}: #{inspect(reason)}" end) end end @@ -141,6 +141,21 @@ def parse_url(url) do end end + def parse_with_timeout(url) do + try do + task = + Task.Supervisor.async_nolink(Pleroma.TaskSupervisor, fn -> + parse_url(url) + end) + + Task.await(task, 5000) + catch + :exit, {:timeout, _} -> + Logger.warning("Timeout while fetching rich media for #{url}") + {:error, :timeout} + end + end + defp maybe_parse(html) do Enum.reduce_while(parsers(), %{}, fn parser, acc -> case parser.parse(html, acc) do diff --git a/lib/pleroma/web/rich_media/parsers/o_embed.ex b/lib/pleroma/web/rich_media/parsers/o_embed.ex index 09eabec56..695740d2e 100644 --- a/lib/pleroma/web/rich_media/parsers/o_embed.ex +++ b/lib/pleroma/web/rich_media/parsers/o_embed.ex @@ -6,8 +6,8 @@ defmodule Pleroma.Web.RichMedia.Parsers.OEmbed do def parse(html, _data) do with elements = [_ | _] <- get_discovery_data(html), oembed_url when is_binary(oembed_url) <- get_oembed_url(elements), - {:ok, oembed_data} <- get_oembed_data(oembed_url) do - oembed_data + {:ok, oembed_data = %{"html" => html}} <- get_oembed_data(oembed_url) do + %{oembed_data | "html" => Pleroma.HTML.filter_tags(html)} else _e -> %{} end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 71a9e4d29..ca4995281 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -147,9 +147,12 @@ defmodule Pleroma.Web.Router do pipeline :http_signature do plug(Pleroma.Web.Plugs.HTTPSignaturePlug) plug(Pleroma.Web.Plugs.MappedSignatureToIdentityPlug) + plug(Pleroma.Web.Plugs.EnsureHTTPSignaturePlug) end pipeline :static_fe do + plug(:fetch_session) + plug(:authenticate) plug(Pleroma.Web.Plugs.StaticFEPlug) end @@ -463,8 +466,32 @@ defmodule Pleroma.Web.Router do put("/statuses/:id/emoji_reactions/:emoji", EmojiReactionController, :create) end + scope "/akkoma/", Pleroma.Web.AkkomaAPI do + pipe_through(:browser) + + get("/frontend", FrontendSwitcherController, :switch) + post("/frontend", FrontendSwitcherController, :do_switch) + end + + scope "/api/v1/akkoma", Pleroma.Web.AkkomaAPI do + pipe_through(:api) + + get( + "/preferred_frontend/available", + FrontendSettingsController, + :available_frontends + ) + + put( + "/preferred_frontend", + FrontendSettingsController, + :update_preferred_frontend + ) + end + scope "/api/v1/akkoma", Pleroma.Web.AkkomaAPI do pipe_through(:authenticated_api) + get("/metrics", MetricsController, :show) get("/translation/languages", TranslationController, :languages) get("/frontend_settings/:frontend_name", FrontendSettingsController, :list_profiles) @@ -594,10 +621,16 @@ defmodule Pleroma.Web.Router do get("/timelines/home", TimelineController, :home) get("/timelines/direct", TimelineController, :direct) get("/timelines/list/:list_id", TimelineController, :list) - get("/timelines/bubble", TimelineController, :bubble) get("/announcements", AnnouncementController, :index) post("/announcements/:id/dismiss", AnnouncementController, :mark_read) + + get("/tags/:id", TagController, :show) + post("/tags/:id/follow", TagController, :follow) + post("/tags/:id/unfollow", TagController, :unfollow) + get("/followed_tags", TagController, :show_followed) + + get("/preferences", AccountController, :preferences) end scope "/api/web", Pleroma.Web do @@ -644,6 +677,7 @@ defmodule Pleroma.Web.Router do get("/timelines/public", TimelineController, :public) get("/timelines/tag/:tag", TimelineController, :hashtag) + get("/timelines/bubble", TimelineController, :bubble) get("/polls/:id", PollController, :show) @@ -724,6 +758,12 @@ defmodule Pleroma.Web.Router do get("/users/:nickname/feed", Feed.UserController, :feed, as: :user_feed) end + scope "/", Pleroma.Web.StaticFE do + # Profile pages for static-fe + get("/users/:nickname/with_replies", StaticFEController, :show) + get("/users/:nickname/media", StaticFEController, :show) + end + scope "/", Pleroma.Web do pipe_through(:accepts_html) get("/notice/:id/embed_player", OStatus.OStatusController, :notice_player) @@ -767,10 +807,16 @@ defmodule Pleroma.Web.Router do post("/users/:nickname/outbox", ActivityPubController, :update_outbox) post("/api/ap/upload_media", ActivityPubController, :upload_media) + get("/users/:nickname/collections/featured", ActivityPubController, :pinned) + end + + scope "/", Pleroma.Web.ActivityPub do + # Note: html format is supported only if static FE is enabled + pipe_through([:accepts_html_json, :static_fe, :activitypub_client]) + # The following two are S2S as well, see `ActivityPub.fetch_follow_information_for_user/1`: get("/users/:nickname/followers", ActivityPubController, :followers) get("/users/:nickname/following", ActivityPubController, :following) - get("/users/:nickname/collections/featured", ActivityPubController, :pinned) end scope "/", Pleroma.Web.ActivityPub do @@ -849,7 +895,11 @@ defmodule Pleroma.Web.Router do scope "/" do pipe_through([:pleroma_html, :authenticate, :require_admin]) - live_dashboard("/phoenix/live_dashboard") + + live_dashboard("/phoenix/live_dashboard", + metrics: {Pleroma.Web.Telemetry, :live_dashboard_metrics}, + csp_nonce_assign_key: :csp_nonce + ) end # Test-only routes needed to test action dispatching and plug chain execution @@ -888,8 +938,7 @@ defmodule Pleroma.Web.Router do scope "/", Pleroma.Web.Fallback do get("/registration/:token", RedirectController, :registration_page) get("/:maybe_nickname_or_id", RedirectController, :redirector_with_meta) - match(:*, "/api/pleroma*path", LegacyPleromaApiRerouterPlug, []) - get("/api*path", RedirectController, :api_not_implemented) + get("/api/*path", RedirectController, :api_not_implemented) get("/*path", RedirectController, :redirector_with_preload) options("/*path", RedirectController, :empty) @@ -897,7 +946,7 @@ defmodule Pleroma.Web.Router do # TODO: Change to Phoenix.Router.routes/1 for Phoenix 1.6.0+ def get_api_routes do - __MODULE__.__routes__() + Phoenix.Router.routes(__MODULE__) |> Enum.reject(fn r -> r.plug == Pleroma.Web.Fallback.RedirectController end) |> Enum.map(fn r -> r.path diff --git a/lib/pleroma/web/static_fe/static_fe_controller.ex b/lib/pleroma/web/static_fe/static_fe_controller.ex index 827c0a384..b1ea3178d 100644 --- a/lib/pleroma/web/static_fe/static_fe_controller.ex +++ b/lib/pleroma/web/static_fe/static_fe_controller.ex @@ -11,7 +11,6 @@ defmodule Pleroma.Web.StaticFE.StaticFEController do alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.Visibility alias Pleroma.Web.Metadata - alias Pleroma.Web.Router.Helpers plug(:put_layout, :static_fe) plug(:assign_id) @@ -25,7 +24,13 @@ def show(%{assigns: %{notice_id: notice_id}} = conn, _params) do true <- Visibility.is_public?(activity.object), {_, true} <- {:visible?, Visibility.visible_for_user?(activity, _reading_user = nil)}, %User{} = user <- User.get_by_ap_id(activity.object.data["actor"]) do - meta = Metadata.build_tags(%{activity_id: notice_id, object: activity.object, user: user}) + meta = + Metadata.build_tags(%{ + activity_id: notice_id, + url: activity.data["id"], + object: activity.object, + user: user + }) timeline = activity.object.data["context"] @@ -45,7 +50,7 @@ def show(%{assigns: %{notice_id: notice_id}} = conn, _params) do end end - def show(%{assigns: %{username_or_id: username_or_id}} = conn, params) do + def show(%{assigns: %{username_or_id: username_or_id, tab: tab}} = conn, params) do with {_, %User{local: true} = user} <- {:fetch_user, User.get_cached_by_nickname_or_id(username_or_id)}, {_, :visible} <- {:visibility, User.visible_for(user, _reading_user = nil)} do @@ -55,11 +60,36 @@ def show(%{assigns: %{username_or_id: username_or_id}} = conn, params) do params |> Map.take(@page_keys) |> Map.new(fn {k, v} -> {String.to_existing_atom(k), v} end) + |> Map.put(:limit, 20) + + params = + case tab do + "posts" -> + Map.put(params, :exclude_replies, true) + + "media" -> + Map.put(params, :only_media, true) + + _ -> + params + end timeline = - user - |> ActivityPub.fetch_user_activities(_reading_user = nil, params) - |> Enum.map(&represent/1) + case tab do + tab when tab in ["posts", "with_replies", "media"] -> + user + |> ActivityPub.fetch_user_activities(_reading_user = nil, params) + |> Enum.map(&represent/1) + + "following" when not user.hide_follows -> + User.get_friends(user) + + "followers" when not user.hide_followers -> + User.get_followers(user) + + _ -> + [] + end prev_page_id = (params["min_id"] || params["max_id"]) && @@ -75,17 +105,22 @@ def show(%{assigns: %{username_or_id: username_or_id}} = conn, params) do meta: meta }) else + {_, %User{} = user} -> + conn + |> put_status(:found) + |> redirect(external: user.uri || user.ap_id) + _ -> not_found(conn, "User not found.") end end def show(%{assigns: %{object_id: _}} = conn, _params) do - url = Helpers.url(conn) <> conn.request_path + url = unverified_url(conn, conn.request_path) case Activity.get_create_by_object_ap_id_with_object(url) do %Activity{} = activity -> - to = Helpers.o_status_path(Pleroma.Web.Endpoint, :notice, activity) + to = ~p[/notice/#{activity}] redirect(conn, to: to) _ -> @@ -94,11 +129,11 @@ def show(%{assigns: %{object_id: _}} = conn, _params) do end def show(%{assigns: %{activity_id: _}} = conn, _params) do - url = Helpers.url(conn) <> conn.request_path + url = unverified_url(conn, conn.request_path) case Activity.get_by_ap_id(url) do %Activity{} = activity -> - to = Helpers.o_status_path(Pleroma.Web.Endpoint, :notice, activity) + to = ~p[/notice/#{activity}] redirect(conn, to: to) _ -> @@ -137,7 +172,7 @@ defp represent(%Activity{object: %Object{data: data}} = activity, selected) do link = case user.local do - true -> Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity) + true -> ~p[/notice/#{activity}] _ -> data["url"] || data["external_url"] || data["id"] end @@ -150,6 +185,15 @@ defp represent(%Activity{object: %Object{data: data}} = activity, selected) do nil end + reply_to_user = in_reply_to_user(activity) + + total_votes = + if data["oneOf"] do + Enum.sum(for option <- data["oneOf"], do: option["replies"]["totalItems"]) + else + 0 + end + %{ user: User.sanitize_html(user), title: get_title(activity.object), @@ -160,10 +204,31 @@ defp represent(%Activity{object: %Object{data: data}} = activity, selected) do sensitive: data["sensitive"], selected: selected, counts: get_counts(activity), - id: activity.id + id: activity.id, + visibility: Visibility.get_visibility(activity.object), + reply_to: data["inReplyTo"], + reply_to_user: reply_to_user, + edited_at: data["updated"], + poll: data["oneOf"], + total_votes: total_votes } end + defp in_reply_to_user(%Activity{object: %Object{data: %{"inReplyTo" => inReplyTo}}} = activity) + when is_binary(inReplyTo) do + in_reply_to_activity = Activity.get_in_reply_to_activity(activity) + + if in_reply_to_activity do + in_reply_to_activity + |> Map.get(:actor) + |> User.get_cached_by_ap_id() + else + nil + end + end + + defp in_reply_to_user(_), do: nil + defp assign_id(%{path_info: ["notice", notice_id]} = conn, _opts), do: assign(conn, :notice_id, notice_id) @@ -177,7 +242,16 @@ defp assign_id(%{path_info: [_nickname, "status", notice_id]} = conn, _opts), do: assign(conn, :notice_id, notice_id) defp assign_id(%{path_info: ["users", user_id]} = conn, _opts), - do: assign(conn, :username_or_id, user_id) + do: + conn + |> assign(:username_or_id, user_id) + |> assign(:tab, "posts") + + defp assign_id(%{path_info: ["users", user_id, tab]} = conn, _opts), + do: + conn + |> assign(:username_or_id, user_id) + |> assign(:tab, tab) defp assign_id(%{path_info: ["objects", object_id]} = conn, _opts), do: assign(conn, :object_id, object_id) diff --git a/lib/pleroma/web/static_fe/static_fe_view.ex b/lib/pleroma/web/static_fe/static_fe_view.ex index c04715337..c1d83c5a0 100644 --- a/lib/pleroma/web/static_fe/static_fe_view.ex +++ b/lib/pleroma/web/static_fe/static_fe_view.ex @@ -8,11 +8,9 @@ defmodule Pleroma.Web.StaticFE.StaticFEView do alias Calendar.Strftime alias Pleroma.Emoji.Formatter alias Pleroma.User - alias Pleroma.Web.Endpoint alias Pleroma.Web.Gettext alias Pleroma.Web.MediaProxy alias Pleroma.Web.Metadata.Utils - alias Pleroma.Web.Router.Helpers use Phoenix.HTML @@ -22,17 +20,38 @@ def fetch_media_type(%{"mediaType" => mediaType}) do Utils.fetch_media_type(@media_types, mediaType) end + def time_ago(date) do + {:ok, date, _} = DateTime.from_iso8601(date) + now = DateTime.utc_now() + + Timex.from_now(date, now) + end + def format_date(date) do {:ok, date, _} = DateTime.from_iso8601(date) Strftime.strftime!(date, "%Y/%m/%d %l:%M:%S %p UTC") end - def instance_name, do: Pleroma.Config.get([:instance, :name], "Pleroma") + def instance_name, do: Pleroma.Config.get([:instance, :name], "Akkoma") def open_content? do Pleroma.Config.get( [:frontend_configurations, :collapse_message_with_subjects], - true + false ) end + + def get_attachment_name(%{"name" => name}), do: name + + def get_attachment_name(_), do: "" + + def poll_percentage(count, total_votes) do + case count do + 0 -> + "0%" + + _ -> + Integer.to_string(trunc(count / total_votes * 100)) <> "%" + end + end end diff --git a/lib/pleroma/web/streamer.ex b/lib/pleroma/web/streamer.ex index c03e7fc30..1c3848bfb 100644 --- a/lib/pleroma/web/streamer.ex +++ b/lib/pleroma/web/streamer.ex @@ -17,6 +17,7 @@ defmodule Pleroma.Web.Streamer do alias Pleroma.Web.OAuth.Token alias Pleroma.Web.Plugs.OAuthScopesPlug alias Pleroma.Web.StreamerView + require Pleroma.Constants @mix_env Mix.env() @registry Pleroma.Web.StreamerRegistry @@ -24,6 +25,7 @@ defmodule Pleroma.Web.Streamer do def registry, do: @registry @public_streams ["public", "public:local", "public:media", "public:local:media"] + @local_streams ["public:local", "public:local:media"] @user_streams ["user", "user:notification", "direct"] @doc "Expands and authorizes a stream, and registers the process for streaming." @@ -40,14 +42,37 @@ def get_topic_and_add_socket(stream, user, oauth_token, params \\ %{}) do end end + defp can_access_stream(user, oauth_token, kind) do + with {_, true} <- {:restrict?, Config.restrict_unauthenticated_access?(:timelines, kind)}, + {_, %User{id: user_id}, %Token{user_id: user_id}} <- {:user, user, oauth_token}, + {_, true} <- + {:scopes, + OAuthScopesPlug.filter_descendants(["read:statuses"], oauth_token.scopes) != []} do + true + else + {:restrict?, _} -> + true + + _ -> + false + end + end + @doc "Expand and authorizes a stream" @spec get_topic(stream :: String.t(), User.t() | nil, Token.t() | nil, Map.t()) :: {:ok, topic :: String.t()} | {:error, :bad_topic} def get_topic(stream, user, oauth_token, params \\ %{}) - # Allow all public steams. - def get_topic(stream, _user, _oauth_token, _params) when stream in @public_streams do - {:ok, stream} + # Allow all public steams if the instance allows unauthenticated access. + # Otherwise, only allow users with valid oauth tokens. + def get_topic(stream, user, oauth_token, _params) when stream in @public_streams do + kind = if stream in @local_streams, do: :local, else: :federated + + if can_access_stream(user, oauth_token, kind) do + {:ok, stream} + else + {:error, :unauthorized} + end end # Allow all hashtags streams. @@ -56,12 +81,20 @@ def get_topic("hashtag", _user, _oauth_token, %{"tag" => tag} = _params) do end # Allow remote instance streams. - def get_topic("public:remote", _user, _oauth_token, %{"instance" => instance} = _params) do - {:ok, "public:remote:" <> instance} + def get_topic("public:remote", user, oauth_token, %{"instance" => instance} = _params) do + if can_access_stream(user, oauth_token, :federated) do + {:ok, "public:remote:" <> instance} + else + {:error, :unauthorized} + end end - def get_topic("public:remote:media", _user, _oauth_token, %{"instance" => instance} = _params) do - {:ok, "public:remote:media:" <> instance} + def get_topic("public:remote:media", user, oauth_token, %{"instance" => instance} = _params) do + if can_access_stream(user, oauth_token, :federated) do + {:ok, "public:remote:media:" <> instance} + else + {:error, :unauthorized} + end end # Expand user streams. @@ -252,7 +285,17 @@ defp do_stream("user", item) do User.get_recipients_from_activity(item) |> Enum.map(fn %{id: id} -> "user:#{id}" end) - Enum.each(recipient_topics, fn topic -> + hashtag_recipients = + if Pleroma.Constants.as_public() in item.recipients do + Pleroma.Hashtag.get_recipients_for_activity(item) + |> Enum.map(fn id -> "user:#{id}" end) + else + [] + end + + all_recipients = Enum.uniq(recipient_topics ++ hashtag_recipients) + + Enum.each(all_recipients, fn topic -> push_to_socket(topic, item) end) end diff --git a/lib/pleroma/web/telemetry.ex b/lib/pleroma/web/telemetry.ex new file mode 100644 index 000000000..269f9f238 --- /dev/null +++ b/lib/pleroma/web/telemetry.ex @@ -0,0 +1,239 @@ +defmodule Pleroma.Web.Telemetry do + use Supervisor + import Telemetry.Metrics + alias Pleroma.Stats + alias Pleroma.Config + + def start_link(arg) do + Supervisor.start_link(__MODULE__, arg, name: __MODULE__) + end + + @impl true + def init(_arg) do + children = + [ + {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} + ] ++ + prometheus_children() + + Supervisor.init(children, strategy: :one_for_one) + end + + defp prometheus_children do + config = Config.get([:instance, :export_prometheus_metrics], true) + + if config do + [ + {TelemetryMetricsPrometheus.Core, metrics: prometheus_metrics()}, + Pleroma.PrometheusExporter + ] + else + [] + end + end + + # A seperate set of metrics for distributions because phoenix dashboard does NOT handle them well + defp distribution_metrics do + [ + distribution( + "phoenix.router_dispatch.stop.duration", + # event_name: [:pleroma, :repo, :query, :total_time], + measurement: :duration, + unit: {:native, :second}, + tags: [:route], + reporter_options: [ + buckets: [0.1, 0.2, 0.5, 1, 2.5, 5, 10, 25, 50, 100, 250, 500, 1000] + ] + ), + + # Database Time Metrics + distribution( + "pleroma.repo.query.total_time", + # event_name: [:pleroma, :repo, :query, :total_time], + measurement: :total_time, + unit: {:native, :millisecond}, + reporter_options: [ + buckets: [0.1, 0.2, 0.5, 1, 2.5, 5, 10, 25, 50, 100, 250, 500, 1000] + ] + ), + distribution( + "pleroma.repo.query.queue_time", + # event_name: [:pleroma, :repo, :query, :total_time], + measurement: :queue_time, + unit: {:native, :millisecond}, + reporter_options: [ + buckets: [0.01, 0.025, 0.05, 0.1, 0.2, 0.5, 1, 2.5, 5, 10] + ] + ), + distribution( + "oban_job_exception", + event_name: [:oban, :job, :exception], + measurement: :duration, + tags: [:worker], + tag_values: fn tags -> Map.put(tags, :worker, tags.job.worker) end, + unit: {:native, :second}, + reporter_options: [ + buckets: [0.01, 0.025, 0.05, 0.1, 0.2, 0.5, 1, 2.5, 5, 10] + ] + ), + distribution( + "tesla_request_completed", + event_name: [:tesla, :request, :stop], + measurement: :duration, + tags: [:response_code], + tag_values: fn tags -> Map.put(tags, :response_code, tags.env.status) end, + unit: {:native, :second}, + reporter_options: [ + buckets: [0.01, 0.025, 0.05, 0.1, 0.2, 0.5, 1, 2.5, 5, 10] + ] + ), + distribution( + "oban_job_completion", + event_name: [:oban, :job, :stop], + measurement: :duration, + tags: [:worker], + tag_values: fn tags -> Map.put(tags, :worker, tags.job.worker) end, + unit: {:native, :second}, + reporter_options: [ + buckets: [0.01, 0.025, 0.05, 0.1, 0.2, 0.5, 1, 2.5, 5, 10] + ] + ) + ] + end + + # Summary metrics are currently not (yet) supported by the prometheus exporter + defp summary_metrics(byte_unit) do + [ + # Phoenix Metrics + summary("phoenix.endpoint.stop.duration", + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.stop.duration", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("pleroma.repo.query.total_time", unit: {:native, :millisecond}), + summary("pleroma.repo.query.decode_time", unit: {:native, :millisecond}), + summary("pleroma.repo.query.query_time", unit: {:native, :millisecond}), + summary("pleroma.repo.query.queue_time", unit: {:native, :millisecond}), + summary("pleroma.repo.query.idle_time", unit: {:native, :millisecond}), + + # VM Metrics + summary("vm.memory.total", unit: {:byte, byte_unit}), + summary("vm.total_run_queue_lengths.total"), + summary("vm.total_run_queue_lengths.cpu"), + summary("vm.total_run_queue_lengths.io") + ] + end + + defp sum_counter_pair(basename, opts) do + [ + sum(basename <> ".psum", opts), + counter(basename <> ".pcount", opts) + ] + end + + # Prometheus exporter doesn't support summaries, so provide fallbacks + defp summary_fallback_metrics(byte_unit \\ :byte) do + # Summary metrics are not supported by the Prometheus exporter + # https://github.com/beam-telemetry/telemetry_metrics_prometheus_core/issues/11 + # and sum metrics currently only work with integers + # https://github.com/beam-telemetry/telemetry_metrics_prometheus_core/issues/35 + # + # For VM metrics this is kindof ok as they appear to always be integers + # and we can use sum + counter to get the average between polls from their change + # But for repo query times we need to use a full distribution + + simple_buckets = [0, 1, 2, 4, 8, 16] + simple_buckets_quick = for t <- simple_buckets, do: t / 100.0 + + # Already included in distribution metrics anyway: + # phoenix.router_dispatch.stop.duration + # pleroma.repo.query.total_time + # pleroma.repo.query.queue_time + dist_metrics = + [ + distribution("phoenix.endpoint.stop.duration.fdist", + event_name: [:phoenix, :endpoint, :stop], + measurement: :duration, + unit: {:native, :millisecond}, + reporter_options: [ + buckets: simple_buckets + ] + ), + distribution("pleroma.repo.query.decode_time.fdist", + event_name: [:pleroma, :repo, :query], + measurement: :decode_time, + unit: {:native, :millisecond}, + reporter_options: [ + buckets: simple_buckets_quick + ] + ), + distribution("pleroma.repo.query.query_time.fdist", + event_name: [:pleroma, :repo, :query], + measurement: :query_time, + unit: {:native, :millisecond}, + reporter_options: [ + buckets: simple_buckets + ] + ), + distribution("pleroma.repo.query.idle_time.fdist", + event_name: [:pleroma, :repo, :query], + measurement: :idle_time, + unit: {:native, :millisecond}, + reporter_options: [ + buckets: simple_buckets + ] + ) + ] + + vm_metrics = + sum_counter_pair("vm.memory.total", + event_name: [:vm, :memory], + measurement: :total, + unit: {:byte, byte_unit} + ) ++ + sum_counter_pair("vm.total_run_queue_lengths.total", + event_name: [:vm, :total_run_queue_lengths], + measurement: :total + ) ++ + sum_counter_pair("vm.total_run_queue_lengths.cpu", + event_name: [:vm, :total_run_queue_lengths], + measurement: :cpu + ) ++ + sum_counter_pair("vm.total_run_queue_lengths.io.fsum", + event_name: [:vm, :total_run_queue_lengths], + measurement: :io + ) + + dist_metrics ++ vm_metrics + end + + defp common_metrics do + [ + last_value("pleroma.local_users.total"), + last_value("pleroma.domains.total"), + last_value("pleroma.local_statuses.total"), + last_value("pleroma.remote_users.total") + ] + end + + def prometheus_metrics, + do: common_metrics() ++ distribution_metrics() ++ summary_fallback_metrics() + + def live_dashboard_metrics, do: common_metrics() ++ summary_metrics(:megabyte) + + defp periodic_measurements do + [ + {__MODULE__, :instance_stats, []} + ] + end + + def instance_stats do + stats = Stats.get_stats() + :telemetry.execute([:pleroma, :local_users], %{total: stats.user_count}, %{}) + :telemetry.execute([:pleroma, :domains], %{total: stats.domain_count}, %{}) + :telemetry.execute([:pleroma, :local_statuses], %{total: stats.status_count}, %{}) + :telemetry.execute([:pleroma, :remote_users], %{total: stats.remote_user_count}, %{}) + end +end diff --git a/lib/pleroma/web/templates/akkoma_api/frontend_switcher/switch.html.eex b/lib/pleroma/web/templates/akkoma_api/frontend_switcher/switch.html.eex new file mode 100644 index 000000000..010a7fbad --- /dev/null +++ b/lib/pleroma/web/templates/akkoma_api/frontend_switcher/switch.html.eex @@ -0,0 +1,10 @@ +

    Switch Frontend

    + +

    After you submit, you will need to refresh manually to get your new frontend!

    + +<%= form_for @conn, ~p"/akkoma/frontend", fn f -> %> + <%= select(f, :frontend, @choices) %> + + <%= submit do: "submit" %> +<% end %> + diff --git a/lib/pleroma/web/templates/feed/feed/tag.atom.eex b/lib/pleroma/web/templates/feed/feed/tag.atom.eex index 6d497e84c..e85c08b2b 100644 --- a/lib/pleroma/web/templates/feed/feed/tag.atom.eex +++ b/lib/pleroma/web/templates/feed/feed/tag.atom.eex @@ -9,13 +9,13 @@ xmlns:ostatus="http://ostatus.org/schema/1.0" xmlns:statusnet="http://status.net/schema/api/1/"> - <%= '#{Routes.tag_feed_url(@conn, :feed, @tag)}.rss' %> + <%= '#{url(~p"/tags/#{@tag}")}.rss' %> #<%= @tag %> <%= Gettext.dpgettext("static_pages", "tag feed description", "These are public toots tagged with #%{tag}. You can interact with them if you have an account anywhere in the fediverse.", tag: @tag) %> <%= feed_logo() %> <%= most_recent_update(@activities) %> - + " type="application/atom+xml"/> <%= for activity <- @activities do %> <%= render @view_module, "_tag_activity.atom", Map.merge(assigns, prepare_activity(activity, actor: true)) %> <% end %> diff --git a/lib/pleroma/web/templates/feed/feed/tag.rss.eex b/lib/pleroma/web/templates/feed/feed/tag.rss.eex index edcc3e436..7ee3ba5a3 100644 --- a/lib/pleroma/web/templates/feed/feed/tag.rss.eex +++ b/lib/pleroma/web/templates/feed/feed/tag.rss.eex @@ -5,7 +5,7 @@ #<%= @tag %> <%= Gettext.dpgettext("static_pages", "tag feed description", "These are public toots tagged with #%{tag}. You can interact with them if you have an account anywhere in the fediverse.", tag: @tag) %> - <%= '#{Routes.tag_feed_url(@conn, :feed, @tag)}.rss' %> + <%= '#{url(~p"/tags/#{@tag}")}.rss' %> <%= feed_logo() %> 2b90d9 <%= for activity <- @activities do %> diff --git a/lib/pleroma/web/templates/feed/feed/user.atom.eex b/lib/pleroma/web/templates/feed/feed/user.atom.eex index 5c1f0ecbc..03585a9d5 100644 --- a/lib/pleroma/web/templates/feed/feed/user.atom.eex +++ b/lib/pleroma/web/templates/feed/feed/user.atom.eex @@ -6,16 +6,16 @@ xmlns:poco="http://portablecontacts.net/spec/1.0" xmlns:ostatus="http://ostatus.org/schema/1.0"> - <%= Routes.user_feed_url(@conn, :feed, @user.nickname) <> ".atom" %> + <%= url(~p"/users/#{@user.nickname}/feed") <> ".atom" %> <%= @user.nickname <> "'s timeline" %> <%= most_recent_update(@activities, @user) %> <%= logo(@user) %> - + " type="application/atom+xml"/> <%= render @view_module, "_author.atom", assigns %> <%= if last_activity(@activities) do %> - + " type="application/atom+xml"/> <% end %> <%= for activity <- @activities do %> diff --git a/lib/pleroma/web/templates/feed/feed/user.rss.eex b/lib/pleroma/web/templates/feed/feed/user.rss.eex index 6b842a085..f2eb7337e 100644 --- a/lib/pleroma/web/templates/feed/feed/user.rss.eex +++ b/lib/pleroma/web/templates/feed/feed/user.rss.eex @@ -1,16 +1,16 @@ - <%= Routes.user_feed_url(@conn, :feed, @user.nickname) <> ".rss" %> + <%= url(~p"/users/#{@user.nickname}/feed") <> ".rss" %> <%= @user.nickname <> "'s timeline" %> <%= most_recent_update(@activities, @user) %> <%= logo(@user) %> - <%= '#{Routes.user_feed_url(@conn, :feed, @user.nickname)}.rss' %> + <%= '#{url(~p"/users/#{@user.nickname}/feed")}.rss' %> <%= render @view_module, "_author.rss", assigns %> <%= if last_activity(@activities) do %> - <%= '#{Routes.user_feed_url(@conn, :feed, @user.nickname)}.rss?max_id=#{last_activity(@activities).id}' %> + <%= '#{url(~p"/users/#{@user.nickname}/feed")}.rss?max_id=#{last_activity(@activities).id}' %> <% end %> <%= for activity <- @activities do %> diff --git a/lib/pleroma/web/templates/layout/app.html.eex b/lib/pleroma/web/templates/layout/app.html.eex index e33bada85..31e6ec52b 100644 --- a/lib/pleroma/web/templates/layout/app.html.eex +++ b/lib/pleroma/web/templates/layout/app.html.eex @@ -4,17 +4,33 @@ <%= Pleroma.Config.get([:instance, :name]) %> - + + - + +
    +
    - <%= @inner_content %> +
    +
    +
    + <%= @inner_content %> +
    +
    + + diff --git a/lib/pleroma/web/templates/layout/embed.html.eex b/lib/pleroma/web/templates/layout/embed.html.eex index 8b905f070..49f2cdb5b 100644 --- a/lib/pleroma/web/templates/layout/embed.html.eex +++ b/lib/pleroma/web/templates/layout/embed.html.eex @@ -6,10 +6,10 @@ <%= Pleroma.Config.get([:instance, :name]) %> <%= Phoenix.HTML.raw(assigns[:meta] || "") %> - + - <%= render @view_module, @view_template, assigns %> + <%= render view_module(@conn), view_template(@conn), assigns %> diff --git a/lib/pleroma/web/templates/layout/static_fe.html.eex b/lib/pleroma/web/templates/layout/static_fe.html.eex index e6adb526b..d159eb901 100644 --- a/lib/pleroma/web/templates/layout/static_fe.html.eex +++ b/lib/pleroma/web/templates/layout/static_fe.html.eex @@ -6,10 +6,39 @@ <%= Pleroma.Config.get([:instance, :name]) %> <%= Phoenix.HTML.raw(assigns[:meta] || "") %> + +
    +
    - <%= @inner_content %> +
    +
    + <%= @inner_content %> +
    +
    + + diff --git a/lib/pleroma/web/templates/masto_fe/fedibird.html.heex b/lib/pleroma/web/templates/masto_fe/fedibird.html.heex new file mode 100644 index 000000000..7070bd8d8 --- /dev/null +++ b/lib/pleroma/web/templates/masto_fe/fedibird.html.heex @@ -0,0 +1,58 @@ + + + + + + + + <%= Config.get([:instance, :name]) %> + + + + + + + + + + + + + + + + + + + + + + +
    + + diff --git a/lib/pleroma/web/templates/masto_fe/fedibird.index.html.eex b/lib/pleroma/web/templates/masto_fe/fedibird.index.html.eex deleted file mode 100644 index 02c421831..000000000 --- a/lib/pleroma/web/templates/masto_fe/fedibird.index.html.eex +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - -<%= Config.get([:instance, :name]) %> - - - - - - - - - - - - - - - - - - - - - - - -
    -
    - - diff --git a/lib/pleroma/web/templates/masto_fe/glitchsoc.html.heex b/lib/pleroma/web/templates/masto_fe/glitchsoc.html.heex new file mode 100644 index 000000000..469c201a5 --- /dev/null +++ b/lib/pleroma/web/templates/masto_fe/glitchsoc.html.heex @@ -0,0 +1,57 @@ + + + + + + + + <%= Config.get([:instance, :name]) %> + + + + + + + + + + + + + + + + + + + + + + + + +
    + + diff --git a/lib/pleroma/web/templates/masto_fe/glitchsoc.index.html.eex b/lib/pleroma/web/templates/masto_fe/glitchsoc.index.html.eex deleted file mode 100644 index dadf8f413..000000000 --- a/lib/pleroma/web/templates/masto_fe/glitchsoc.index.html.eex +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - -<%= Config.get([:instance, :name]) %> - - - - - - - - - - - - - - - - - - - - - - - - -
    -
    - - diff --git a/lib/pleroma/web/templates/o_auth/mfa/recovery.html.eex b/lib/pleroma/web/templates/o_auth/mfa/recovery.html.eex index e45d13bdf..b9b08c45d 100644 --- a/lib/pleroma/web/templates/o_auth/mfa/recovery.html.eex +++ b/lib/pleroma/web/templates/o_auth/mfa/recovery.html.eex @@ -1,24 +1,29 @@ -<%= if get_flash(@conn, :info) do %> - -<% end %> -<%= if get_flash(@conn, :error) do %> - -<% end %> +
    + <%= if Flash.get(@flash, :info) do %> + + <% end %> + <%= if Flash.get(@flash, :error) do %> + + <% end %> +
    + <%= Gettext.dpgettext("static_pages", "mfa recover page title", "Two-factor recovery") %> +
    +
    + <%= form_for @conn, ~p"/oauth/mfa/verify", [as: "mfa"], fn f -> %> +
    + <%= label f, :code, Gettext.dpgettext("static_pages", "mfa recover recovery code prompt", "Recovery code") %> + <%= text_input f, :code, [autocomplete: false, autocorrect: "off", autocapitalize: "off", autofocus: true, spellcheck: false] %> + <%= hidden_input f, :mfa_token, value: @mfa_token %> + <%= hidden_input f, :state, value: @state %> + <%= hidden_input f, :redirect_uri, value: @redirect_uri %> + <%= hidden_input f, :challenge_type, value: "recovery" %> +
    -

    <%= Gettext.dpgettext("static_pages", "mfa recover page title", "Two-factor recovery") %>

    + <%= submit Gettext.dpgettext("static_pages", "mfa recover verify recovery code button", "Verify") %> + <% end %> + "> + <%= Gettext.dpgettext("static_pages", "mfa recover use 2fa code link", "Enter a two-factor code") %> + -<%= form_for @conn, Routes.mfa_verify_path(@conn, :verify), [as: "mfa"], fn f -> %> -
    - <%= label f, :code, Gettext.dpgettext("static_pages", "mfa recover recovery code prompt", "Recovery code") %> - <%= text_input f, :code, [autocomplete: false, autocorrect: "off", autocapitalize: "off", autofocus: true, spellcheck: false] %> - <%= hidden_input f, :mfa_token, value: @mfa_token %> - <%= hidden_input f, :state, value: @state %> - <%= hidden_input f, :redirect_uri, value: @redirect_uri %> - <%= hidden_input f, :challenge_type, value: "recovery" %> +
    - -<%= submit Gettext.dpgettext("static_pages", "mfa recover verify recovery code button", "Verify") %> -<% end %> -"> - <%= Gettext.dpgettext("static_pages", "mfa recover use 2fa code link", "Enter a two-factor code") %> - diff --git a/lib/pleroma/web/templates/o_auth/mfa/totp.html.eex b/lib/pleroma/web/templates/o_auth/mfa/totp.html.eex index 50e6c04b6..59827780b 100644 --- a/lib/pleroma/web/templates/o_auth/mfa/totp.html.eex +++ b/lib/pleroma/web/templates/o_auth/mfa/totp.html.eex @@ -1,24 +1,28 @@ -<%= if get_flash(@conn, :info) do %> - -<% end %> -<%= if get_flash(@conn, :error) do %> - -<% end %> +
    + <%= if Flash.get(@flash, :info) do %> + + <% end %> + <%= if Flash.get(@flash, :error) do %> + + <% end %> +
    + <%= Gettext.dpgettext("static_pages", "mfa auth page title", "Two-factor authentication") %> +
    +
    + <%= form_for @conn, ~p"/oauth/mfa/verify", [as: "mfa"], fn f -> %> +
    + <%= label f, :code, Gettext.dpgettext("static_pages", "mfa auth code prompt", "Authentication code") %> + <%= text_input f, :code, [autocomplete: "one-time-code", autocorrect: "off", autocapitalize: "off", autofocus: true, pattern: "[0-9]*", spellcheck: false] %> + <%= hidden_input f, :mfa_token, value: @mfa_token %> + <%= hidden_input f, :state, value: @state %> + <%= hidden_input f, :redirect_uri, value: @redirect_uri %> + <%= hidden_input f, :challenge_type, value: "totp" %> +
    -

    <%= Gettext.dpgettext("static_pages", "mfa auth page title", "Two-factor authentication") %>

    - -<%= form_for @conn, Routes.mfa_verify_path(@conn, :verify), [as: "mfa"], fn f -> %> -
    - <%= label f, :code, Gettext.dpgettext("static_pages", "mfa auth code prompt", "Authentication code") %> - <%= text_input f, :code, [autocomplete: "one-time-code", autocorrect: "off", autocapitalize: "off", autofocus: true, pattern: "[0-9]*", spellcheck: false] %> - <%= hidden_input f, :mfa_token, value: @mfa_token %> - <%= hidden_input f, :state, value: @state %> - <%= hidden_input f, :redirect_uri, value: @redirect_uri %> - <%= hidden_input f, :challenge_type, value: "totp" %> + <%= submit Gettext.dpgettext("static_pages", "mfa auth verify code button", "Verify") %> + <% end %> + "> + <%= Gettext.dpgettext("static_pages", "mfa auth page use recovery code link", "Enter a two-factor recovery code") %> + +
    - -<%= submit Gettext.dpgettext("static_pages", "mfa auth verify code button", "Verify") %> -<% end %> -"> - <%= Gettext.dpgettext("static_pages", "mfa auth page use recovery code link", "Enter a two-factor recovery code") %> - diff --git a/lib/pleroma/web/templates/o_auth/o_auth/consumer.html.eex b/lib/pleroma/web/templates/o_auth/o_auth/consumer.html.eex index 8b894cd58..97f2b770f 100644 --- a/lib/pleroma/web/templates/o_auth/o_auth/consumer.html.eex +++ b/lib/pleroma/web/templates/o_auth/o_auth/consumer.html.eex @@ -1,6 +1,6 @@

    <%= Gettext.dpgettext("static_pages", "oauth external provider page title", "Sign in with external provider") %>

    -<%= form_for @conn, Routes.o_auth_path(@conn, :prepare_request), [as: "authorization", method: "get"], fn f -> %> +<%= form_for @conn, ~p"/oauth/prepare_request", [as: "authorization", method: "get"], fn f -> %>
    <%= render @view_module, "_scopes.html", Map.merge(assigns, %{form: f}) %>
    diff --git a/lib/pleroma/web/templates/o_auth/o_auth/oob_authorization_created.html.eex b/lib/pleroma/web/templates/o_auth/o_auth/oob_authorization_created.html.eex index 76ed3fda5..17e54fb42 100644 --- a/lib/pleroma/web/templates/o_auth/o_auth/oob_authorization_created.html.eex +++ b/lib/pleroma/web/templates/o_auth/o_auth/oob_authorization_created.html.eex @@ -1,2 +1,8 @@ -

    <%= Gettext.dpgettext("static_pages", "oauth authorized page title", "Successfully authorized") %>

    -

    <%= raw Gettext.dpgettext("static_pages", "oauth token code message", "Token code is
    %{token}", token: safe_to_string(html_escape(@auth.token))) %>

    +
    +
    + <%= Gettext.dpgettext("static_pages", "oauth authorized page title", "Successfully authorized") %> +
    +
    + <%= raw Gettext.dpgettext("static_pages", "oauth token code message", "Token code is
    %{token}", token: safe_to_string(html_escape(@auth.token))) %> +
    +
    diff --git a/lib/pleroma/web/templates/o_auth/o_auth/oob_token_exists.html.eex b/lib/pleroma/web/templates/o_auth/o_auth/oob_token_exists.html.eex index 754bf2eb0..11671fa1c 100644 --- a/lib/pleroma/web/templates/o_auth/o_auth/oob_token_exists.html.eex +++ b/lib/pleroma/web/templates/o_auth/o_auth/oob_token_exists.html.eex @@ -1,2 +1,8 @@ -

    <%= Gettext.dpgettext("static_pages", "oauth authorization exists page title", "Authorization exists") %>

    -

    <%= raw Gettext.dpgettext("static_pages", "oauth token code message", "Token code is
    %{token}", token: safe_to_string(html_escape(@token.token))) %>

    +
    +
    + <%= Gettext.dpgettext("static_pages", "oauth authorization exists page title", "Authorization exists") %> +
    +
    + <%= raw Gettext.dpgettext("static_pages", "oauth token code message", "Token code is
    %{token}", token: safe_to_string(html_escape(@token.token))) %> +
    +
    diff --git a/lib/pleroma/web/templates/o_auth/o_auth/register.html.eex b/lib/pleroma/web/templates/o_auth/o_auth/register.html.eex index 1f661efb2..601b16b98 100644 --- a/lib/pleroma/web/templates/o_auth/o_auth/register.html.eex +++ b/lib/pleroma/web/templates/o_auth/o_auth/register.html.eex @@ -1,14 +1,14 @@ -<%= if get_flash(@conn, :info) do %> - +<%= if Flash.get(@flash, :info) do %> + <% end %> -<%= if get_flash(@conn, :error) do %> - +<%= if Flash.get(@flash, :error) do %> + <% end %>

    <%= Gettext.dpgettext("static_pages", "oauth register page title", "Registration Details") %>

    <%= Gettext.dpgettext("static_pages", "oauth register page fill form prompt", "If you'd like to register a new account, please provide the details below.") %>

    -<%= form_for @conn, Routes.o_auth_path(@conn, :register), [as: "authorization"], fn f -> %> +<%= form_for @conn, ~p"/oauth/register", [as: "authorization"], fn f -> %>
    <%= label f, :nickname, Gettext.dpgettext("static_pages", "oauth register page nickname prompt", "Nickname") %> diff --git a/lib/pleroma/web/templates/o_auth/o_auth/show.html.eex b/lib/pleroma/web/templates/o_auth/o_auth/show.html.eex index a2f41618e..420a17562 100644 --- a/lib/pleroma/web/templates/o_auth/o_auth/show.html.eex +++ b/lib/pleroma/web/templates/o_auth/o_auth/show.html.eex @@ -1,59 +1,65 @@ -<%= if get_flash(@conn, :info) do %> - +<%= if Flash.get(@flash, :info) do %> + <% end %> -<%= if get_flash(@conn, :error) do %> - +<%= if Flash.get(@flash, :error) do %> + <% end %> -<%= form_for @conn, Routes.o_auth_path(@conn, :authorize), [as: "authorization"], fn f -> %> +<%= form_for @conn, ~p"/oauth/authorize", [as: "authorization"], fn f -> %> <%= if @user do %>