diff --git a/Dockerfile b/Dockerfile index 4018d9bc9..1067ef1c8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -42,7 +42,7 @@ ARG HOME=/opt/pleroma ARG DATA=/var/lib/pleroma RUN apk update &&\ - apk add exiftool ffmpeg vips libmagic ncurses postgresql-client &&\ + apk add ffmpeg vips libmagic ncurses postgresql-client &&\ adduser --system --shell /bin/false --home ${HOME} pleroma &&\ mkdir -p ${DATA}/uploads &&\ mkdir -p ${DATA}/static &&\ diff --git a/Dockerfile-e2e b/Dockerfile-e2e index df8bb39aa..59a285a67 100644 --- a/Dockerfile-e2e +++ b/Dockerfile-e2e @@ -41,7 +41,7 @@ ARG HOME=/opt/pleroma ARG DATA=/var/lib/pleroma RUN apk update &&\ - apk add exiftool ffmpeg vips libmagic ncurses postgresql-client &&\ + apk add ffmpeg vips libmagic ncurses postgresql-client &&\ adduser --system --shell /bin/false --home ${HOME} pleroma &&\ mkdir -p ${DATA}/uploads &&\ mkdir -p ${DATA}/static &&\ diff --git a/changelog.d/advisory-bumps.security b/changelog.d/advisory-bumps.security new file mode 100644 index 000000000..baf9e1ccb --- /dev/null +++ b/changelog.d/advisory-bumps.security @@ -0,0 +1 @@ +Update ssl_verify_fun to 1.1.9 (EEF-CVE-2026-117666) and phoenix_html to 3.3.5 (EEF-CVE-2026-117645) diff --git a/changelog.d/bandit-1.14.1.security b/changelog.d/bandit-1.14.1.security new file mode 100644 index 000000000..5c4703b31 --- /dev/null +++ b/changelog.d/bandit-1.14.1.security @@ -0,0 +1 @@ +Update Bandit to version 1.14.1, which fixes an HTTP/2 request body memory amplification diff --git a/changelog.d/c2s-rate-limit.security b/changelog.d/c2s-rate-limit.security new file mode 100644 index 000000000..8cab4a309 --- /dev/null +++ b/changelog.d/c2s-rate-limit.security @@ -0,0 +1 @@ +Rate limit ActivityPub C2S outbox posts and emoji reactions diff --git a/changelog.d/card-skip-quote-link.fix b/changelog.d/card-skip-quote-link.fix new file mode 100644 index 000000000..27fe096fa --- /dev/null +++ b/changelog.d/card-skip-quote-link.fix @@ -0,0 +1 @@ +Link preview cards skip the fallback link of quote posts (`RE: `), which pointed at the quoted post and showed it twice. diff --git a/changelog.d/cast-and-validate-cast-opts.fix b/changelog.d/cast-and-validate-cast-opts.fix new file mode 100644 index 000000000..ba4ce2283 --- /dev/null +++ b/changelog.d/cast-and-validate-cast-opts.fix @@ -0,0 +1 @@ +Fix per-status and per-account rate limits when `Pleroma.Web.ApiSpec.CastAndValidate` `strict` is off (the production default) diff --git a/changelog.d/cast-and-validate-many-params.fix b/changelog.d/cast-and-validate-many-params.fix new file mode 100644 index 000000000..c44396618 --- /dev/null +++ b/changelog.d/cast-and-validate-many-params.fix @@ -0,0 +1 @@ +Drop all unexpected query params, not just the first one, when `Pleroma.Web.ApiSpec.CastAndValidate` `strict` is off diff --git a/changelog.d/ci-cheap-changelog-check.skip b/changelog.d/ci-cheap-changelog-check.skip new file mode 100644 index 000000000..e69de29bb diff --git a/changelog.d/config-db-unknown-module.fix b/changelog.d/config-db-unknown-module.fix new file mode 100644 index 000000000..05a6a2b2f --- /dev/null +++ b/changelog.d/config-db-unknown-module.fix @@ -0,0 +1 @@ +Do not crash at boot or in the AdminAPI config listing when a ConfigDB setting refers to a module that no longer exists diff --git a/changelog.d/context-local-only.fix b/changelog.d/context-local-only.fix new file mode 100644 index 000000000..5a1d10f3a --- /dev/null +++ b/changelog.d/context-local-only.fix @@ -0,0 +1 @@ +Show local-only replies in status context to local users who don't follow the replier diff --git a/changelog.d/emoji-react-single-tag.fix b/changelog.d/emoji-react-single-tag.fix new file mode 100644 index 000000000..565775830 --- /dev/null +++ b/changelog.d/emoji-react-single-tag.fix @@ -0,0 +1 @@ +Accept incoming emoji reactions whose tag is a single object instead of an array diff --git a/changelog.d/empty-proxy-url.fix b/changelog.d/empty-proxy-url.fix new file mode 100644 index 000000000..4dd547aba --- /dev/null +++ b/changelog.d/empty-proxy-url.fix @@ -0,0 +1 @@ +HTTP: treat an empty proxy_url as no proxy; with Hackney 4 it made every outgoing request fail diff --git a/changelog.d/exif-heic-gps.security b/changelog.d/exif-heic-gps.security new file mode 100644 index 000000000..0b6593f5f --- /dev/null +++ b/changelog.d/exif-heic-gps.security @@ -0,0 +1 @@ +Strip GPS data from WebP and HEIC/AVIF uploads; the Exiftool.StripLocation filter used to skip them; GPS data in XMP (including DJI drone positions) is stripped too diff --git a/changelog.d/exif-stripper.change b/changelog.d/exif-stripper.change new file mode 100644 index 000000000..ba8d47a19 --- /dev/null +++ b/changelog.d/exif-stripper.change @@ -0,0 +1 @@ +Exiftool upload filters are replaced by the built-in Exif.StripLocation and Exif.ReadDescription filters; exiftool is no longer required, old filter names still work but are deprecated diff --git a/changelog.d/gin-fuzzy-search-limit.fix b/changelog.d/gin-fuzzy-search-limit.fix new file mode 100644 index 000000000..276c3daa9 --- /dev/null +++ b/changelog.d/gin-fuzzy-search-limit.fix @@ -0,0 +1 @@ +gin_fuzzy_search_limit is now only set for search queries (configurable under Pleroma.Search.DatabaseSearch), so other GIN index scans such as recipient lookups and Oban job queries are exact again diff --git a/changelog.d/gun-ipv6-only-hosts.fix b/changelog.d/gun-ipv6-only-hosts.fix new file mode 100644 index 000000000..69cdc56e3 --- /dev/null +++ b/changelog.d/gun-ipv6-only-hosts.fix @@ -0,0 +1 @@ +Gun adapter: connect to hosts that only have IPv6 (AAAA) addresses diff --git a/changelog.d/http-backoff.add b/changelog.d/http-backoff.add new file mode 100644 index 000000000..5c9eb988b --- /dev/null +++ b/changelog.d/http-backoff.add @@ -0,0 +1 @@ +Back off from hosts that answer 429 or 503 to object fetches and WebFinger, until Retry-After or X-RateLimit-Reset (default 5 minutes, max 1 hour) diff --git a/changelog.d/jose-1.11.13.security b/changelog.d/jose-1.11.13.security new file mode 100644 index 000000000..e672d07e7 --- /dev/null +++ b/changelog.d/jose-1.11.13.security @@ -0,0 +1 @@ +Update jose to version 1.11.13, which fixes JWE, key parsing and signature validation vulnerabilities diff --git a/changelog.d/marker-updated-at-format.fix b/changelog.d/marker-updated-at-format.fix new file mode 100644 index 000000000..2ff681525 --- /dev/null +++ b/changelog.d/marker-updated-at-format.fix @@ -0,0 +1 @@ +Mastodon API: Use the Mastodon date format for marker updated_at diff --git a/changelog.d/media-proxy-data-urls.fix b/changelog.d/media-proxy-data-urls.fix new file mode 100644 index 000000000..a83b8ef29 --- /dev/null +++ b/changelog.d/media-proxy-data-urls.fix @@ -0,0 +1 @@ +MediaProxy: Do not proxy data: URLs diff --git a/changelog.d/media-proxy-static-redirect.fix b/changelog.d/media-proxy-static-redirect.fix new file mode 100644 index 000000000..4408278ba --- /dev/null +++ b/changelog.d/media-proxy-static-redirect.fix @@ -0,0 +1 @@ +Media proxy: serve a preview for static=true on non-GIF files instead of redirecting to the main endpoint host diff --git a/changelog.d/move-refetch-target.fix b/changelog.d/move-refetch-target.fix new file mode 100644 index 000000000..1c200568e --- /dev/null +++ b/changelog.d/move-refetch-target.fix @@ -0,0 +1 @@ +Accept a Move when the cached remote target does not list the alias yet, by refetching the target once diff --git a/changelog.d/mrf-domain-regex-escape.security b/changelog.d/mrf-domain-regex-escape.security new file mode 100644 index 000000000..07274c37b --- /dev/null +++ b/changelog.d/mrf-domain-regex-escape.security @@ -0,0 +1 @@ +MRF domain rules now match dots literally, so a rule for `good.example` no longer also matches `goodxexample`, and wildcard rules no longer backtrack on hostile host names diff --git a/changelog.d/personal-inbox-addressing.fix b/changelog.d/personal-inbox-addressing.fix new file mode 100644 index 000000000..9a2ba48a2 --- /dev/null +++ b/changelog.d/personal-inbox-addressing.fix @@ -0,0 +1 @@ +Accept activities delivered to a personal inbox that do not address the inbox owner (fixes Bridgy Fed follows) diff --git a/changelog.d/question-note-fixups.fix b/changelog.d/question-note-fixups.fix new file mode 100644 index 000000000..31f54b944 --- /dev/null +++ b/changelog.d/question-note-fixups.fix @@ -0,0 +1 @@ +Apply the Note fixups to incoming Questions, so polls with a single non-array tag or attachment are accepted diff --git a/changelog.d/search-remote-fetch.security b/changelog.d/search-remote-fetch.security new file mode 100644 index 000000000..4211615ae --- /dev/null +++ b/changelog.d/search-remote-fetch.security @@ -0,0 +1 @@ +Status search only fetches remote URLs with resolve=true, on the first page, and for anonymous users only if limit_to_local_content allows it diff --git a/changelog.d/shared-inbox.change b/changelog.d/shared-inbox.change new file mode 100644 index 000000000..0cb74711e --- /dev/null +++ b/changelog.d/shared-inbox.change @@ -0,0 +1 @@ +Deliver to shared inboxes whenever possible, including followers-only posts and DMs, so each server gets one copy diff --git a/changelog.d/signature-freshness.security b/changelog.d/signature-freshness.security new file mode 100644 index 000000000..a314c403e --- /dev/null +++ b/changelog.d/signature-freshness.security @@ -0,0 +1 @@ +Reject incoming HTTP signatures that do not sign the date, are older than 2 hours (or more than 1 hour in the future), or do not sign the digest of a request body, so captured requests cannot be replayed. Set `config :pleroma, :activitypub, signature_policy: :log` to only log these diff --git a/changelog.d/stats-peers-from-instances.change b/changelog.d/stats-peers-from-instances.change new file mode 100644 index 000000000..a3dfe2e0c --- /dev/null +++ b/changelog.d/stats-peers-from-instances.change @@ -0,0 +1 @@ +Read the peer list from the instances table (hosts of remote actors, without our own host) and update instance stats every 5 minutes; the old peers query scanned all remote users every minute diff --git a/changelog.d/status-id-action-rate-limit.fix b/changelog.d/status-id-action-rate-limit.fix new file mode 100644 index 000000000..030eeb0df --- /dev/null +++ b/changelog.d/status-id-action-rate-limit.fix @@ -0,0 +1 @@ +Rate limit favourites and reblogs per status, not across all statuses diff --git a/changelog.d/undo-block-outgoing-blocks.security b/changelog.d/undo-block-outgoing-blocks.security new file mode 100644 index 000000000..8945666bc --- /dev/null +++ b/changelog.d/undo-block-outgoing-blocks.security @@ -0,0 +1 @@ +Do not federate the Undo of a block when `outgoing_blocks` is disabled diff --git a/config/config.exs b/config/config.exs index 1b8baed1f..576ae28db 100644 --- a/config/config.exs +++ b/config/config.exs @@ -373,6 +373,9 @@ config :pleroma, :activitypub, note_replies_output_limit: 5, sign_object_fetches: true, authorized_fetch_mode: false, + signature_policy: :enforce, + signature_max_age: 7200, + signature_max_clock_skew: 3600, client_api_enabled: false, anonymize_reporter: false, anonymize_reporter_local_nickname: "" @@ -843,7 +846,7 @@ config :pleroma, :modules, runtime_dir: "instance/modules" config :pleroma, configurable_from_database: false config :pleroma, Pleroma.Repo, - parameters: [gin_fuzzy_search_limit: "500", jit: "off"], + parameters: [jit: "off"], prepare: :unnamed config :pleroma, :connections_pool, @@ -948,6 +951,8 @@ config :pleroma, Pleroma.Web.WebFinger, domain: nil, update_nickname_on_user_fet config :pleroma, Pleroma.Search, module: Pleroma.Search.DatabaseSearch +config :pleroma, Pleroma.Search.DatabaseSearch, gin_fuzzy_search_limit: 500 + config :pleroma, Pleroma.Search.Meilisearch, url: "http://127.0.0.1:7700/", private_key: nil, diff --git a/config/description.exs b/config/description.exs index f435628aa..81bc358a9 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1788,6 +1788,27 @@ config :pleroma, :config_description, [ description: "List of IPs (CIDR format accepted) to exempt from HTTP Signatures requirement (for example to allow debugging, you shouldn't otherwise need this)" }, + %{ + key: :signature_policy, + type: {:dropdown, :atom}, + description: + "What to do with incoming HTTP signatures that do not sign the date (or `(created)`), are outside the allowed time window, or do not sign the digest of a request body. `:enforce` treats them as invalid, `:log` only logs them", + suggestions: [:enforce, :log] + }, + %{ + key: :signature_max_age, + type: :integer, + description: + "How old, in seconds, the signed date of an incoming HTTP signature can be. Default: 7200 (2 hours)", + suggestions: [7200] + }, + %{ + key: :signature_max_clock_skew, + type: :integer, + description: + "How far, in seconds, the signed date of an incoming HTTP signature can be in the future. Default: 3600 (1 hour). A `(created)` time in the future is always rejected", + suggestions: [3600] + }, %{ key: :note_replies_output_limit, type: :integer, @@ -3565,6 +3586,21 @@ config :pleroma, :config_description, [ } ] }, + %{ + group: :pleroma, + key: Pleroma.Search.DatabaseSearch, + type: :group, + description: "Built-in database search settings.", + children: [ + %{ + key: :gin_fuzzy_search_limit, + type: :integer, + description: + "Limits how many matches PostgreSQL considers in GIN full-text search queries (see `gin_fuzzy_search_limit` in the PostgreSQL docs). Only set for search queries. Keeps search fast on large databases, but results become incomplete and change between requests. Set to `nil` for exact results, which is fine on small instances. Default: 500.", + suggestions: [500, nil] + } + ] + }, %{ group: :pleroma, key: Pleroma.Search.Meilisearch, diff --git a/docs/administration/CLI_tasks/instance.md b/docs/administration/CLI_tasks/instance.md index 88509cf5b..622299629 100644 --- a/docs/administration/CLI_tasks/instance.md +++ b/docs/administration/CLI_tasks/instance.md @@ -37,8 +37,8 @@ If any of the options are left unspecified, you will be prompted interactively. - `--static-dir ` - the directory custom public files should be read from (custom emojis, frontend bundle overrides, robots.txt, etc.) - `--listen-ip ` - the ip the app should listen to, defaults to 127.0.0.1 - `--listen-port ` - the port the app should listen to, defaults to 4000 -- `--strip-uploads-location ` - use ExifTool to strip uploads of sensitive location data -- `--read-uploads-description ` - use ExifTool to read image descriptions from uploads +- `--strip-uploads-location ` - strip sensitive location data from uploaded images +- `--read-uploads-description ` - read image descriptions from upload metadata - `--anonymize-uploads ` - randomize uploaded filenames - `--dedupe-uploads ` - store files based on their hash to reduce data storage requirements if duplicates are uploaded with different filenames - `--skip-release-env` - skip generation the release environment file diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 4865ff0aa..869179798 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -286,6 +286,9 @@ Notes: * `sign_object_fetches`: Sign object fetches with HTTP signatures * `authorized_fetch_mode`: Require HTTP signatures for AP fetches * `authorized_fetch_mode_exceptions`: List of IPs (CIDR format accepted) to exempt from HTTP Signatures requirement (for example to allow debugging, you shouldn't otherwise need this) +* `signature_policy`: What to do with incoming HTTP signatures that do not sign the `date` header (or `(created)`), whose signed date is outside the window below, or that do not sign the `digest` header of a request with a body (or whose body Pleroma did not hash). `:enforce` (default) treats them as invalid, `:log` only logs a warning. +* `signature_max_age`: How old, in seconds, the signed date of an incoming HTTP signature can be. Default: `7200` (2 hours). +* `signature_max_clock_skew`: How far, in seconds, the signed `date` of an incoming HTTP signature can be in the future. Default: `3600` (1 hour). A `(created)` time in the future is always rejected. ## Pleroma.User @@ -702,15 +705,19 @@ This filter replaces the filename (not the path) of an upload. For complete obfu No specific configuration. -#### Pleroma.Upload.Filter.Exiftool.StripLocation +#### Pleroma.Upload.Filter.Exif.StripLocation -This filter only strips the GPS and location metadata with Exiftool leaving color profiles and attributes intact. +This filter strips GPS and location metadata from JPEG, WebP, HEIC, AVIF and TIFF images, leaving color profiles and attributes intact. For PNG images it removes all text and EXIF metadata chunks. Images are not re-encoded, and other formats are left unchanged. + +It replaces the deprecated `Pleroma.Upload.Filter.Exiftool.StripLocation`, which no longer needs exiftool. No specific configuration. -#### Pleroma.Upload.Filter.Exiftool.ReadDescription +#### Pleroma.Upload.Filter.Exif.ReadDescription -This filter reads the ImageDescription and iptc:Caption-Abstract fields with Exiftool so clients can prefill the media description field. +This filter reads the ImageDescription and iptc:Caption-Abstract fields so clients can prefill the media description field. + +It replaces the deprecated `Pleroma.Upload.Filter.Exiftool.ReadDescription`, which no longer needs exiftool. No specific configuration. diff --git a/docs/configuration/search.md b/docs/configuration/search.md index 5820cdbd9..c37629184 100644 --- a/docs/configuration/search.md +++ b/docs/configuration/search.md @@ -10,6 +10,14 @@ To use built-in search that has no external dependencies, set the search module While it has no external dependencies, it has problems with performance and relevancy. +With a GIN index (the default), search queries limit how many matches PostgreSQL considers (`gin_fuzzy_search_limit`, see [PostgreSQL's docs](https://www.postgresql.org/docs/current/gin.html#GIN-TIPS)). This keeps searches for common words fast on large databases, but the results are incomplete and can change between requests. On smaller instances you can turn it off for exact results: + +> config :pleroma, Pleroma.Search.DatabaseSearch, gin_fuzzy_search_limit: nil + +Without a limit, searches for common words can take longer than the database timeout on large databases, and then return nothing. + +Earlier versions of Pleroma set this limit for every database connection, which also made other GIN index scans (for example recipient lookups) incomplete. It is now only set for search queries. + ## QdrantSearch This uses the vector search engine [Qdrant](https://qdrant.tech) to search the posts in a vector space. This needs a way to generate embeddings and uses the [OpenAI API](https://platform.openai.com/docs/guides/embeddings/what-are-embeddings). This is implemented by several project besides OpenAI itself, including the python-based fastembed-server found in `supplemental/search/fastembed-api`. diff --git a/docs/installation/alpine_linux_en.md b/docs/installation/alpine_linux_en.md index 7154bca48..777a94dfc 100644 --- a/docs/installation/alpine_linux_en.md +++ b/docs/installation/alpine_linux_en.md @@ -68,7 +68,7 @@ sudo rc-update add postgresql ### Install media / graphics packages (optional, see [`docs/installation/optional/media_graphics_packages.md`](../installation/optional/media_graphics_packages.md)) ```shell -sudo apk add ffmpeg imagemagick exiftool +sudo apk add ffmpeg imagemagick ``` ### Install PleromaBE diff --git a/docs/installation/debian_based_en.md b/docs/installation/debian_based_en.md index f9086d2f9..2c85e52aa 100644 --- a/docs/installation/debian_based_en.md +++ b/docs/installation/debian_based_en.md @@ -36,7 +36,7 @@ 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 +sudo apt install imagemagick ffmpeg ``` ### Install PleromaBE diff --git a/docs/installation/debian_based_jp.md b/docs/installation/debian_based_jp.md index c853840c2..e374900e5 100644 --- a/docs/installation/debian_based_jp.md +++ b/docs/installation/debian_based_jp.md @@ -28,7 +28,6 @@ Note: This article is potentially outdated because at this time we may not have - `certbot` (または何らかのLet's Encrypt向けACMEクライアント) - `ImageMagick` - `ffmpeg` -- `exiftool` ### システムを準備する @@ -60,7 +59,7 @@ sudo apt install elixir erlang-dev erlang-nox ### オプションパッケージ: [`docs/installation/optional/media_graphics_packages.md`](../installation/optional/media_graphics_packages.md) ```shell -sudo apt install imagemagick ffmpeg libimage-exiftool-perl +sudo apt install imagemagick ffmpeg ``` ### Pleroma BE (バックエンド) をインストールします diff --git a/docs/installation/freebsd_en.md b/docs/installation/freebsd_en.md index 920bc7d35..2b857b9a9 100644 --- a/docs/installation/freebsd_en.md +++ b/docs/installation/freebsd_en.md @@ -31,7 +31,7 @@ Setup the required services to automatically start at boot, using `sysrc(8)`. ### Install media / graphics packages (optional, see [`docs/installation/optional/media_graphics_packages.md`](../installation/optional/media_graphics_packages.md)) ```shell -# pkg install imagemagick ffmpeg p5-Image-ExifTool vips +# pkg install imagemagick ffmpeg vips ``` ## Configuring Pleroma diff --git a/docs/installation/generic_dependencies.include b/docs/installation/generic_dependencies.include index 86dbc6838..da7e50848 100644 --- a/docs/installation/generic_dependencies.include +++ b/docs/installation/generic_dependencies.include @@ -12,7 +12,6 @@ * ImageMagick * FFmpeg -* exiftool !!!info **Source installs only** diff --git a/docs/installation/gentoo_en.md b/docs/installation/gentoo_en.md index dc47d27f8..066497b85 100644 --- a/docs/installation/gentoo_en.md +++ b/docs/installation/gentoo_en.md @@ -41,7 +41,6 @@ Gentoo quite pointedly does not come with a cron daemon installed, and as such i * `app-crypt/certbot-nginx` (nginx certbot plugin that allows use of the all-powerful `--nginx` flag on certbot) * `media-gfx/imagemagick` * `media-video/ffmpeg` -* `media-libs/exiftool` ### Prepare the system @@ -97,7 +96,7 @@ Not only does this make it much easier to deploy changes you make, as you can co ### Install media / graphics packages (optional, see [`docs/installation/optional/media_graphics_packages.md`](docs/installation/optional/media_graphics_packages.md)) ```shell -# emerge --ask media-video/ffmpeg media-gfx/imagemagick media-libs/exiftool +# emerge --ask media-video/ffmpeg media-gfx/imagemagick ``` ### Install PleromaBE diff --git a/docs/installation/gentoo_otp_en.md b/docs/installation/gentoo_otp_en.md index 20d8835da..352d9b807 100644 --- a/docs/installation/gentoo_otp_en.md +++ b/docs/installation/gentoo_otp_en.md @@ -25,7 +25,6 @@ Gentoo quite pointedly does not come with a cron daemon installed, and as such i * `app-crypt/certbot-nginx` (nginx certbot plugin that allows use of the all-powerful `--nginx` flag on certbot) * `media-gfx/imagemagick` * `media-video/ffmpeg` -* `media-libs/exiftool` ### Prepare the system @@ -68,7 +67,7 @@ The output from emerging postgresql should give you a command for initializing t See [Optional software packages needed for specific functionality](optional/media_graphics_packages.md) for details. ```shell -# emerge --ask media-video/ffmpeg media-gfx/imagemagick media-libs/exiftool +# emerge --ask media-video/ffmpeg media-gfx/imagemagick ``` ### Setup PleromaBE diff --git a/docs/installation/openbsd_en.md b/docs/installation/openbsd_en.md index 82d137e96..1cc638ae0 100644 --- a/docs/installation/openbsd_en.md +++ b/docs/installation/openbsd_en.md @@ -26,12 +26,11 @@ Independently of the reverse proxy, [acme-client(1)](https://man.openbsd.org/acm * ImageMagick * ffmpeg - * exiftool To install the above: ``` -# pkg_add ImageMagick ffmpeg p5-Image-ExifTool +# pkg_add ImageMagick ffmpeg ``` For more information read [`docs/installation/optional/media_graphics_packages.md`](../installation/optional/media_graphics_packages.md): diff --git a/docs/installation/openbsd_fi.md b/docs/installation/openbsd_fi.md index 858e64020..9ed553280 100644 --- a/docs/installation/openbsd_fi.md +++ b/docs/installation/openbsd_fi.md @@ -25,11 +25,10 @@ Asenna tarvittava ohjelmisto: [`docs/installation/optional/media_graphics_packages.md`](../installation/optional/media_graphics_packages.md): * ImageMagick * ffmpeg - * exiftool Asenna tarvittava ohjelmisto: -`# pkg_add ImageMagick ffmpeg p5-Image-ExifTool` +`# pkg_add ImageMagick ffmpeg` Luo postgresql-tietokanta: diff --git a/docs/installation/optional/media_graphics_packages.md b/docs/installation/optional/media_graphics_packages.md index 89125d1c6..e7e6283f8 100644 --- a/docs/installation/optional/media_graphics_packages.md +++ b/docs/installation/optional/media_graphics_packages.md @@ -2,9 +2,8 @@ For specific Pleroma functionality (which is disabled by default) some or all of the below packages are required: -* `ImageMagic` +* `ImageMagick` * `ffmpeg` -* `exiftool` Please refer to documentation in `docs/installation` on how to install them on specific OS. @@ -26,12 +25,3 @@ It is required for the following Pleroma features: It is required for the following Pleroma 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: `Pleroma.Upload/filters` in `config/config.exs`) -* `Pleroma.Upload.Filters.Exiftool.ReadDescription` upload filter (related config: `Pleroma.Upload/filters` in `config/config.exs`) diff --git a/docs/installation/otp_en.md b/docs/installation/otp_en.md index 0f296a9f6..7132ef5a5 100644 --- a/docs/installation/otp_en.md +++ b/docs/installation/otp_en.md @@ -61,17 +61,16 @@ Other than things bundled in the OTP release Pleroma depends on: Per [`docs/installation/optional/media_graphics_packages.md`](optional/media_graphics_packages.md): * ImageMagick * ffmpeg - * exiftool === "Alpine" ``` apk update - apk add imagemagick ffmpeg exiftool + apk add imagemagick ffmpeg ``` === "Debian/Ubuntu" ``` - apt install imagemagick ffmpeg libimage-exiftool-perl + apt install imagemagick ffmpeg ``` ## Setup diff --git a/installation/release-to-docker/Dockerfile b/installation/release-to-docker/Dockerfile index 0ffeb5d5b..ac9118aba 100644 --- a/installation/release-to-docker/Dockerfile +++ b/installation/release-to-docker/Dockerfile @@ -12,7 +12,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ openssl libssl3 \ libmagic1t64 file \ postgresql-client \ - ffmpeg imagemagick libimage-exiftool-perl \ + ffmpeg imagemagick \ libvips42t64 \ unzip \ curl \ diff --git a/lib/mix/pleroma.ex b/lib/mix/pleroma.ex index aebad6984..080dd1065 100644 --- a/lib/mix/pleroma.ex +++ b/lib/mix/pleroma.ex @@ -17,7 +17,7 @@ defmodule Mix.Pleroma do :oban, :logger_backends ] - @cachex_children ["object", "user", "scrubber", "web_resp"] + @cachex_children ["object", "user", "scrubber", "web_resp", "http_backoff"] @doc "Common functions to be reused in mix tasks" def start_pleroma do Pleroma.Config.Holder.save_default() diff --git a/lib/mix/tasks/pleroma/instance.ex b/lib/mix/tasks/pleroma/instance.ex index 143af5cdd..1a196264a 100644 --- a/lib/mix/tasks/pleroma/instance.ex +++ b/lib/mix/tasks/pleroma/instance.ex @@ -162,38 +162,20 @@ defmodule Mix.Tasks.Pleroma.Instance do ) |> Path.expand() - {strip_uploads_location_message, strip_uploads_location_default} = - if Pleroma.Utils.command_available?("exiftool") do - {"Do you want to strip location (GPS) data from uploaded images? This requires exiftool, it was detected as installed. (y/n)", - "y"} - else - {"Do you want to strip location (GPS) data from uploaded images? This requires exiftool, it was detected as not installed, please install it if you answer yes. (y/n)", - "n"} - end - strip_uploads_location = get_option( options, :strip_uploads_location, - strip_uploads_location_message, - strip_uploads_location_default + "Do you want to strip location (GPS) data from uploaded images? (y/n)", + "y" ) === "y" - {read_uploads_description_message, read_uploads_description_default} = - if Pleroma.Utils.command_available?("exiftool") do - {"Do you want to read data from uploaded files so clients can use it to prefill fields like image description? This requires exiftool, it was detected as installed. (y/n)", - "y"} - else - {"Do you want to read data from uploaded files so clients can use it to prefill fields like image description? This requires exiftool, it was detected as not installed, please install it if you answer yes. (y/n)", - "n"} - end - read_uploads_description = get_option( options, :read_uploads_description, - read_uploads_description_message, - read_uploads_description_default + "Do you want to read metadata from uploaded images so clients can prefill the media description field? (y/n)", + "y" ) === "y" anonymize_uploads = @@ -324,14 +306,14 @@ defmodule Mix.Tasks.Pleroma.Instance do defp upload_filters(filters) when is_map(filters) do enabled_filters = if filters.strip_location do - [Pleroma.Upload.Filter.Exiftool.StripLocation] + [Pleroma.Upload.Filter.Exif.StripLocation] else [] end enabled_filters = if filters.read_description do - enabled_filters ++ [Pleroma.Upload.Filter.Exiftool.ReadDescription] + enabled_filters ++ [Pleroma.Upload.Filter.Exif.ReadDescription] else enabled_filters end diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index adc054f3b..2c8eede1c 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -198,6 +198,7 @@ defmodule Pleroma.Application do ), build_cachex("rel_me", limit: 2500), build_cachex("host_meta", default_ttl: :timer.minutes(120), limit: 5_000), + build_cachex("http_backoff", limit: 10_000), build_cachex("translations", default_ttl: :timer.hours(24), limit: 5_000) ] end diff --git a/lib/pleroma/application_requirements.ex b/lib/pleroma/application_requirements.ex index 87ecb7e2d..d9f4eb1d0 100644 --- a/lib/pleroma/application_requirements.ex +++ b/lib/pleroma/application_requirements.ex @@ -168,8 +168,6 @@ defmodule Pleroma.ApplicationRequirements do defp check_system_commands!(:ok) do filter_commands_statuses = [ - check_filter(Pleroma.Upload.Filter.Exiftool.StripLocation, "exiftool"), - check_filter(Pleroma.Upload.Filter.Exiftool.ReadDescription, "exiftool"), check_filter(Pleroma.Upload.Filter.Mogrify, "mogrify"), check_filter(Pleroma.Upload.Filter.Mogrifun, "mogrify"), check_filter(Pleroma.Upload.Filter.AnalyzeMetadata, "ffprobe") diff --git a/lib/pleroma/config/deprecation_warnings.ex b/lib/pleroma/config/deprecation_warnings.ex index 934a4a02e..b383b646c 100644 --- a/lib/pleroma/config/deprecation_warnings.ex +++ b/lib/pleroma/config/deprecation_warnings.ex @@ -22,40 +22,44 @@ defmodule Pleroma.Config.DeprecationWarnings do @logger_formatter_config_knobs [:colors, :format, :metadata, :truncate, :utc_log] + @exiftool_filter_replacements %{ + Pleroma.Upload.Filter.Exiftool => Pleroma.Upload.Filter.Exif.StripLocation, + Pleroma.Upload.Filter.Exiftool.StripLocation => Pleroma.Upload.Filter.Exif.StripLocation, + Pleroma.Upload.Filter.Exiftool.ReadDescription => Pleroma.Upload.Filter.Exif.ReadDescription + } + def check_exiftool_filter do filters = Config.get([Pleroma.Upload]) |> Keyword.get(:filters, []) - if Pleroma.Upload.Filter.Exiftool in filters do - Logger.warning(""" - !!!DEPRECATION WARNING!!! - Your config is using Exiftool as a filter instead of Exiftool.StripLocation. This should work for now, but you are advised to change to the new configuration to prevent possible issues later: + deprecated_filters = + filters + |> Enum.filter(&Map.has_key?(@exiftool_filter_replacements, &1)) + |> Enum.uniq() - ``` - config :pleroma, Pleroma.Upload, - filters: [Pleroma.Upload.Filter.Exiftool] - ``` + case deprecated_filters do + [] -> + :ok - Is now + deprecated_filters -> + replacements = + Enum.map_join(deprecated_filters, fn filter -> + "\n* `#{inspect(filter)}` is now `#{inspect(@exiftool_filter_replacements[filter])}`" + end) + Logger.warning(""" + !!!DEPRECATION WARNING!!! + Your config is using Exiftool upload filters. Pleroma no longer needs exiftool, the filters were replaced by built-in ones. The old names should work for now, but you are advised to change to the new configuration to prevent possible issues later: + #{replacements} + """) - ``` - config :pleroma, Pleroma.Upload, - filters: [Pleroma.Upload.Filter.Exiftool.StripLocation] - ``` - """) + new_config = + filters + |> Enum.map(&Map.get(@exiftool_filter_replacements, &1, &1)) + |> Enum.uniq() - new_config = - filters - |> Enum.map(fn - Pleroma.Upload.Filter.Exiftool -> Pleroma.Upload.Filter.Exiftool.StripLocation - filter -> filter - end) + Config.put([Pleroma.Upload, :filters], new_config) - Config.put([Pleroma.Upload, :filters], new_config) - - :error - else - :ok + :error end end diff --git a/lib/pleroma/config/transfer_task.ex b/lib/pleroma/config/transfer_task.ex index 48cb158ca..731d05e8b 100644 --- a/lib/pleroma/config/transfer_task.ex +++ b/lib/pleroma/config/transfer_task.ex @@ -48,6 +48,7 @@ defmodule Pleroma.Config.TransferTask do settings = (Repo.all(ConfigDB) ++ deleted_settings) |> Enum.reject(&ConfigDB.static?/1) + |> Enum.reject(&(&1.group == :invalid_atom or &1.key == :invalid_atom)) |> Enum.map(&merge_with_default/1) started_applications = Application.started_applications() diff --git a/lib/pleroma/ecto_type/config/atom.ex b/lib/pleroma/ecto_type/config/atom.ex index c44d655e0..dad640f3d 100644 --- a/lib/pleroma/ecto_type/config/atom.ex +++ b/lib/pleroma/ecto_type/config/atom.ex @@ -5,6 +5,8 @@ defmodule Pleroma.EctoType.Config.Atom do use Ecto.Type + require Logger + def type, do: :atom def cast(key) when is_atom(key) do @@ -17,8 +19,17 @@ defmodule Pleroma.EctoType.Config.Atom do def cast(_), do: :error + # A key or group naming a module that no longer exists (e.g. a removed uploader) + # must not crash loading def load(key) do {:ok, Pleroma.ConfigDB.string_to_elixir_types(key)} + rescue + ArgumentError -> + Logger.warning( + "ConfigDB: unknown module #{key} in a setting key or group, the setting is not applied" + ) + + {:ok, :invalid_atom} end def dump(key) when is_atom(key), do: {:ok, inspect(key)} diff --git a/lib/pleroma/gun/conn.ex b/lib/pleroma/gun/conn.ex index 85a2fb0e8..3291b9048 100644 --- a/lib/pleroma/gun/conn.ex +++ b/lib/pleroma/gun/conn.ex @@ -14,7 +14,9 @@ defmodule Pleroma.Gun.Conn do opts |> Enum.into(%{}) |> Map.put_new(:connect_timeout, pool_opts[:connect_timeout] || 5_000) - |> Map.put_new(:supervise, false) + # Under gun's supervisor, a failed connect does not kill the caller + # through the link. Gun still stops when its owner goes down. + |> Map.put_new(:supervise, true) |> maybe_add_tls_opts(uri) do_open(uri, opts) @@ -117,8 +119,7 @@ defmodule Pleroma.Gun.Conn do host = Pleroma.HTTP.AdapterHelper.parse_host(host) opts = Map.put(opts, :transport, transport(uri.scheme)) - with {:ok, conn} <- Gun.open(host, port, opts), - {:ok, protocol} <- await_up(conn, opts[:connect_timeout]) do + with {:ok, conn, protocol} <- open_direct(host, port, opts) do {:ok, conn, protocol, nil} else error -> @@ -130,6 +131,40 @@ defmodule Pleroma.Gun.Conn do end end + defp open_direct(host, port, opts) do + result = + with {:ok, conn} <- Gun.open(host, port, opts), + {:ok, protocol} <- await_up(conn, opts[:connect_timeout]) do + {:ok, conn, protocol} + end + + case inet6_fallback(result, host, opts) do + {:ok, inet6_opts} -> open_direct(host, port, inet6_opts) + :error -> result + end + end + + # Gun's default when :tcp_opts is not set. + @default_tcp_opts [send_timeout: 15_000, send_timeout_close: true] + + @doc """ + Gun resolves hostnames with `inet_tcp` (A records only) unless `:inet6` is + in `:tcp_opts`, for both the :tcp and :tls transports. When the A lookup + finds nothing, returns the options for one more attempt over IPv6. + """ + @spec inet6_fallback(term(), Pleroma.HTTP.AdapterHelper.host(), map()) :: {:ok, map()} | :error + def inet6_fallback({:error, {:down, {:shutdown, :nxdomain}}}, host, opts) when is_list(host) do + tcp_opts = Map.get(opts, :tcp_opts, @default_tcp_opts) + + if :inet6 in tcp_opts do + :error + else + {:ok, Map.put(opts, :tcp_opts, [:inet6 | tcp_opts])} + end + end + + def inet6_fallback(_result, _host, _opts), do: :error + defp destination_opts(%URI{host: host, port: port}) do host = Pleroma.HTTP.AdapterHelper.parse_host(host) %{host: host, port: port} diff --git a/lib/pleroma/html.ex b/lib/pleroma/html.ex index 4de7cbb76..aeac8bd04 100644 --- a/lib/pleroma/html.ex +++ b/lib/pleroma/html.ex @@ -66,14 +66,16 @@ defmodule Pleroma.HTML do end @spec extract_first_external_url_from_object(Pleroma.Object.t()) :: String.t() | nil - def extract_first_external_url_from_object(%{data: %{"content" => content}}) + def extract_first_external_url_from_object(%{data: %{"content" => content} = data}) when is_binary(content) do + # A quote post's fallback link ("RE: ", in a .quote-inline element or + # bare) points at the quoted post, which is shown already. content |> Floki.parse_fragment!() + |> Floki.filter_out(".quote-inline") |> Floki.find("a:not(.mention,.hashtag,.attachment,[rel~=\"tag\"])") - |> Enum.take(1) |> Floki.attribute("href") - |> Enum.at(0) + |> Enum.find(&(&1 != data["quoteUrl"])) end def extract_first_external_url_from_object(_), do: nil diff --git a/lib/pleroma/http/adapter_helper.ex b/lib/pleroma/http/adapter_helper.ex index 4f0e23f20..fa1d1ab54 100644 --- a/lib/pleroma/http/adapter_helper.ex +++ b/lib/pleroma/http/adapter_helper.ex @@ -20,8 +20,9 @@ defmodule Pleroma.HTTP.AdapterHelper do @callback options(keyword(), URI.t()) :: keyword() + # AdminFE stores an empty proxy field as "", which means no proxy. @spec format_proxy(String.t() | tuple() | nil) :: proxy() | nil - def format_proxy(nil), do: nil + def format_proxy(proxy) when proxy in [nil, ""], do: nil def format_proxy(proxy_url) do case parse_proxy(proxy_url) do @@ -32,7 +33,7 @@ defmodule Pleroma.HTTP.AdapterHelper do end @spec maybe_add_proxy(keyword(), proxy() | nil) :: keyword() - def maybe_add_proxy(opts, nil), do: opts + def maybe_add_proxy(opts, proxy) when proxy in [nil, ""], do: opts def maybe_add_proxy(opts, proxy), do: Keyword.put_new(opts, :proxy, proxy) @doc """ diff --git a/lib/pleroma/http/backoff.ex b/lib/pleroma/http/backoff.ex new file mode 100644 index 000000000..5e432b285 --- /dev/null +++ b/lib/pleroma/http/backoff.ex @@ -0,0 +1,153 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.HTTP.Backoff do + @moduledoc """ + GET wrapper that stops sending requests to a host after it answers + 429 or 503, until the time given by `Retry-After` or `X-RateLimit-Reset`. + + Based on Akkoma's `Pleroma.HTTP.Backoff`. + """ + + alias Pleroma.HTTP + + require Logger + + @cachex Pleroma.Config.get([:cachex, :provider], Cachex) + @cache :http_backoff_cache + + @default_seconds 5 * 60 + @max_seconds 60 * 60 + @max_job_age 2 * 60 * 60 + + @spec get(String.t(), list(), keyword()) :: {:ok, Tesla.Env.t()} | {:error, any()} + def get(url, headers \\ [], options \\ []) when is_binary(url) do + host = url |> URI.parse() |> Map.get(:host) |> downcase() + + case @cachex.get(@cache, host) do + {:ok, true} -> + {:error, :ratelimit} + + _ -> + url + |> HTTP.get(headers, options) + |> check_backoff(host) + end + end + + defp downcase(nil), do: nil + defp downcase(host), do: String.downcase(host) + + defp check_backoff({:ok, %{status: status, headers: headers} = env}, host) + when status in [429, 503] do + host = response_host(env, host) + seconds = backoff_seconds(headers, DateTime.utc_now()) + Logger.warning("#{host} answered #{status}, backing off for #{seconds} seconds") + @cachex.put(@cache, host, true, ttl: :timer.seconds(seconds)) + {:error, :ratelimit} + end + + defp check_backoff(response, _host), do: response + + # After a redirect, the 429/503 came from the last host, not the requested one. + defp response_host(%{url: url}, host) when is_binary(url) do + URI.parse(url).host |> downcase() || host + end + + defp response_host(_env, host), do: host + + @doc """ + Turns an Oban job's `{:error, :ratelimit}` result into a snooze for the + default backoff, so the job's attempts aren't all spent while the host is + backed off. Jobs inserted more than #{@max_job_age} seconds ago get the + error back and fail as usual. + """ + @spec snooze_ratelimited(any(), Oban.Job.t()) :: any() + def snooze_ratelimited({:error, :ratelimit} = error, %Oban.Job{inserted_at: inserted_at}) do + if is_nil(inserted_at) or DateTime.diff(DateTime.utc_now(), inserted_at) < @max_job_age do + {:snooze, @default_seconds} + else + error + end + end + + def snooze_ratelimited(result, _job), do: result + + @doc """ + Seconds to back off for, from the first valid `Retry-After` or + `X-RateLimit-Reset` header. Missing, invalid or past values give the + default of #{@default_seconds} seconds; values are capped at #{@max_seconds}. + """ + @spec backoff_seconds(list(), DateTime.t()) :: pos_integer() + def backoff_seconds(headers, now) do + [ + parse_retry_after(header(headers, "retry-after"), now), + parse_ratelimit_reset(header(headers, "x-ratelimit-reset"), now) + ] + |> Enum.find(@default_seconds, &is_integer/1) + |> min(@max_seconds) + end + + defp header(headers, name) do + Enum.find_value(headers, fn + {key, value} when is_binary(key) and is_binary(value) -> + if String.downcase(key) == name, do: String.trim(value) + + _ -> + nil + end) + end + + # Retry-After is either delta-seconds or an HTTP-date (RFC 9110, 10.2.3) + defp parse_retry_after(nil, _now), do: nil + + defp parse_retry_after(value, now) do + case Integer.parse(value) do + {seconds, ""} when seconds > 0 -> seconds + {_, ""} -> nil + _ -> parse_timestamp(value, now) + end + end + + # Servers send X-RateLimit-Reset as a timestamp (ISO 8601 like Mastodon, or + # Unix seconds) or as seconds until the reset. + @unix_timestamp_min 1_000_000_000 + + defp parse_ratelimit_reset(nil, _now), do: nil + + defp parse_ratelimit_reset(value, now) do + case Integer.parse(value) do + {timestamp, ""} when timestamp >= @unix_timestamp_min -> + timestamp - DateTime.to_unix(now) + + {seconds, ""} -> + seconds + + _ -> + parse_timestamp(value, now) + end + |> positive() + end + + defp positive(seconds) when is_integer(seconds) and seconds > 0, do: seconds + defp positive(_), do: nil + + defp parse_timestamp(nil, _now), do: nil + + defp parse_timestamp(value, now) do + with {:ok, %DateTime{} = datetime} <- parse_datetime(value), + seconds when seconds > 0 <- DateTime.diff(datetime, now) do + seconds + else + _ -> nil + end + end + + defp parse_datetime(value) do + with {:error, _} <- Timex.parse(value, "{RFC1123}"), + {:ok, datetime, _offset} <- DateTime.from_iso8601(value) do + {:ok, datetime} + end + end +end diff --git a/lib/pleroma/instances/instance.ex b/lib/pleroma/instances/instance.ex index 3695e0b75..3d3ef5774 100644 --- a/lib/pleroma/instances/instance.ex +++ b/lib/pleroma/instances/instance.ex @@ -91,6 +91,13 @@ defmodule Pleroma.Instances.Instance do def reachable?(url_or_host) when is_binary(url_or_host), do: true + @doc "Creates a record for the instance unless one exists. Existing records are kept." + def ensure_exists(url_or_host) when is_binary(url_or_host) do + %Instance{host: host(url_or_host)} + |> changeset(%{}) + |> Repo.insert(on_conflict: :nothing, conflict_target: :host) + end + def set_reachable(url_or_host) when is_binary(url_or_host) do host = host(url_or_host) diff --git a/lib/pleroma/object/fetcher.ex b/lib/pleroma/object/fetcher.ex index 706ee317c..3313583cc 100644 --- a/lib/pleroma/object/fetcher.ex +++ b/lib/pleroma/object/fetcher.ex @@ -216,7 +216,7 @@ defmodule Pleroma.Object.Fetcher do |> maybe_date_fetch(date) |> sign_fetch(id, date) - case HTTP.get(id, headers) do + case HTTP.Backoff.get(id, headers) do {:ok, %{body: body, status: code, headers: headers, url: final_url}} when code in 200..299 -> remote_host = if final_url, do: URI.parse(final_url).host, else: nil diff --git a/lib/pleroma/search/database_search.ex b/lib/pleroma/search/database_search.ex index e88d632cb..dca69260d 100644 --- a/lib/pleroma/search/database_search.ex +++ b/lib/pleroma/search/database_search.ex @@ -7,6 +7,7 @@ defmodule Pleroma.Search.DatabaseSearch do alias Pleroma.Config alias Pleroma.Object.Fetcher alias Pleroma.Pagination + alias Pleroma.Repo alias Pleroma.User alias Pleroma.Web.ActivityPub.Visibility @@ -23,22 +24,49 @@ defmodule Pleroma.Search.DatabaseSearch do offset = Keyword.get(options, :offset, 0) author = Keyword.get(options, :author) - try do - Activity - |> Activity.with_preloaded_object() - |> Activity.restrict_deactivated_users() - |> restrict_public(user) - |> query_with(index_type, search_query) - |> maybe_restrict_local(user) - |> maybe_restrict_author(author) - |> maybe_restrict_blocked(user) - |> Pagination.fetch_paginated( - %{"offset" => offset, "limit" => limit, "skip_order" => index_type == :rum}, - :offset - ) - |> maybe_fetch(user, search_query) - rescue - _ -> maybe_fetch([], user, search_query) + activities = + try do + with_fuzzy_limit(fn -> + Activity + |> Activity.with_preloaded_object() + |> Activity.restrict_deactivated_users() + |> restrict_public(user) + |> query_with(index_type, search_query) + |> maybe_restrict_local(user) + |> maybe_restrict_author(author) + |> maybe_restrict_blocked(user) + |> Pagination.fetch_paginated( + %{"offset" => offset, "limit" => limit, "skip_order" => index_type == :rum}, + :offset + ) + end) + rescue + _ -> [] + end + + # Outside the query transaction, as it may fetch over the network. + maybe_fetch(activities, user, search_query, options) + end + + @doc """ + Runs `fun` with `gin_fuzzy_search_limit` set, if configured under + `Pleroma.Search.DatabaseSearch`. The limit makes GIN scans faster but their + results incomplete and random, so it is only set for search queries. + """ + def with_fuzzy_limit(fun) do + case Config.get([__MODULE__, :gin_fuzzy_search_limit]) do + limit when is_integer(limit) and limit > 0 -> + {:ok, result} = + Repo.transaction(fn -> + # SET LOCAL takes no parameters; limit is an integer. + Repo.query!("SET LOCAL gin_fuzzy_search_limit TO #{limit}") + fun.() + end) + + result + + _ -> + fun.() end end @@ -132,7 +160,27 @@ defmodule Pleroma.Search.DatabaseSearch do defp restrict_local(q), do: where(q, local: true) - def maybe_fetch(activities, user, search_query) do + @doc """ + Adds the status a URL query points to, fetching it if needed. As in Mastodon, + this only happens with `resolve: true` and on the first page, and for + anonymous users only if `[:instance, :limit_to_local_content]` is `false`. + """ + def maybe_fetch(activities, user, search_query, options) do + if fetch_allowed?(user, options), + do: fetch(activities, user, search_query), + else: activities + end + + defp fetch_allowed?(user, options) do + options[:resolve] == true and (options[:offset] || 0) == 0 and + case Config.get([:instance, :limit_to_local_content], :unauthenticated) do + :all -> false + :unauthenticated -> match?(%User{}, user) + false -> true + end + end + + defp fetch(activities, user, search_query) do with true <- Regex.match?(~r/https?:/, search_query), {:ok, object} <- Fetcher.fetch_object_from_id(search_query), %Activity{} = activity <- Activity.get_create_by_object_ap_id(object.data["id"]), diff --git a/lib/pleroma/search/meilisearch.ex b/lib/pleroma/search/meilisearch.ex index dc10076e1..802870b75 100644 --- a/lib/pleroma/search/meilisearch.ex +++ b/lib/pleroma/search/meilisearch.ex @@ -111,11 +111,11 @@ defmodule Pleroma.Search.Meilisearch do |> maybe_restrict_local(user) |> maybe_restrict_author(author) |> maybe_restrict_blocked(user) - |> maybe_fetch(user, query) + |> maybe_fetch(user, query, options) |> order_by([object: obj], desc: obj.data["published"]) |> Pleroma.Repo.all() rescue - _ -> maybe_fetch([], user, query) + _ -> maybe_fetch([], user, query, options) end end end diff --git a/lib/pleroma/search/parade_db.ex b/lib/pleroma/search/parade_db.ex index ee00e74af..78d561505 100644 --- a/lib/pleroma/search/parade_db.ex +++ b/lib/pleroma/search/parade_db.ex @@ -12,7 +12,7 @@ defmodule Pleroma.Search.ParadeDB do import Pleroma.Search.DatabaseSearch, only: [ - maybe_fetch: 3, + maybe_fetch: 4, maybe_restrict_author: 2, maybe_restrict_blocked: 2, maybe_restrict_local: 2 @@ -149,19 +149,19 @@ defmodule Pleroma.Search.ParadeDB do case search_ids(query, limit, offset, author) do {:ok, []} -> - maybe_fetch([], user, query) + maybe_fetch([], user, query, options) {:ok, ids} -> ids - |> fetch_activities(user, query, author) + |> fetch_activities(user, query, author, options) {:error, error} -> Logger.error("ParadeDB search failed: #{inspect(error)}") - maybe_fetch([], user, query) + maybe_fetch([], user, query, options) end end - defp fetch_activities(ids, user, query, author) do + defp fetch_activities(ids, user, query, author, options) do dumped_ids = ids |> Enum.map(&dump_activity_id/1) @@ -183,7 +183,7 @@ defmodule Pleroma.Search.ParadeDB do ) ) |> Pleroma.Repo.all() - |> maybe_fetch(user, query) + |> maybe_fetch(user, query, options) end defp maybe_restrict_public(query, %Pleroma.User{}) do diff --git a/lib/pleroma/search/qdrant_search.ex b/lib/pleroma/search/qdrant_search.ex index a8c5a3eb9..a453d222f 100644 --- a/lib/pleroma/search/qdrant_search.ex +++ b/lib/pleroma/search/qdrant_search.ex @@ -10,7 +10,7 @@ defmodule Pleroma.Search.QdrantSearch do alias __MODULE__.OpenAIClient alias __MODULE__.QdrantClient - import Pleroma.Search.DatabaseSearch, only: [maybe_fetch: 3] + import Pleroma.Search.DatabaseSearch, only: [maybe_fetch: 4] @impl true def create_index do @@ -131,7 +131,7 @@ defmodule Pleroma.Search.QdrantSearch do |> Activity.restrict_deactivated_users() |> Ecto.Query.order_by([a], fragment("array_position(?, ?)", ^ids, a.id)) |> Pleroma.Repo.all() - |> maybe_fetch(user, original_query) + |> maybe_fetch(user, original_query, options) else _ -> [] diff --git a/lib/pleroma/signature.ex b/lib/pleroma/signature.ex index 382cf9db3..e85c2aa5e 100644 --- a/lib/pleroma/signature.ex +++ b/lib/pleroma/signature.ex @@ -95,6 +95,84 @@ defmodule Pleroma.Signature do Timex.format!(date, "{WDshort}, {0D} {Mshort} {YYYY} {h24}:{m}:{s} GMT") end + @doc """ + Checks the parts of an incoming signature that the cryptographic check does + not cover, so that a captured request cannot be replayed later or with a + different body. + + The signature must cover `date` or `(created)`, and the signed time must be + within the configured window. Requests other than GET and HEAD must also + sign `digest`, and DigestPlug must have hashed their body. Based on Akkoma's http_signatures fork and Mastodon's + SignedRequest. + """ + @spec check_signature_policy(Plug.Conn.t(), DateTime.t()) :: :ok | {:error, atom()} + def check_signature_policy(%Plug.Conn{} = conn, now \\ DateTime.utc_now()) do + signature = HTTPSignatures.signature_for_conn(conn) + signed = Map.get(signature, "headers", []) + + cond do + "date" not in signed and "(created)" not in signed -> + {:error, :date_not_signed} + + conn.method not in ["GET", "HEAD"] and "digest" not in signed -> + {:error, :digest_not_signed} + + # The plug puts the digest computed by DigestPlug into the header. With + # no computed digest (e.g. multipart), nothing checked the body. + conn.method not in ["GET", "HEAD"] and is_nil(conn.assigns[:digest]) -> + {:error, :body_not_hashed} + + true -> + with :ok <- check_signed_date(conn, signed, now) do + check_signed_created(signature, signed, now) + end + end + end + + defp check_signed_date(conn, signed, now) do + if "date" in signed do + # Exactly one header: a second one could make this check and the + # signature check see different dates. + with [date] <- Plug.Conn.get_req_header(conn, "date"), + {:ok, %DateTime{} = date} <- Timex.parse(date, "{RFC1123}") do + check_time_window(date, now) + else + _ -> {:error, :invalid_date} + end + else + :ok + end + end + + defp check_signed_created(signature, signed, now) do + if "(created)" in signed do + with created when is_binary(created) <- signature["created"], + {created, ""} <- Integer.parse(created), + {:ok, created} <- DateTime.from_unix(created) do + check_time_window(created, now) + else + _ -> {:error, :invalid_date} + end + else + :ok + end + end + + defp check_time_window(signed_at, now) do + age = DateTime.diff(now, signed_at) + + cond do + age > Pleroma.Config.get([:activitypub, :signature_max_age], 7200) -> + {:error, :date_too_old} + + -age > Pleroma.Config.get([:activitypub, :signature_max_clock_skew], 3600) -> + {:error, :date_in_future} + + true -> + :ok + end + end + @spec validate_signature(Plug.Conn.t(), String.t()) :: boolean() def validate_signature(%Plug.Conn{} = conn, request_target) do # Newer drafts for HTTP signatures now use @request-target instead of the diff --git a/lib/pleroma/stats.ex b/lib/pleroma/stats.ex index 47b30b951..079f6f7dd 100644 --- a/lib/pleroma/stats.ex +++ b/lib/pleroma/stats.ex @@ -8,10 +8,11 @@ defmodule Pleroma.Stats do import Ecto.Query alias Pleroma.CounterCache + alias Pleroma.Instances.Instance alias Pleroma.Repo alias Pleroma.User - @interval :timer.seconds(60) + @interval :timer.seconds(300) def start_link(_) do GenServer.start_link( @@ -64,14 +65,12 @@ defmodule Pleroma.Stats do } } def calculate_stat_data do + # Hosts are unique in instances, and remote users add theirs. Scanning + # all remote users for their domains was one of the costliest queries. + # Favicon lookups for local accounts can add our own host, so skip it. peers = - from( - u in User, - select: fragment("distinct split_part(?, '@', 2)", u.nickname), - where: u.local != ^true - ) + from(i in Instance, where: i.host != ^local_host(), select: i.host) |> Repo.all() - |> Enum.filter(& &1) domain_count = Enum.count(peers) @@ -97,6 +96,9 @@ defmodule Pleroma.Stats do } end + # Read from the config: this also runs before the endpoint has started. + defp local_host, do: Pleroma.Config.get([Pleroma.Web.Endpoint, :url, :host]) + @spec get_status_visibility_count(String.t() | nil) :: map() def get_status_visibility_count(instance \\ nil) do if is_nil(instance) do diff --git a/lib/pleroma/upload/filter/exif/read_description.ex b/lib/pleroma/upload/filter/exif/read_description.ex new file mode 100644 index 000000000..96b219042 --- /dev/null +++ b/lib/pleroma/upload/filter/exif/read_description.ex @@ -0,0 +1,32 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2026 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Upload.Filter.Exif.ReadDescription do + @moduledoc """ + Reads a description from image metadata when the upload has none yet. + + Checks EXIF ImageDescription, then IPTC Caption-Abstract (and XMP for GIF). Blank + values and values longer than `[:instance, :description_limit]` are ignored. + """ + + @behaviour Pleroma.Upload.Filter + + @impl true + def filter(%Pleroma.Upload{description: description}) when is_binary(description), + do: {:ok, :noop} + + def filter(%Pleroma.Upload{tempfile: file, content_type: "image" <> _} = upload) do + max_length = Pleroma.Config.get([:instance, :description_limit]) + + case ExifStripper.Description.read_file(file, max_length: max_length) do + description when is_binary(description) -> + {:ok, :filtered, %{upload | description: description}} + + nil -> + {:ok, :noop} + end + end + + def filter(_), do: {:ok, :noop} +end diff --git a/lib/pleroma/upload/filter/exif/strip_location.ex b/lib/pleroma/upload/filter/exif/strip_location.ex new file mode 100644 index 000000000..622f8e335 --- /dev/null +++ b/lib/pleroma/upload/filter/exif/strip_location.ex @@ -0,0 +1,25 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2026 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Upload.Filter.Exif.StripLocation do + @moduledoc """ + Strips GPS metadata from uploaded images and overwrites the file in place. + + Supports JPEG, PNG, WebP, TIFF and ISO-BMFF images (HEIC, AVIF). This is lossless: + the image data is not re-encoded. Other image types are left untouched. + """ + + @behaviour Pleroma.Upload.Filter + + @impl true + def filter(%Pleroma.Upload{tempfile: file, content_type: "image" <> _}) do + case ExifStripper.GpsStripper.strip_gps_data(file, file) do + :ok -> {:ok, :filtered} + :noop -> {:ok, :noop} + {:error, _} = error -> error + end + end + + def filter(_), do: {:ok, :noop} +end diff --git a/lib/pleroma/upload/filter/exiftool.ex b/lib/pleroma/upload/filter/exiftool.ex new file mode 100644 index 000000000..869ac8b2b --- /dev/null +++ b/lib/pleroma/upload/filter/exiftool.ex @@ -0,0 +1,14 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2026 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Upload.Filter.Exiftool do + @moduledoc """ + Deprecated: use `Pleroma.Upload.Filter.Exif.StripLocation`. This module delegates to it. + """ + + @behaviour Pleroma.Upload.Filter + + @impl true + defdelegate filter(upload), to: Pleroma.Upload.Filter.Exif.StripLocation +end diff --git a/lib/pleroma/upload/filter/exiftool/read_description.ex b/lib/pleroma/upload/filter/exiftool/read_description.ex index 8283b8643..c1c60dfdc 100644 --- a/lib/pleroma/upload/filter/exiftool/read_description.ex +++ b/lib/pleroma/upload/filter/exiftool/read_description.ex @@ -4,51 +4,11 @@ defmodule Pleroma.Upload.Filter.Exiftool.ReadDescription do @moduledoc """ - Gets a valid description from the related EXIF tags and provides them in the response if no description is provided yet. - It will first check ImageDescription, when that doesn't probide a valid description, it will check iptc:Caption-Abstract. - A valid description means the fields are filled in and not too long (see `:instance, :description_limit`). + Deprecated: use `Pleroma.Upload.Filter.Exif.ReadDescription`. This module delegates to it. """ + @behaviour Pleroma.Upload.Filter - def filter(%Pleroma.Upload{description: description}) - when is_binary(description), - do: {:ok, :noop} - - def filter(%Pleroma.Upload{tempfile: file} = upload), - do: {:ok, :filtered, upload |> Map.put(:description, read_description_from_exif_data(file))} - - def filter(_, _), do: {:ok, :noop} - - defp read_description_from_exif_data(file) do - nil - |> read_when_empty(file, "-ImageDescription") - |> read_when_empty(file, "-iptc:Caption-Abstract") - end - - defp read_when_empty(current_description, _, _) when is_binary(current_description), - do: current_description - - defp read_when_empty(_, file, tag) do - if File.exists?(file) do - try do - {tag_content, 0} = - System.cmd("exiftool", ["-m", "-b", "-s3", tag, file], - stderr_to_stdout: false, - parallelism: true - ) - - tag_content = String.trim(tag_content) - - if tag_content != "" and - String.length(tag_content) <= - Pleroma.Config.get([:instance, :description_limit]), - do: tag_content, - else: nil - rescue - _ in ErlangError -> nil - end - else - nil - end - end + @impl true + defdelegate filter(upload), to: Pleroma.Upload.Filter.Exif.ReadDescription end diff --git a/lib/pleroma/upload/filter/exiftool/strip_location.ex b/lib/pleroma/upload/filter/exiftool/strip_location.ex index 23346d234..3e64adca8 100644 --- a/lib/pleroma/upload/filter/exiftool/strip_location.ex +++ b/lib/pleroma/upload/filter/exiftool/strip_location.ex @@ -4,30 +4,11 @@ defmodule Pleroma.Upload.Filter.Exiftool.StripLocation do @moduledoc """ - Strips GPS related EXIF tags and overwrites the file in place. - Also strips or replaces filesystem metadata e.g., timestamps. + Deprecated: use `Pleroma.Upload.Filter.Exif.StripLocation`. This module delegates to it. """ + @behaviour Pleroma.Upload.Filter - # 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" <> _}), do: {:ok, :noop} - - def filter(%Pleroma.Upload{tempfile: file, content_type: "image" <> _}) do - try do - case System.cmd("exiftool", ["-m", "-overwrite_original", "-gps:all=", "-png:all=", file], - stderr_to_stdout: true, - parallelism: true - ) do - {_response, 0} -> {:ok, :filtered} - {error, _} -> {:error, error} - end - rescue - e in ErlangError -> - {:error, "#{__MODULE__}: #{inspect(e)}"} - end - end - - def filter(_), do: {:ok, :noop} + @impl true + defdelegate filter(upload), to: Pleroma.Upload.Filter.Exif.StripLocation end diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index b997e7831..807e68ffb 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -11,6 +11,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do alias Pleroma.Conversation.Participation alias Pleroma.Filter alias Pleroma.Hashtag + alias Pleroma.Instances.Instance alias Pleroma.Maps alias Pleroma.Notification alias Pleroma.Object @@ -465,21 +466,28 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do end def fetch_activities_for_context_query(context, opts) do - public = [Constants.as_public()] + user = opts[:user] recipients = - if opts[:user], - do: [opts[:user].ap_id | User.following(opts[:user])] ++ public, - else: public + cond do + user && user.local -> + [Constants.as_public(), as_local_public(), user.ap_id | User.following(user)] + + user -> + [Constants.as_public(), user.ap_id | User.following(user)] + + true -> + [Constants.as_public()] + end from(activity in Activity) |> maybe_preload_objects(opts) |> maybe_preload_bookmarks(opts) |> maybe_set_thread_muted_field(opts) - |> restrict_unauthenticated(opts[:user]) + |> restrict_unauthenticated(user) |> restrict_blocked(opts) |> restrict_blockers_visibility(opts) - |> restrict_recipients(recipients, opts[:user]) + |> restrict_recipients(recipients, user) |> restrict_filtered(opts) |> where( [activity], @@ -1948,6 +1956,10 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do Logger.info("Rejected user #{ap_id}: #{inspect(reason)}") {:error, e} + {:error, :ratelimit = e} -> + Logger.debug("Backing off from fetching user #{ap_id}") + {:error, e} + {:error, e} -> Logger.error("Could not decode user at fetch #{ap_id}, #{inspect(e)}") {:error, e} @@ -2057,6 +2069,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do def make_user_from_ap_id(ap_id, additional \\ []) do with {:ok, data} <- fetch_and_prepare_user_from_ap_id(ap_id, additional) do enqueue_pin_fetches(data) + # Keeps the instances table complete, so it can list our peers. + Instance.ensure_exists(data.ap_id) # the fetched actor's id differs from ap_id when it was fetched from a display URL user = User.get_cached_by_ap_id(data.ap_id) diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index e9b0a0d6d..ae45bc748 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -26,6 +26,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do alias Pleroma.Web.Plugs.EnsureAuthenticatedPlug alias Pleroma.Web.Plugs.FederatingPlug alias Pleroma.Web.Plugs.OAuthScopesPlug + alias Pleroma.Web.Plugs.RateLimiter require Logger @@ -66,6 +67,9 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do when action in [:followers, :following] ) + # Shares the per-user bucket with Mastodon API status actions and emoji reactions. + plug(RateLimiter, [name: :statuses_actions] when action == :update_outbox) + plug(Majic.Plug, [pool: Pleroma.MajicPool] when action in [:upload_media]) plug( @@ -341,7 +345,6 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do {:sender_exists, User.get_or_fetch_by_ap_id(params["actor"])}, {:recipient_active, true} <- {:recipient_active, recipient.is_active}, {:sender_active, true} <- {:sender_active, actor.is_active}, - true <- Utils.recipient_in_message(recipient, actor, params), params <- Utils.maybe_splice_recipient(recipient.ap_id, params) do Federator.incoming_ap_doc(params) json(conn, "ok") @@ -386,18 +389,25 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do end def inbox(%{assigns: %{valid_signature: false}} = conn, params) do - if unsupported_http_message_signature?(conn) do - reject_unsupported_http_message_signature(conn) - else - Federator.incoming_failed_signature_ap_doc(%{ - method: conn.method, - req_headers: conn.req_headers, - request_path: conn.request_path, - params: params, - query_string: conn.query_string - }) + cond do + unsupported_http_message_signature?(conn) -> + reject_unsupported_http_message_signature(conn) - json(conn, "ok") + reason = conn.assigns[:signature_error] -> + conn + |> put_status(:unauthorized) + |> json("error, invalid HTTP signature (#{reason})") + + true -> + Federator.incoming_failed_signature_ap_doc(%{ + method: conn.method, + req_headers: conn.req_headers, + request_path: conn.request_path, + params: params, + query_string: conn.query_string + }) + + json(conn, "ok") end end diff --git a/lib/pleroma/web/activity_pub/mrf.ex b/lib/pleroma/web/activity_pub/mrf.ex index 2496d1858..ddcd85624 100644 --- a/lib/pleroma/web/activity_pub/mrf.ex +++ b/lib/pleroma/web/activity_pub/mrf.ex @@ -148,14 +148,13 @@ defmodule Pleroma.Web.ActivityPub.MRF do @spec subdomains_regex([String.t()]) :: [Regex.t()] def subdomains_regex(domains) when is_list(domains) do for domain <- domains do - try do - target = String.replace(domain, "*.", "(.*\\.)*") - ~r<^#{target}$>i - rescue - e -> - Logger.error("MRF: Invalid subdomain Regex: #{domain}") - reraise e, __STACKTRACE__ - end + {prefix, rest} = + case domain do + "*." <> rest -> {"(.*\\.)?", rest} + _ -> {"", domain} + end + + ~r<^#{prefix}#{Regex.escape(rest)}$>i end end diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index 468298a4d..a89ad94f8 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -78,10 +78,16 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do |> Ecto.Changeset.apply_action(:insert) do object = stringify_keys(object) undone_object = Activity.get_by_ap_id(object["object"]) + outgoing_blocks = Pleroma.Config.get([:activitypub, :outgoing_blocks]) + # if we're undoing a block, and do not permit federating that: + do_not_federate = + Keyword.get(meta, :do_not_federate) || + (Map.get(undone_object.data, "type") == "Block" && !outgoing_blocks) meta = meta |> Keyword.put(:object_data, undone_object.data) + |> Keyword.put(:do_not_federate, do_not_federate) {:ok, object, meta} 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 1d6e0abfb..2f4490e91 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 @@ -213,7 +213,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ArticleNotePageValidator do defp fix_misskey_content(object), do: object - defp fix(data) do + def fix(data) do data |> CommonFixes.fix_actor() |> CommonFixes.fix_object_defaults() 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 65ba047e6..b7197c3f3 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 @@ -57,7 +57,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.EmojiReactValidator do |> CommonFixes.fix_actor() |> CommonFixes.fix_activity_addressing() - data = Map.put_new(data, "tag", []) + data = fix_tag(data) case Object.normalize(data["object"]) do %Object{} = object -> @@ -70,6 +70,10 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.EmojiReactValidator do end end + # JSON-LD compaction turns a single-element array into a bare object + defp fix_tag(%{"tag" => %{} = tag} = data), do: Map.put(data, "tag", [tag]) + defp fix_tag(data), do: Map.put_new(data, "tag", []) + defp fix_emoji_qualification(%{"content" => emoji} = data) do new_emoji = Pleroma.Emoji.fully_qualify_emoji(emoji) diff --git a/lib/pleroma/web/activity_pub/object_validators/question_validator.ex b/lib/pleroma/web/activity_pub/object_validators/question_validator.ex index 065c75910..be9dca4fa 100644 --- a/lib/pleroma/web/activity_pub/object_validators/question_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/question_validator.ex @@ -6,10 +6,9 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.QuestionValidator do use Ecto.Schema alias Pleroma.EctoType.ActivityPub.ObjectValidators - alias Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes + alias Pleroma.Web.ActivityPub.ObjectValidators.ArticleNotePageValidator alias Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations alias Pleroma.Web.ActivityPub.ObjectValidators.QuestionOptionsValidator - alias Pleroma.Web.ActivityPub.Transmogrifier import Ecto.Changeset @@ -62,11 +61,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.QuestionValidator do defp fix(data) do data - |> CommonFixes.fix_actor() - |> CommonFixes.fix_object_defaults() - |> CommonFixes.fix_quote_url() - |> CommonFixes.fix_likes() - |> Transmogrifier.fix_emoji() + |> ArticleNotePageValidator.fix() |> fix_closed() end diff --git a/lib/pleroma/web/activity_pub/publisher.ex b/lib/pleroma/web/activity_pub/publisher.ex index 17fae2a86..89c8168d4 100644 --- a/lib/pleroma/web/activity_pub/publisher.ex +++ b/lib/pleroma/web/activity_pub/publisher.ex @@ -281,36 +281,18 @@ defmodule Pleroma.Web.ActivityPub.Publisher do defp maybe_use_sharedinbox(%User{shared_inbox: shared_inbox}), do: shared_inbox @doc """ - Determine a user inbox to use based on heuristics. These heuristics - are based on an approximation of the ``sharedInbox`` rules in the - [ActivityPub specification][ap-sharedinbox]. + Determines the inbox to deliver an activity to. - Please do not edit this function (or its children) without reading - the spec, as editing the code is likely to introduce some breakage - without some familiarity. - - [ap-sharedinbox]: https://www.w3.org/TR/activitypub/#shared-inbox-delivery + Like Mastodon, GoToSocial and Akkoma, this always prefers the shared inbox, + which saves duplicate deliveries to the same server. Only activities with + `bcc` or `bto` go to personal inboxes, because the receiver rebuilds that + addressing from the inbox the activity arrived at. """ - def determine_inbox( - %Activity{data: activity_data}, - %User{inbox: inbox} = user - ) do - to = activity_data["to"] || [] - cc = activity_data["cc"] || [] - type = activity_data["type"] - - cond do - type == "Delete" -> - maybe_use_sharedinbox(user) - - Pleroma.Constants.as_public() in to || Pleroma.Constants.as_public() in cc -> - maybe_use_sharedinbox(user) - - length(to) + length(cc) > 1 -> - maybe_use_sharedinbox(user) - - true -> - inbox + def determine_inbox(%Activity{data: activity_data}, %User{inbox: inbox} = user) do + if Enum.empty?(activity_data["bcc"] || []) and Enum.empty?(activity_data["bto"] || []) do + maybe_use_sharedinbox(user) + else + inbox end end diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 33ab62ca3..0d4b15288 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -799,7 +799,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do ) 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), - true <- origin_actor in target_user.also_known_as do + {:ok, %User{} = target_user} <- refetch_unless_aliased(target_user, origin_actor) do ActivityPub.move(origin_user, target_user, false) else _e -> :error @@ -1204,4 +1204,20 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do def maybe_fix_user_url(data), do: data def maybe_fix_user_object(data), do: maybe_fix_user_url(data) + + # A Move usually follows right after the alias was added, which the cached + # remote target may not have yet. + defp refetch_unless_aliased(%User{} = target, origin_ap_id, refetch? \\ true) do + cond do + origin_ap_id in target.also_known_as -> + {:ok, target} + + refetch? and not target.local -> + with {:ok, %User{} = target} <- User.fetch_by_ap_id(target.ap_id), + do: refetch_unless_aliased(target, origin_ap_id, false) + + true -> + :error + end + end end diff --git a/lib/pleroma/web/activity_pub/utils.ex b/lib/pleroma/web/activity_pub/utils.ex index 0af4ceaf5..5c25f4c69 100644 --- a/lib/pleroma/web/activity_pub/utils.ex +++ b/lib/pleroma/web/activity_pub/utils.ex @@ -78,22 +78,6 @@ defmodule Pleroma.Web.ActivityPub.Utils do [params["to"], params["cc"], params["bto"], params["bcc"]] |> Enum.any?(&label_in_collection?(label, &1)) - @spec unaddressed_message?(map()) :: boolean() - def unaddressed_message?(params), - do: - [params["to"], params["cc"], params["bto"], params["bcc"]] - |> Enum.all?(fn - nil -> true - [] -> true - _ -> false - end) - - @spec recipient_in_message(User.t(), User.t(), map()) :: boolean() - def recipient_in_message(%User{ap_id: ap_id} = recipient, %User{} = actor, params), - do: - label_in_message?(ap_id, params) || unaddressed_message?(params) || - User.following?(recipient, actor) - defp extract_list(target) when is_binary(target), do: [target] defp extract_list(lst) when is_list(lst), do: lst defp extract_list(_), do: [] diff --git a/lib/pleroma/web/api_spec/cast_and_validate.ex b/lib/pleroma/web/api_spec/cast_and_validate.ex index 95bd4d9cf..41e69727e 100644 --- a/lib/pleroma/web/api_spec/cast_and_validate.ex +++ b/lib/pleroma/web/api_spec/cast_and_validate.ex @@ -20,6 +20,9 @@ defmodule Pleroma.Web.ApiSpec.CastAndValidate do require Logger + # How often to drop offending query params and retry in permissive mode + @max_retries 20 + @impl Plug def init(opts) do opts @@ -114,34 +117,49 @@ defmodule Pleroma.Web.ApiSpec.CastAndValidate do false = _strict, cast_opts ) do - case OpenApiSpex.cast_and_validate(spec, operation, conn, content_type) do - {:ok, conn} -> - {:ok, conn} + permissive_cast_and_validate(spec, operation, conn, content_type, cast_opts, @max_retries) + end - # Remove unexpected query params and cast/validate again - {:error, errors} -> - query_params = - Enum.reduce(errors, conn.query_params, fn - %{reason: :unexpected_field, name: name, path: [name]}, params -> - Map.delete(params, name) + # OpenApiSpex reports one offending param at a time, so drop it and cast/validate again + defp permissive_cast_and_validate( + spec, + operation, + %Conn{} = conn, + content_type, + cast_opts, + retries + ) do + with {:error, errors} = error <- + OpenApiSpex.cast_and_validate(spec, operation, conn, content_type, cast_opts) do + query_params = drop_invalid_query_params(errors, conn.query_params) - # Filter out empty params - %{reason: :invalid_type, path: [name_atom], value: ""}, params -> - Map.delete(params, to_string(name_atom)) - - %{reason: :invalid_enum, name: nil, path: path, value: value}, params -> - path = path |> Enum.reverse() |> tl() |> Enum.reverse() |> list_items_to_string() - update_in(params, path, &List.delete(&1, value)) - - _, params -> - params - end) - - conn = %Conn{conn | query_params: query_params} - OpenApiSpex.cast_and_validate(spec, operation, conn, content_type, cast_opts) + if retries > 0 and query_params != conn.query_params do + conn = %{conn | query_params: query_params} + permissive_cast_and_validate(spec, operation, conn, content_type, cast_opts, retries - 1) + else + error + end end end + defp drop_invalid_query_params(errors, query_params) do + Enum.reduce(errors, query_params, fn + %{reason: :unexpected_field, name: name, path: [name]}, params -> + Map.delete(params, name) + + # Filter out empty params + %{reason: :invalid_type, path: [name_atom], value: ""}, params -> + Map.delete(params, to_string(name_atom)) + + %{reason: :invalid_enum, name: nil, path: path, value: value}, params -> + path = path |> Enum.reverse() |> tl() |> Enum.reverse() |> list_items_to_string() + update_in(params, path, &List.delete(&1, value)) + + _, params -> + params + end) + end + defp list_items_to_string(list) do Enum.map(list, fn i when is_atom(i) -> to_string(i) diff --git a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex index 6cd7469f9..c5210d588 100644 --- a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex @@ -92,13 +92,13 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do plug( RateLimiter, - [name: :status_id_action, bucket_name: "status_id_action:reblog_unreblog", params: [:id]] + [name: :status_id_action, bucket_name: "status_id_action:reblog_unreblog", params: ["id"]] when action in ~w(reblog unreblog)a ) plug( RateLimiter, - [name: :status_id_action, bucket_name: "status_id_action:fav_unfav", params: [:id]] + [name: :status_id_action, bucket_name: "status_id_action:fav_unfav", params: ["id"]] when action in ~w(favourite unfavourite)a ) diff --git a/lib/pleroma/web/mastodon_api/views/marker_view.ex b/lib/pleroma/web/mastodon_api/views/marker_view.ex index 944769b1a..fd067f713 100644 --- a/lib/pleroma/web/mastodon_api/views/marker_view.ex +++ b/lib/pleroma/web/mastodon_api/views/marker_view.ex @@ -5,13 +5,15 @@ defmodule Pleroma.Web.MastodonAPI.MarkerView do use Pleroma.Web, :view + alias Pleroma.Web.CommonAPI.Utils + def render("markers.json", %{markers: markers}) do Map.new(markers, fn m -> {m.timeline, %{ last_read_id: m.last_read_id, version: m.lock_version, - updated_at: NaiveDateTime.to_iso8601(m.updated_at), + updated_at: Utils.to_masto_date(m.updated_at), pleroma: %{ unread_count: m.unread_count } diff --git a/lib/pleroma/web/media_proxy.ex b/lib/pleroma/web/media_proxy.ex index 82a070767..a318ab10f 100644 --- a/lib/pleroma/web/media_proxy.ex +++ b/lib/pleroma/web/media_proxy.ex @@ -45,7 +45,7 @@ defmodule Pleroma.Web.MediaProxy do def url("/" <> _ = url), do: url def url(url) do - if enabled?() and url_proxiable?(url) do + if enabled?() and not data_url?(url) and url_proxiable?(url) do encode_url(url) else url @@ -61,7 +61,7 @@ defmodule Pleroma.Web.MediaProxy do def preview_url("//" <> _ = url, pparams), do: preview_url("https:" <> url, pparams) def preview_url(url, preview_params) do - if preview_enabled?() do + if preview_enabled?() and not data_url?(url) do encode_preview_url(url, preview_params) else url(url) @@ -74,6 +74,8 @@ defmodule Pleroma.Web.MediaProxy do # non-local non-whitelisted URLs through it and be sure that body size constraint is preserved. def preview_enabled?, do: enabled?() and !!Config.get([:media_preview_proxy, :enabled]) + defp data_url?(url), do: url |> String.slice(0, 5) |> String.downcase() == "data:" + def local?(url), do: String.starts_with?(url, Endpoint.url()) def whitelisted?(url) do diff --git a/lib/pleroma/web/media_proxy/media_proxy_controller.ex b/lib/pleroma/web/media_proxy/media_proxy_controller.ex index a0aafc32e..04e8ffd52 100644 --- a/lib/pleroma/web/media_proxy/media_proxy_controller.ex +++ b/lib/pleroma/web/media_proxy/media_proxy_controller.ex @@ -7,7 +7,6 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do alias Pleroma.Config alias Pleroma.Helpers.MediaHelper - alias Pleroma.Helpers.UriHelper alias Pleroma.ReverseProxy alias Pleroma.Web.MediaProxy alias Plug.Conn @@ -67,9 +66,6 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do static and content_type == "image/gif" -> handle_jpeg_preview(conn, media_proxy_url) - static -> - drop_static_param_and_redirect(conn) - content_type == "image/gif" -> conn |> put_status(301) @@ -166,15 +162,6 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do end end - defp drop_static_param_and_redirect(conn) do - uri_without_static_param = - conn - |> current_url() - |> UriHelper.modify_uri_params(%{}, ["static"]) - - redirect(conn, external: uri_without_static_param) - end - defp fallback_on_preview_error(conn, media_proxy_url) do redirect(conn, external: media_proxy_url) end 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 82abfce7f..57e3a2d5b 100644 --- a/lib/pleroma/web/pleroma_api/controllers/emoji_reaction_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/emoji_reaction_controller.ex @@ -12,6 +12,7 @@ defmodule Pleroma.Web.PleromaAPI.EmojiReactionController do alias Pleroma.Web.CommonAPI alias Pleroma.Web.MastodonAPI.StatusView alias Pleroma.Web.Plugs.OAuthScopesPlug + alias Pleroma.Web.Plugs.RateLimiter plug(Pleroma.Web.ApiSpec.CastAndValidate) plug(OAuthScopesPlug, %{scopes: ["write:statuses"]} when action in [:create, :delete]) @@ -22,6 +23,14 @@ defmodule Pleroma.Web.PleromaAPI.EmojiReactionController do when action == :index ) + plug( + RateLimiter, + [name: :status_id_action, bucket_name: "status_id_action:emoji_react", params: [:id]] + when action in [:create, :delete] + ) + + plug(RateLimiter, [name: :statuses_actions] when action in [:create, :delete]) + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.EmojiReactionOperation action_fallback(Pleroma.Web.MastodonAPI.FallbackController) diff --git a/lib/pleroma/web/plugs/http_signature_plug.ex b/lib/pleroma/web/plugs/http_signature_plug.ex index 1f7cb51f3..be1c9c2ee 100644 --- a/lib/pleroma/web/plugs/http_signature_plug.ex +++ b/lib/pleroma/web/plugs/http_signature_plug.ex @@ -45,20 +45,52 @@ defmodule Pleroma.Web.Plugs.HTTPSignaturePlug do defp maybe_assign_valid_signature(conn) do if has_signature_header?(conn) do - # we replace the digest header with the one we computed in DigestPlug - conn = - case conn do - %{assigns: %{digest: digest}} = conn -> put_req_header(conn, "digest", digest) - conn -> conn - end + conn = put_computed_digest(conn) - assign(conn, :valid_signature, Signature.validate_signature(conn)) + case check_signature_policy(conn) do + :ok -> + assign(conn, :valid_signature, Signature.validate_signature(conn)) + + {:error, reason} -> + reject_signature(conn, reason) + end else Logger.debug("No signature header!") conn end end + # we replace the digest header with the one we computed in DigestPlug + defp put_computed_digest(%{assigns: %{digest: digest}} = conn), + do: put_req_header(conn, "digest", digest) + + defp put_computed_digest(conn), do: conn + + defp check_signature_policy(conn) do + with {:error, reason} <- Signature.check_signature_policy(conn) do + if Pleroma.Config.get([:activitypub, :signature_policy], :enforce) == :log do + Logger.warning( + "HTTP signature policy violation (#{reason}) for #{conn.method} #{conn.request_path}, allowed by config" + ) + + :ok + else + {:error, reason} + end + end + end + + # The request is treated as badly signed. The reason is kept so the inbox + # can refuse it instead of queueing a failed-signature retry, which only + # checks the cryptographic signature. + defp reject_signature(conn, reason) do + Logger.debug("Rejecting HTTP signature: #{reason}") + + conn + |> assign(:valid_signature, false) + |> assign(:signature_error, reason) + end + defp maybe_assign_actor_id(%{assigns: %{valid_signature: true}} = conn) do adapter = Application.get_env(:http_signatures, :adapter) diff --git a/lib/pleroma/web/web_finger.ex b/lib/pleroma/web/web_finger.ex index 62a2715f5..cb312405c 100644 --- a/lib/pleroma/web/web_finger.ex +++ b/lib/pleroma/web/web_finger.ex @@ -158,7 +158,11 @@ defmodule Pleroma.Web.WebFinger do @cachex Pleroma.Config.get([:cachex, :provider], Cachex) def find_lrdd_template(domain) do @cachex.fetch!(:host_meta_cache, domain, fn _ -> - {:commit, fetch_lrdd_template(domain)} + case fetch_lrdd_template(domain) do + # retry once the backoff expires instead of caching the miss + {:error, :ratelimit} = error -> {:ignore, error} + result -> {:commit, result} + end end) rescue e -> {:error, "Cachex error: #{inspect(e)}"} @@ -168,9 +172,13 @@ defmodule Pleroma.Web.WebFinger do # WebFinger is restricted to HTTPS - https://tools.ietf.org/html/rfc7033#section-9.1 meta_url = "https://#{domain}/.well-known/host-meta" - with {:ok, %{status: status, body: body}} when status in 200..299 <- HTTP.get(meta_url) do + with {:ok, %{status: status, body: body}} when status in 200..299 <- + HTTP.Backoff.get(meta_url) do get_template_from_xml(body) else + {:error, :ratelimit} = error -> + error + error -> Logger.warning("Can't find LRDD template in #{inspect(meta_url)}: #{inspect(error)}") {:error, :lrdd_not_found} @@ -210,7 +218,7 @@ defmodule Pleroma.Web.WebFinger do with address when is_binary(address) <- get_address_from_domain(domain, account), {:ok, %{status: status, body: body, headers: headers}} when status in 200..299 <- - HTTP.get( + HTTP.Backoff.get( address, [{"accept", "application/xrd+xml,application/jrd+json"}] ) do diff --git a/lib/pleroma/workers/receiver_worker.ex b/lib/pleroma/workers/receiver_worker.ex index 3afbe138d..4d1b5ba92 100644 --- a/lib/pleroma/workers/receiver_worker.ex +++ b/lib/pleroma/workers/receiver_worker.ex @@ -3,6 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Workers.ReceiverWorker do + alias Pleroma.HTTP.Backoff alias Pleroma.Instances alias Pleroma.Web.Federator alias Pleroma.Workers.SignatureRetryWorker @@ -14,7 +15,9 @@ defmodule Pleroma.Workers.ReceiverWorker do if signature_retry_job?(args) do perform_signature_retry(job) else - perform_incoming(params) + params + |> perform_incoming() + |> Backoff.snooze_ratelimited(job) end end diff --git a/lib/pleroma/workers/signature_retry_worker.ex b/lib/pleroma/workers/signature_retry_worker.ex index 28958faff..159d802ad 100644 --- a/lib/pleroma/workers/signature_retry_worker.ex +++ b/lib/pleroma/workers/signature_retry_worker.ex @@ -3,6 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Workers.SignatureRetryWorker do + alias Pleroma.HTTP.Backoff alias Pleroma.Instances alias Pleroma.Signature alias Pleroma.User @@ -16,16 +17,18 @@ defmodule Pleroma.Workers.SignatureRetryWorker do use Oban.Worker, queue: :federator_incoming, max_attempts: 5, unique: [period: :infinity] @impl true - def perform(%Job{ - args: %{ - "op" => "incoming_failed_signature_ap_doc", - "method" => method, - "params" => params, - "req_headers" => req_headers, - "request_path" => request_path, - "query_string" => query_string - } - }) + def perform( + %Job{ + args: %{ + "op" => "incoming_failed_signature_ap_doc", + "method" => method, + "params" => params, + "req_headers" => req_headers, + "request_path" => request_path, + "query_string" => query_string + } + } = job + ) when is_binary(method) and is_map(params) and is_list(req_headers) and is_binary(request_path) and is_binary(query_string) do case normalize_req_headers(req_headers) do @@ -58,7 +61,10 @@ defmodule Pleroma.Workers.SignatureRetryWorker do {:ok, res} else - e -> process_errors(e, retry_log_context(params, request_path, signature_actor_result)) + e -> + e + |> process_errors(retry_log_context(params, request_path, signature_actor_result)) + |> Backoff.snooze_ratelimited(job) end e -> diff --git a/mix.exs b/mix.exs index e2af5fda6..6c2cfe9c9 100644 --- a/mix.exs +++ b/mix.exs @@ -205,6 +205,7 @@ defmodule Pleroma.Mixfile do {:majic, "~> 1.2"}, {:open_api_spex, "~> 3.22"}, {:ecto_psql_extras, "~> 0.8"}, + {:exif_stripper, "~> 0.1.3"}, {:vix, "~> 0.36"}, {:elixir_make, "~> 0.7.8", override: true}, {:blurhash, "~> 0.1.0", hex: :rinpatch_blurhash}, diff --git a/mix.lock b/mix.lock index f8ce53f4c..e6c9969cf 100644 --- a/mix.lock +++ b/mix.lock @@ -1,7 +1,7 @@ %{ "accept": {:hex, :accept, "0.3.5", "b33b127abca7cc948bbe6caa4c263369abf1347cfa9d8e699c6d214660f10cd1", [:rebar3], [], "hexpm", "11b18c220bcc2eab63b5470c038ef10eb6783bcb1fcdb11aa4137defa5ac1bb8"}, "argon2_elixir": {:hex, :argon2_elixir, "4.1.3", "4f28318286f89453364d7fbb53e03d4563fd7ed2438a60237eba5e426e97785f", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "7c295b8d8e0eaf6f43641698f962526cdf87c6feb7d14bd21e599271b510608c"}, - "bandit": {:hex, :bandit, "1.14.0", "555af34f126edb98d8b6b95bbeba4d5f4c3af21e37535d791f32db2365c532fb", [:mix], [{:hpax, "~> 1.1", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.6", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "2d07630e4bf77b55240881386f7629bcc2f87426a299860b2d31c4ad77a48746"}, + "bandit": {:hex, :bandit, "1.14.1", "5fb450e2360faf03d9b9fb45cfe52e14197f62d376c34781219bec0f9bc7ca06", [:mix], [{:hpax, "~> 1.1", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.6", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "87c7ee5e08fb5f22d4e072e681e1101266a21432889b08ef8d0cb0c1450f8bba"}, "base62": {:hex, :base62, "1.2.2", "85c6627eb609317b70f555294045895ffaaeb1758666ab9ef9ca38865b11e629", [:mix], [{:custom_base, "~> 0.2.1", [hex: :custom_base, repo: "hexpm", optional: false]}], "hexpm", "d41336bda8eaa5be197f1e4592400513ee60518e5b9f4dcf38f4b4dae6f377bb"}, "bbcode_pleroma": {:hex, :bbcode_pleroma, "0.2.0", "d36f5bca6e2f62261c45be30fa9b92725c0655ad45c99025cb1c3e28e25803ef", [:mix], [{:nimble_parsec, "~> 0.5", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "19851074419a5fedb4ef49e1f01b30df504bb5dbb6d6adfc135238063bebd1c3"}, "bcrypt_elixir": {:hex, :bcrypt_elixir, "2.3.1", "5114d780459a04f2b4aeef52307de23de961b69e13a5cd98a911e39fda13f420", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "42182d5f46764def15bf9af83739e3bf4ad22661b1c34fc3e88558efced07279"}, @@ -47,6 +47,7 @@ "ex_doc": {:hex, :ex_doc, "0.38.2", "504d25eef296b4dec3b8e33e810bc8b5344d565998cd83914ffe1b8503737c02", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "732f2d972e42c116a70802f9898c51b54916e542cc50968ac6980512ec90f42b"}, "ex_machina": {:hex, :ex_machina, "2.8.0", "a0e847b5712065055ec3255840e2c78ef9366634d62390839d4880483be38abe", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm", "79fe1a9c64c0c1c1fab6c4fa5d871682cb90de5885320c187d117004627a7729"}, "ex_syslogger": {:hex, :ex_syslogger, "1.5.2", "72b6aa2d47a236e999171f2e1ec18698740f40af0bd02c8c650bf5f1fd1bac79", [:mix], [{:poison, ">= 1.5.0", [hex: :poison, repo: "hexpm", optional: true]}, {:syslog, "~> 1.1.0", [hex: :syslog, repo: "hexpm", optional: false]}], "hexpm", "ab9fab4136dbc62651ec6f16fa4842f10cf02ab4433fa3d0976c01be99398399"}, + "exif_stripper": {:hex, :exif_stripper, "0.1.3", "b3d89a623bb49c09fbe45fe5d4f1becee0ffe011bcade174c3bb91cdae59745e", [:mix], [], "hexpm", "e1ab26d766205ae54be57c19ec70d408b020f3c31855b49d53a55460de81c6e1"}, "exile": {:hex, :exile, "0.10.0", "b69e2d27a9af670b0f0a0898addca0eda78f6f5ba95ccfbc9bc6ccdd04925436", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "c62ee8fee565b5ac4a898d0dcd58d2b04fb5eec1655af1ddcc9eb582c6732c33"}, "expo": {:hex, :expo, "0.5.1", "249e826a897cac48f591deba863b26c16682b43711dd15ee86b92f25eafd96d9", [:mix], [], "hexpm", "68a4233b0658a3d12ee00d27d37d856b1ba48607e7ce20fd376958d0ba6ce92b"}, "fast_html": {:hex, :fast_html, "2.5.0", "23578c1c1fa03db6a3786d78218b2d944df34b0fc3729e72de912f390913c80f", [:make, :mix], [{:elixir_make, "~> 0.4", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}], "hexpm", "69eb46ed98a5d9cca1ccd4a5ac94ce5dd626fc29513fbaa0a16cd8b2da67ae3e"}, @@ -68,7 +69,7 @@ "inet_cidr": {:hex, :inet_cidr, "1.0.9", "e0ef72a2942529da78c8e4147d53f2ef5f6f5293335c3637b0fdf83c012cc816", [:mix], [], "hexpm", "172da15ff7cf635b1feaf14f5818be28c811b37cc5fb7c5f7c01058c1c1066cc"}, "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, "joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"}, - "jose": {:hex, :jose, "1.11.10", "a903f5227417bd2a08c8a00a0cbcc458118be84480955e8d251297a425723f83", [:mix, :rebar3], [], "hexpm", "0d6cd36ff8ba174db29148fc112b5842186b68a90ce9fc2b3ec3afe76593e614"}, + "jose": {:hex, :jose, "1.11.13", "94abcd8bf98eb9f007272918bd3f5ffb613e8d250567de233e6c2b2456319005", [:mix, :rebar3], [], "hexpm", "15d8bd0f01ab29d1771e8da1f60f9c98725b23a17f7312cdd40e8e2757bd98c7"}, "jumper": {:hex, :jumper, "1.0.2", "68cdcd84472a00ac596b4e6459a41b3062d4427cbd4f1e8c8793c5b54f1406a7", [:mix], [], "hexpm", "9b7782409021e01ab3c08270e26f36eb62976a38c1aa64b2eaf6348422f165e1"}, "linkify": {:hex, :linkify, "0.5.3", "5f8143d8f61f5ff08d3aeeff47ef6509492b4948d8f08007fbf66e4d2246a7f2", [:mix], [], "hexpm", "3ef35a1377d47c25506e07c1c005ea9d38d700699d92ee92825f024434258177"}, "logger_backends": {:hex, :logger_backends, "1.0.0", "09c4fad6202e08cb0fbd37f328282f16539aca380f512523ce9472b28edc6bdf", [:mix], [], "hexpm", "1faceb3e7ec3ef66a8f5746c5afd020e63996df6fd4eb8cdb789e5665ae6c9ce"}, @@ -105,7 +106,7 @@ "pbkdf2_elixir": {:hex, :pbkdf2_elixir, "1.2.1", "9cbe354b58121075bd20eb83076900a3832324b7dd171a6895fab57b6bb2752c", [:mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}], "hexpm", "d3b40a4a4630f0b442f19eca891fcfeeee4c40871936fed2f68e1c4faa30481f"}, "phoenix": {:hex, :phoenix, "1.8.11", "9bb8f0c4e9f0b9eaf7f34cf8cb0bd25bd8599763a5587d64918a9ebb04027823", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 2.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "44f028f4129e5a29487e868f84903373e3d032da151ad0c789c3849f464e7351"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.5", "c4ef322acd15a574a8b1a08eff0ee0a85e73096b53ce1403b6563709f15e1cea", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "26ec3208eef407f31b748cadd044045c6fd485fbff168e35963d2f9dfff28d4b"}, - "phoenix_html": {:hex, :phoenix_html, "3.3.4", "42a09fc443bbc1da37e372a5c8e6755d046f22b9b11343bf885067357da21cb3", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0249d3abec3714aff3415e7ee3d9786cb325be3151e6c4b3021502c585bf53fb"}, + "phoenix_html": {:hex, :phoenix_html, "3.3.5", "a2ce9bfc938bd7e23f10de438d19550074d7fdba3ea8d54095142f52da288fec", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "9966451c70e3080124b5a6b9b30f8a7927983528d890fd457f9eee4074dfa167"}, "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"}, "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.3.3", "3a53772a6118d5679bf50fc1670505a290e32a1d195df9e069d8c53ab040c054", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "766796676e5f558dbae5d1bdb066849673e956005e3730dfd5affd7a6da4abac"}, "phoenix_live_view": {:hex, :phoenix_live_view, "1.2.9", "d35ddac2fab4480e6bdbf712ff104fd9e969a2bbe81b578ee80f066b371f56db", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2f9528c3d7046edabbb30a91710ca33988f8d8bc20a964a1fc48b32134572afa"}, @@ -135,7 +136,7 @@ "recon": {:hex, :recon, "2.5.6", "9052588e83bfedfd9b72e1034532aee2a5369d9d9343b61aeb7fbce761010741", [:mix, :rebar3], [], "hexpm", "96c6799792d735cc0f0fd0f86267e9d351e63339cbe03df9d162010cefc26bb0"}, "remote_ip": {:hex, :remote_ip, "1.2.0", "fb078e12a44414f4cef5a75963c33008fe169b806572ccd17257c208a7bc760f", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "2ff91de19c48149ce19ed230a81d377186e4412552a597d6a5137373e5877cb7"}, "sleeplocks": {:hex, :sleeplocks, "1.1.3", "96a86460cc33b435c7310dbd27ec82ca2c1f24ae38e34f8edde97f756503441a", [:rebar3], [], "hexpm", "d3b3958552e6eb16f463921e70ae7c767519ef8f5be46d7696cc1ed649421321"}, - "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.9", "4ff292dc16e728fde1235cf5fa6bcd3ca01864004526f71a4a70935509d1a7f1", [:make, :mix, :rebar3], [], "hexpm", "19f24ad715ef6255d530b2f0db8b2d2191ae435b6527234e943f523e84ed0895"}, "statistex": {:hex, :statistex, "1.1.0", "7fec1eb2f580a0d2c1a05ed27396a084ab064a40cfc84246dbfb0c72a5c761e5", [:mix], [], "hexpm", "f5950ea26ad43246ba2cce54324ac394a4e7408fdcf98b8e230f503a0cba9cf5"}, "sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"}, "swoosh": {:hex, :swoosh, "1.28.0", "30d9f3519a150128e90d97f9878a8a5cd8325ed03d618d1c626201496fc20baf", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, ">= 1.9.0 and < 5.0.0", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, ">= 6.0.0 and < 8.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "bb5c0b7c988beb53786254a61580597fe1017a652039061ee2b4c28b5097b10e"}, diff --git a/priv/repo/migrations/20261027000000_upload_filter_exiftool_to_exif.exs b/priv/repo/migrations/20261027000000_upload_filter_exiftool_to_exif.exs new file mode 100644 index 000000000..4e737e859 --- /dev/null +++ b/priv/repo/migrations/20261027000000_upload_filter_exiftool_to_exif.exs @@ -0,0 +1,41 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2026 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Repo.Migrations.UploadFilterExiftoolToExif do + use Ecto.Migration + + alias Pleroma.ConfigDB + + @up %{ + Pleroma.Upload.Filter.Exiftool => Pleroma.Upload.Filter.Exif.StripLocation, + Pleroma.Upload.Filter.Exiftool.StripLocation => Pleroma.Upload.Filter.Exif.StripLocation, + Pleroma.Upload.Filter.Exiftool.ReadDescription => Pleroma.Upload.Filter.Exif.ReadDescription + } + + @down %{ + Pleroma.Upload.Filter.Exif.StripLocation => Pleroma.Upload.Filter.Exiftool.StripLocation, + Pleroma.Upload.Filter.Exif.ReadDescription => Pleroma.Upload.Filter.Exiftool.ReadDescription + } + + def up, do: rename_filters(@up) + + def down, do: rename_filters(@down) + + # Only touch configs that list filters: adding an empty list would hide the + # filters from the config file. + defp rename_filters(renames) do + with %{value: value} <- ConfigDB.get_by_params(%{group: :pleroma, key: Pleroma.Upload}), + filters when is_list(filters) <- value[:filters], + new_filters when new_filters != filters <- + filters |> Enum.map(&Map.get(renames, &1, &1)) |> Enum.uniq() do + ConfigDB.update_or_create(%{ + group: :pleroma, + key: Pleroma.Upload, + value: Keyword.put(value, :filters, new_filters) + }) + else + _ -> nil + end + end +end diff --git a/priv/repo/migrations/20261028000000_backfill_instances_from_users.exs b/priv/repo/migrations/20261028000000_backfill_instances_from_users.exs new file mode 100644 index 000000000..88dfe9ec5 --- /dev/null +++ b/priv/repo/migrations/20261028000000_backfill_instances_from_users.exs @@ -0,0 +1,30 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2026 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Repo.Migrations.BackfillInstancesFromUsers do + use Ecto.Migration + + # Remote users now create an instances row; add the hosts of existing ones, + # so the peer list can come from the instances table. + def up do + Pleroma.Repo.query!( + """ + INSERT INTO instances (host, inserted_at, updated_at) + SELECT DISTINCT host, now(), now() + FROM ( + -- Skips userinfo; IPv6 literals give NULL and are left out. + SELECT substring(ap_id from '^[A-Za-z][A-Za-z0-9+.-]*://(?:[^/?#@]*@)?([^/:?#\\[]+)') AS host + FROM users + WHERE local = false AND ap_id IS NOT NULL + ) AS hosts + WHERE host IS NOT NULL + ON CONFLICT (host) DO NOTHING + """, + [], + timeout: :infinity + ) + end + + def down, do: :ok +end diff --git a/test/fixtures/dji_gps.jpg b/test/fixtures/dji_gps.jpg new file mode 100644 index 000000000..7249228cf Binary files /dev/null and b/test/fixtures/dji_gps.jpg differ diff --git a/test/fixtures/strip_location_gps.heic b/test/fixtures/strip_location_gps.heic new file mode 100644 index 000000000..f27961d83 Binary files /dev/null and b/test/fixtures/strip_location_gps.heic differ diff --git a/test/fixtures/strip_location_gps.tiff b/test/fixtures/strip_location_gps.tiff new file mode 100644 index 000000000..afbc4ac58 Binary files /dev/null and b/test/fixtures/strip_location_gps.tiff differ diff --git a/test/fixtures/strip_location_gps.webp b/test/fixtures/strip_location_gps.webp new file mode 100644 index 000000000..9c7724529 Binary files /dev/null and b/test/fixtures/strip_location_gps.webp differ diff --git a/test/fixtures/strip_location_gps_grid.heic b/test/fixtures/strip_location_gps_grid.heic new file mode 100644 index 000000000..3a9d5bf97 Binary files /dev/null and b/test/fixtures/strip_location_gps_grid.heic differ diff --git a/test/fixtures/xmp_gps.jpg b/test/fixtures/xmp_gps.jpg new file mode 100644 index 000000000..dd76e84f7 Binary files /dev/null and b/test/fixtures/xmp_gps.jpg differ diff --git a/test/mix/tasks/pleroma/instance_test.exs b/test/mix/tasks/pleroma/instance_test.exs index 5ecb6e445..0a8c3d107 100644 --- a/test/mix/tasks/pleroma/instance_test.exs +++ b/test/mix/tasks/pleroma/instance_test.exs @@ -95,7 +95,7 @@ defmodule Mix.Tasks.Pleroma.InstanceTest do assert generated_config =~ "http: [ip: {127, 0, 0, 1}, port: 4000]" assert generated_config =~ - "filters: [Pleroma.Upload.Filter.Exiftool.StripLocation, Pleroma.Upload.Filter.Exiftool.ReadDescription]" + "filters: [Pleroma.Upload.Filter.Exif.StripLocation, Pleroma.Upload.Filter.Exif.ReadDescription]" assert File.read!(tmp_path() <> "setup.psql") == generated_setup_psql() assert File.exists?(Path.expand("./test/instance/static/robots.txt")) diff --git a/test/pleroma/config/deprecation_warnings_test.exs b/test/pleroma/config/deprecation_warnings_test.exs index 62f5bdeea..65b6a3d98 100644 --- a/test/pleroma/config/deprecation_warnings_test.exs +++ b/test/pleroma/config/deprecation_warnings_test.exs @@ -13,40 +13,39 @@ defmodule Pleroma.Config.DeprecationWarningsTest do describe "filter exiftool" do test "gives warning when still used" do - clear_config( - [Pleroma.Upload, :filters], - [Pleroma.Upload.Filter.Exiftool] - ) - - assert capture_log(fn -> DeprecationWarnings.check_exiftool_filter() end) =~ - """ - !!!DEPRECATION WARNING!!! - Your config is using Exiftool as a filter instead of Exiftool.StripLocation. This should work for now, but you are advised to change to the new configuration to prevent possible issues later: - - ``` - config :pleroma, Pleroma.Upload, - filters: [Pleroma.Upload.Filter.Exiftool] - ``` - - Is now - - - ``` - config :pleroma, Pleroma.Upload, - filters: [Pleroma.Upload.Filter.Exiftool.StripLocation] - ``` - """ - end - - test "changes setting to exiftool strip location" do clear_config( [Pleroma.Upload, :filters], [Pleroma.Upload.Filter.Exiftool, Pleroma.Upload.Filter.Exiftool.ReadDescription] ) + log = capture_log(fn -> DeprecationWarnings.check_exiftool_filter() end) + + assert log =~ "!!!DEPRECATION WARNING!!!" + + assert log =~ + "* `Pleroma.Upload.Filter.Exiftool` is now `Pleroma.Upload.Filter.Exif.StripLocation`" + + assert log =~ + "* `Pleroma.Upload.Filter.Exiftool.ReadDescription` is now `Pleroma.Upload.Filter.Exif.ReadDescription`" + + refute log =~ "Exiftool.StripLocation`" + end + + test "changes setting to the Exif filters" do + clear_config( + [Pleroma.Upload, :filters], + [ + Pleroma.Upload.Filter.Dedupe, + Pleroma.Upload.Filter.Exiftool, + Pleroma.Upload.Filter.Exiftool.StripLocation, + Pleroma.Upload.Filter.Exiftool.ReadDescription + ] + ) + expected_config = [ - Pleroma.Upload.Filter.Exiftool.StripLocation, - Pleroma.Upload.Filter.Exiftool.ReadDescription + Pleroma.Upload.Filter.Dedupe, + Pleroma.Upload.Filter.Exif.StripLocation, + Pleroma.Upload.Filter.Exif.ReadDescription ] capture_log(fn -> DeprecationWarnings.warn() end) @@ -58,8 +57,8 @@ defmodule Pleroma.Config.DeprecationWarningsTest do clear_config( [Pleroma.Upload, :filters], [ - Pleroma.Upload.Filter.Exiftool.StripLocation, - Pleroma.Upload.Filter.Exiftool.ReadDescription + Pleroma.Upload.Filter.Exif.StripLocation, + Pleroma.Upload.Filter.Exif.ReadDescription ] ) diff --git a/test/pleroma/config/transfer_task_test.exs b/test/pleroma/config/transfer_task_test.exs index ea5378ffd..2f39712e3 100644 --- a/test/pleroma/config/transfer_task_test.exs +++ b/test/pleroma/config/transfer_task_test.exs @@ -96,6 +96,29 @@ defmodule Pleroma.Config.TransferTaskTest do assert Application.get_env(:pleroma, Pleroma.Search.ParadeDB.Repo)[:pool_size] == 2 end + test "skips settings with an unknown module key" do + now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) + + Repo.insert_all("config", [ + %{ + group: ":pleroma", + key: "Pleroma.Nowhere.Module", + value: :erlang.term_to_binary(enabled: true), + inserted_at: now, + updated_at: now + } + ]) + + insert(:config, key: :test_key, value: [live: 2]) + on_exit(fn -> Application.delete_env(:pleroma, :test_key) end) + + log = capture_log(fn -> assert :ok = TransferTask.load_and_update_env([], false) end) + + assert log =~ "Pleroma.Nowhere.Module" + assert Application.get_env(:pleroma, :test_key) == [live: 2] + refute Application.get_env(:pleroma, :invalid_atom) + end + describe "pleroma restart" do setup do on_exit(fn -> diff --git a/test/pleroma/config_db_test.exs b/test/pleroma/config_db_test.exs index ae17a9d34..43aca06c2 100644 --- a/test/pleroma/config_db_test.exs +++ b/test/pleroma/config_db_test.exs @@ -50,6 +50,22 @@ defmodule Pleroma.ConfigDBTest do assert config[:pleroma][:instance] == [name: "Pleroma"] end + test "loads a row with an unknown module key as :invalid_atom" do + now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) + + Repo.insert_all("config", [ + %{ + group: ":pleroma", + key: "Pleroma.Nowhere.Module", + value: :erlang.term_to_binary(enabled: true), + inserted_at: now, + updated_at: now + } + ]) + + assert [%ConfigDB{group: :pleroma, key: :invalid_atom}] = Repo.all(ConfigDB) + end + describe "update_or_create/1" do test "common" do config = insert(:config) diff --git a/test/pleroma/gun/conn_test.exs b/test/pleroma/gun/conn_test.exs index a38740a42..db3ce6275 100644 --- a/test/pleroma/gun/conn_test.exs +++ b/test/pleroma/gun/conn_test.exs @@ -42,6 +42,7 @@ defmodule Pleroma.Gun.ConnTest do expect(Pleroma.GunMock, :open, fn ~c"proxy.example", 8080, opts -> assert opts.transport == :tcp + assert opts.supervise refute Map.has_key?(opts, :tls_opts) {:ok, conn} end) @@ -172,6 +173,67 @@ defmodule Pleroma.Gun.ConnTest do ) end + describe "IPv6-only hosts" do + @nxdomain {:error, {:down, {:shutdown, :nxdomain}}} + + test "retries once over IPv6 when the IPv4 lookup finds no address" do + ipv4_conn = spawn_link(fn -> Process.sleep(:infinity) end) + ipv6_conn = spawn_link(fn -> Process.sleep(:infinity) end) + + expect(Pleroma.GunMock, :open, 2, fn + ~c"v6only.example", 443, %{tcp_opts: tcp_opts} = opts -> + assert :inet6 in tcp_opts + assert opts.transport == :tls + assert opts.tls_opts[:verify] == :verify_peer + {:ok, ipv6_conn} + + ~c"v6only.example", 443, opts -> + refute Map.has_key?(opts, :tcp_opts) + # A linked Gun process would take the caller down with the lookup failure. + assert opts.supervise + {:ok, ipv4_conn} + end) + + stub(Pleroma.GunMock, :await_up, fn + ^ipv4_conn, _ -> @nxdomain + ^ipv6_conn, _ -> {:ok, :http2} + end) + + assert {:ok, ^ipv6_conn, :http2, nil} = + Conn.open(URI.parse("https://v6only.example/inbox"), []) + + stop_conn(ipv4_conn) + stop_conn(ipv6_conn) + end + + test "does not retry IP literals or other errors" do + expect(Pleroma.GunMock, :open, 2, fn _, _, _ -> + {:ok, spawn(fn -> Process.sleep(:infinity) end)} + end) + + stub(Pleroma.GunMock, :await_up, fn _, _ -> @nxdomain end) + assert @nxdomain = Conn.open(URI.parse("https://192.0.2.1/inbox"), []) + + stub(Pleroma.GunMock, :await_up, fn _, _ -> + {:error, {:down, {:shutdown, :econnrefused}}} + end) + + assert {:error, _} = Conn.open(URI.parse("https://v6only.example/inbox"), []) + end + + test "inet6_fallback/3 keeps configured TCP options and skips hosts already on IPv6" do + assert {:ok, %{tcp_opts: [:inet6, nodelay: true]}} = + Conn.inet6_fallback(@nxdomain, ~c"v6only.example", %{tcp_opts: [nodelay: true]}) + + assert {:ok, %{tcp_opts: [:inet6, send_timeout: 15_000, send_timeout_close: true]}} = + Conn.inet6_fallback(@nxdomain, ~c"v6only.example", %{}) + + assert :error = Conn.inet6_fallback(@nxdomain, ~c"v6only.example", %{tcp_opts: [:inet6]}) + assert :error = Conn.inet6_fallback(@nxdomain, {192, 0, 2, 1}, %{}) + assert :error = Conn.inet6_fallback({:error, :timeout}, ~c"v6only.example", %{}) + end + end + defp stop_conn(conn) do Process.unlink(conn) Process.exit(conn, :kill) diff --git a/test/pleroma/html_test.exs b/test/pleroma/html_test.exs index 9266f62ed..b8b694db2 100644 --- a/test/pleroma/html_test.exs +++ b/test/pleroma/html_test.exs @@ -337,5 +337,42 @@ defmodule Pleroma.HTMLTest do assert nil == HTML.extract_first_external_url_from_object(object) end + + test "skips the link in a Mastodon quote fallback paragraph" do + object = %Object{ + data: %{ + "quoteUrl" => "https://raru.re/users/ocean/statuses/116235427788617987", + "content" => + ~s(

RE: raru.re/@ocean/116235427788617987

) <> + ~s(

Berapa lapis?

youtube.com/watch?v=S0dJMhWDRDE

) + } + } + + assert HTML.extract_first_external_url_from_object(object) == + "https://www.youtube.com/watch?v=S0dJMhWDRDE" + end + + test "skips the link in a quote fallback span (Misskey, InlineQuotePolicy)" do + object = %Object{ + data: %{ + "content" => + ~s(hello

RE: https://misskey.example/notes/abc
) + } + } + + assert nil == HTML.extract_first_external_url_from_object(object) + end + + test "skips a link to the quoted post without fallback markup" do + object = %Object{ + data: %{ + "quoteUrl" => "https://remote.example/objects/1", + "content" => + ~s(RE: https://remote.example/objects/1 cofe) + } + } + + assert HTML.extract_first_external_url_from_object(object) == "https://cofe.example/" + end end end diff --git a/test/pleroma/http/adapter_helper/hackney_test.exs b/test/pleroma/http/adapter_helper/hackney_test.exs index 4a31ffb07..ecfd2323d 100644 --- a/test/pleroma/http/adapter_helper/hackney_test.exs +++ b/test/pleroma/http/adapter_helper/hackney_test.exs @@ -58,6 +58,13 @@ defmodule Pleroma.HTTP.AdapterHelper.HackneyTest do {:socks5, ~c"::1", 9050} end + # AdminFE stores an empty field as "", and Hackney 4 fails every request with it. + test "ignores an empty proxy_url", %{uri: uri} do + clear_config([:http, :proxy_url], "") + + refute Keyword.has_key?(Hackney.options([], uri), :proxy) + end + test "respect connection opts and no proxy", %{uri: uri} do opts = Hackney.options([a: 2, b: 1], uri) diff --git a/test/pleroma/http/adapter_helper_test.exs b/test/pleroma/http/adapter_helper_test.exs index cc0e24167..e75710d0d 100644 --- a/test/pleroma/http/adapter_helper_test.exs +++ b/test/pleroma/http/adapter_helper_test.exs @@ -12,6 +12,12 @@ defmodule Pleroma.HTTP.AdapterHelperTest do assert AdapterHelper.format_proxy(nil) == nil end + test "with an empty string" do + assert ExUnit.CaptureLog.capture_log(fn -> + assert AdapterHelper.format_proxy("") == nil + end) == "" + end + test "with string" do assert AdapterHelper.format_proxy("127.0.0.1:8123") == {{127, 0, 0, 1}, 8123} end diff --git a/test/pleroma/http/backoff_test.exs b/test/pleroma/http/backoff_test.exs new file mode 100644 index 000000000..b650391dd --- /dev/null +++ b/test/pleroma/http/backoff_test.exs @@ -0,0 +1,195 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +# Based on Akkoma's test/pleroma/http/backoff_test.exs +defmodule Pleroma.HTTP.BackoffTest do + use Pleroma.DataCase, async: false + + alias Pleroma.HTTP.Backoff + + @cache :http_backoff_cache + @url "https://ratelimited.example.com/users/alice" + @host "ratelimited.example.com" + + defp mock_response(status, headers \\ []) do + test_pid = self() + + Tesla.Mock.mock(fn %{method: :get} -> + send(test_pid, :request) + %Tesla.Env{status: status, headers: headers, body: ""} + end) + end + + defp ttl_seconds do + {:ok, ttl} = Cachex.ttl(@cache, @host) + div(ttl, 1000) + end + + defp assert_ttl_about(expected) do + ttl = ttl_seconds() + assert ttl in (expected - 15)..expected//1, "expected a TTL near #{expected}, got #{ttl}" + end + + describe "get/3" do + test "returns the response when the host is not backed off" do + mock_response(200) + + assert {:ok, %Tesla.Env{status: 200}} = Backoff.get(@url) + assert {:ok, nil} = Cachex.get(@cache, @host) + end + + test "does not send a request while the host is backed off" do + Cachex.put(@cache, @host, true) + mock_response(200) + + assert {:error, :ratelimit} = Backoff.get(@url) + refute_received :request + end + + for status <- [429, 503] do + test "backs off from the host after a #{status} response" do + mock_response(unquote(status)) + + assert {:error, :ratelimit} = Backoff.get(@url) + assert_received :request + assert {:ok, true} = Cachex.get(@cache, @host) + + assert {:error, :ratelimit} = Backoff.get(@url <> "/other") + refute_received :request + end + end + + test "uses a default backoff without headers" do + mock_response(429) + + assert {:error, :ratelimit} = Backoff.get(@url) + assert_ttl_about(300) + end + + test "treats host names case-insensitively" do + mock_response(429) + assert {:error, :ratelimit} = Backoff.get("https://RateLimited.Example.com/users/alice") + assert_received :request + + assert {:error, :ratelimit} = Backoff.get(@url) + refute_received :request + end + + test "only backs off from the host that answered" do + mock_response(429) + assert {:error, :ratelimit} = Backoff.get(@url) + + mock_response(200) + assert {:ok, _} = Backoff.get("https://other.example.com/users/bob") + end + + test "backs off from the host that answered after a redirect" do + test_pid = self() + + Tesla.Mock.mock(fn + %{url: "https://redirector.example.com/users/alice"} = env -> + %{env | status: 302, headers: [{"location", @url}], body: ""} + + %{url: @url} = env -> + send(test_pid, :request) + %{env | status: 429, headers: [], body: ""} + end) + + assert {:error, :ratelimit} = Backoff.get("https://redirector.example.com/users/alice") + assert {:ok, true} = Cachex.get(@cache, @host) + assert {:ok, nil} = Cachex.get(@cache, "redirector.example.com") + end + end + + describe "backoff headers" do + test "parses Retry-After in seconds" do + mock_response(429, [{"retry-after", "600"}]) + + assert {:error, :ratelimit} = Backoff.get(@url) + assert_ttl_about(600) + end + + test "parses Retry-After as an HTTP-date" do + date = + DateTime.utc_now() + |> DateTime.add(600) + |> Timex.format!("{WDshort}, {0D} {Mshort} {YYYY} {h24}:{m}:{s} GMT") + + mock_response(503, [{"retry-after", date}]) + + assert {:error, :ratelimit} = Backoff.get(@url) + assert_ttl_about(600) + end + + test "parses Retry-After as an ISO 8601 timestamp" do + date = DateTime.utc_now() |> DateTime.add(600) |> DateTime.to_iso8601() + mock_response(429, [{"retry-after", date}]) + + assert {:error, :ratelimit} = Backoff.get(@url) + assert_ttl_about(600) + end + + test "parses X-RateLimit-Reset as an ISO 8601 timestamp" do + date = DateTime.utc_now() |> DateTime.add(600) |> DateTime.to_iso8601() + mock_response(429, [{"x-ratelimit-reset", date}]) + + assert {:error, :ratelimit} = Backoff.get(@url) + assert_ttl_about(600) + end + + test "parses X-RateLimit-Reset as a Unix timestamp" do + reset = DateTime.utc_now() |> DateTime.add(600) |> DateTime.to_unix() |> to_string() + mock_response(429, [{"x-ratelimit-reset", reset}]) + + assert {:error, :ratelimit} = Backoff.get(@url) + assert_ttl_about(600) + end + + test "parses X-RateLimit-Reset in seconds" do + mock_response(429, [{"x-ratelimit-reset", "600"}]) + + assert {:error, :ratelimit} = Backoff.get(@url) + assert_ttl_about(600) + end + + test "matches header names case-insensitively" do + mock_response(429, [{"Retry-After", "600"}]) + + assert {:error, :ratelimit} = Backoff.get(@url) + assert_ttl_about(600) + end + + test "prefers Retry-After over X-RateLimit-Reset" do + date = DateTime.utc_now() |> DateTime.add(1200) |> DateTime.to_iso8601() + mock_response(429, [{"x-ratelimit-reset", date}, {"retry-after", "600"}]) + + assert {:error, :ratelimit} = Backoff.get(@url) + assert_ttl_about(600) + end + + test "falls back to the next header when one is invalid" do + date = DateTime.utc_now() |> DateTime.add(600) |> DateTime.to_iso8601() + mock_response(429, [{"retry-after", "soon"}, {"x-ratelimit-reset", date}]) + + assert {:error, :ratelimit} = Backoff.get(@url) + assert_ttl_about(600) + end + + for value <- ["soon", "-5", "0", "12.5", "", "Wed, 21 Oct 2015 07:28:00 GMT"] do + test "ignores the invalid or past Retry-After value #{inspect(value)}" do + mock_response(429, [{"retry-after", unquote(value)}]) + + assert {:error, :ratelimit} = Backoff.get(@url) + assert_ttl_about(300) + end + end + + test "caps absurd values at one hour" do + mock_response(429, [{"retry-after", "99999999999"}]) + + assert {:error, :ratelimit} = Backoff.get(@url) + assert_ttl_about(3600) + end + end +end diff --git a/test/pleroma/object/fetcher_test.exs b/test/pleroma/object/fetcher_test.exs index 61a6fb517..80b172a36 100644 --- a/test/pleroma/object/fetcher_test.exs +++ b/test/pleroma/object/fetcher_test.exs @@ -94,6 +94,27 @@ defmodule Pleroma.Object.FetcherTest do end end + describe "rate limiting" do + test "a backed-off host gets no further requests" do + test_pid = self() + + mock(fn %{method: :get, url: url} -> + send(test_pid, {:request, url}) + %Tesla.Env{status: 429, headers: [{"retry-after", "600"}]} + end) + + first = "https://ratelimited.example.com/objects/1" + second = "https://ratelimited.example.com/objects/2" + + assert {:fetch, {:error, :ratelimit}} = Fetcher.fetch_object_from_id(first) + assert_received {:request, ^first} + + assert {:error, :ratelimit} = Fetcher.fetch_and_contain_remote_object_from_id(second) + assert {:fetch, {:error, :ratelimit}} = Fetcher.fetch_object_from_id(second) + refute_received {:request, _} + end + end + describe "max thread distance restriction" do @ap_id "http://mastodon.example.org/@admin/99541947525187367" setup do: clear_config([:instance, :federation_incoming_replies_max_depth]) diff --git a/test/pleroma/repo/migrations/backfill_instances_from_users_test.exs b/test/pleroma/repo/migrations/backfill_instances_from_users_test.exs new file mode 100644 index 000000000..811e336bd --- /dev/null +++ b/test/pleroma/repo/migrations/backfill_instances_from_users_test.exs @@ -0,0 +1,33 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2026 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Repo.Migrations.BackfillInstancesFromUsersTest do + use Pleroma.DataCase + import Pleroma.Factory + import Pleroma.Tests.Helpers + + alias Pleroma.Instances.Instance + + setup_all do: require_migration("20261028000000_backfill_instances_from_users") + + test "up/0 adds the hosts of remote users and keeps existing records", %{migration: migration} do + unreachable_since = ~N[2020-01-01 00:00:00.000000] + insert(:instance, host: "known.example", unreachable_since: unreachable_since) + + insert(:user, local: false, ap_id: "https://known.example/users/a") + insert(:user, local: false, ap_id: "https://new.example/users/a") + insert(:user, local: false, ap_id: "https://new.example/users/b") + insert(:user, local: false, ap_id: "https://ported.example:8443/users/c") + insert(:user, local: false, ap_id: "https://user@userinfo.example/users/d") + insert(:user, local: false, ap_id: "https://[2001:db8::1]/users/e") + insert(:user, local: true) + + migration.up() + + hosts = Instance |> Repo.all() |> Enum.map(& &1.host) |> Enum.sort() + assert hosts == ["known.example", "new.example", "ported.example", "userinfo.example"] + + assert %{unreachable_since: ^unreachable_since} = Repo.get_by(Instance, host: "known.example") + end +end diff --git a/test/pleroma/repo/migrations/upload_filter_exiftool_to_exif_test.exs b/test/pleroma/repo/migrations/upload_filter_exiftool_to_exif_test.exs new file mode 100644 index 000000000..0fdf26961 --- /dev/null +++ b/test/pleroma/repo/migrations/upload_filter_exiftool_to_exif_test.exs @@ -0,0 +1,73 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2026 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Repo.Migrations.UploadFilterExiftoolToExifTest do + use Pleroma.DataCase + import Pleroma.Factory + import Pleroma.Tests.Helpers + alias Pleroma.ConfigDB + + setup_all do: require_migration("20261027000000_upload_filter_exiftool_to_exif") + + defp filters do + ConfigDB.get_by_params(%{group: :pleroma, key: Pleroma.Upload}).value[:filters] + end + + test "up/0 renames the Exiftool filters", %{migration: migration} do + insert(:config, + group: :pleroma, + key: Pleroma.Upload, + value: [ + filters: [ + Pleroma.Upload.Filter.Dedupe, + Pleroma.Upload.Filter.Exiftool, + Pleroma.Upload.Filter.Exiftool.StripLocation, + Pleroma.Upload.Filter.Exiftool.ReadDescription + ] + ] + ) + + migration.up() + + assert filters() == [ + Pleroma.Upload.Filter.Dedupe, + Pleroma.Upload.Filter.Exif.StripLocation, + Pleroma.Upload.Filter.Exif.ReadDescription + ] + end + + test "down/0 restores the Exiftool filters", %{migration: migration} do + insert(:config, + group: :pleroma, + key: Pleroma.Upload, + value: [ + filters: [ + Pleroma.Upload.Filter.Exif.StripLocation, + Pleroma.Upload.Filter.Exif.ReadDescription + ] + ] + ) + + migration.down() + + assert filters() == [ + Pleroma.Upload.Filter.Exiftool.StripLocation, + Pleroma.Upload.Filter.Exiftool.ReadDescription + ] + end + + test "up/0 does nothing without a database config", %{migration: migration} do + assert migration.up() == nil + assert ConfigDB.get_by_params(%{group: :pleroma, key: Pleroma.Upload}) == nil + end + + test "up/0 does not add filters to a database config without them", %{migration: migration} do + insert(:config, group: :pleroma, key: Pleroma.Upload, value: [uploader: Pleroma.Uploaders.S3]) + + migration.up() + + value = ConfigDB.get_by_params(%{group: :pleroma, key: Pleroma.Upload}).value + assert value == [uploader: Pleroma.Uploaders.S3] + end +end diff --git a/test/pleroma/search/database_search_test.exs b/test/pleroma/search/database_search_test.exs index d8dd09063..cce810e99 100644 --- a/test/pleroma/search/database_search_test.exs +++ b/test/pleroma/search/database_search_test.exs @@ -44,4 +44,31 @@ defmodule Pleroma.Search.DatabaseSearchTest do assert result.id == other_post.id end + + describe "gin_fuzzy_search_limit" do + defp fuzzy_limit do + %{rows: [[value]]} = Pleroma.Repo.query!("SHOW gin_fuzzy_search_limit") + value + end + + test "is not set on normal database connections" do + assert fuzzy_limit() == "0" + end + + test "is only set for the search query when configured" do + clear_config([Search, :gin_fuzzy_search_limit], nil) + assert Search.with_fuzzy_limit(&fuzzy_limit/0) == "0" + + clear_config([Search, :gin_fuzzy_search_limit], 10_000) + assert Search.with_fuzzy_limit(&fuzzy_limit/0) == "10000" + end + + test "search still works with a limit" do + clear_config([Search, :gin_fuzzy_search_limit], 10_000) + {:ok, post} = CommonAPI.post(insert(:user), %{status: "it's wednesday my dudes"}) + + assert [%{id: id}] = Search.search(nil, "wednesday") + assert id == post.id + end + end end diff --git a/test/pleroma/signature_test.exs b/test/pleroma/signature_test.exs index 0c7c4c840..3c0ec598f 100644 --- a/test/pleroma/signature_test.exs +++ b/test/pleroma/signature_test.exs @@ -116,6 +116,135 @@ defmodule Pleroma.SignatureTest do end end + describe "check_signature_policy/2" do + @now ~U[2026-10-27 12:00:00Z] + + defp policy_conn(method, signed_headers, extra_headers, extra_params \\ "") do + signature = + ~s(keyId="https://remote.example/users/a#main-key",headers="#{signed_headers}") <> + extra_params <> ~s(,signature="fake") + + %Plug.Conn{ + method: method, + req_headers: [{"signature", signature}, {"host", "pleroma.example"} | extra_headers], + assigns: %{digest: "SHA-256=abc"} + } + end + + defp date_header(seconds_ago) do + date = DateTime.add(@now, -seconds_ago) |> DateTime.to_naive() + {"date", Signature.signed_date(date)} + end + + test "accepts a fresh signed GET (Pleroma, Mastodon, Misskey style)" do + for headers <- ["(request-target) host date", "(request-target) host date accept"] do + conn = policy_conn("GET", headers, [date_header(10)]) + assert Signature.check_signature_policy(conn, @now) == :ok + end + end + + test "accepts a fresh signed POST covering the digest" do + conn = + policy_conn("POST", "(request-target) host content-length digest date", [ + date_header(10), + {"digest", "SHA-256=abc"} + ]) + + assert Signature.check_signature_policy(conn, @now) == :ok + end + + test "accepts (created) instead of date (GoToSocial style)" do + created = DateTime.to_unix(@now) - 10 + + conn = + policy_conn( + "POST", + "(request-target) (created) host digest", + [{"digest", "SHA-256=abc"}], + ",algorithm=\"hs2019\",created=#{created}" + ) + + assert Signature.check_signature_policy(conn, @now) == :ok + end + + test "rejects signatures that cover neither date nor (created)" do + conn = policy_conn("GET", "(request-target) host", [date_header(10)]) + assert Signature.check_signature_policy(conn, @now) == {:error, :date_not_signed} + end + + test "rejects a body request whose digest is not signed" do + conn = + policy_conn("POST", "(request-target) host date", [ + date_header(10), + {"digest", "SHA-256=abc"} + ]) + + assert Signature.check_signature_policy(conn, @now) == {:error, :digest_not_signed} + end + + test "rejects a signed date that is missing or unparsable" do + conn = policy_conn("GET", "(request-target) host date", []) + assert Signature.check_signature_policy(conn, @now) == {:error, :invalid_date} + + conn = policy_conn("GET", "(request-target) host date", [{"date", "yesterday"}]) + assert Signature.check_signature_policy(conn, @now) == {:error, :invalid_date} + end + + test "rejects more than one date header" do + conn = + policy_conn("GET", "(request-target) host date", [date_header(10), date_header(9000)]) + + assert Signature.check_signature_policy(conn, @now) == {:error, :invalid_date} + end + + test "rejects a signed digest when the body was not hashed" do + conn = + policy_conn("POST", "(request-target) host date digest", [ + date_header(10), + {"digest", "SHA-256=abc"} + ]) + |> Map.put(:assigns, %{}) + + assert Signature.check_signature_policy(conn, @now) == {:error, :body_not_hashed} + end + + test "rejects a signed date that is too old or too far in the future" do + clear_config([:activitypub, :signature_max_age], 7200) + clear_config([:activitypub, :signature_max_clock_skew], 3600) + + conn = policy_conn("GET", "(request-target) host date", [date_header(7200)]) + assert Signature.check_signature_policy(conn, @now) == :ok + + conn = policy_conn("GET", "(request-target) host date", [date_header(7201)]) + assert Signature.check_signature_policy(conn, @now) == {:error, :date_too_old} + + conn = policy_conn("GET", "(request-target) host date", [date_header(-3600)]) + assert Signature.check_signature_policy(conn, @now) == :ok + + conn = policy_conn("GET", "(request-target) host date", [date_header(-3601)]) + assert Signature.check_signature_policy(conn, @now) == {:error, :date_in_future} + end + + test "rejects a (created) timestamp that is too old" do + clear_config([:activitypub, :signature_max_age], 60) + created = DateTime.to_unix(@now) - 61 + + conn = + policy_conn("GET", "(request-target) (created) host", [], ",created=#{created}") + + assert Signature.check_signature_policy(conn, @now) == {:error, :date_too_old} + end + + test "uses the cavage default of a signed date when headers are not given" do + conn = %Plug.Conn{ + method: "GET", + req_headers: [{"signature", "keyId=\"https://remote.example/users/a#main-key\""}] + } + + assert Signature.check_signature_policy(conn, @now) == {:error, :invalid_date} + end + end + describe "key_id_to_actor_id/1" do test "it properly deduces the actor id for misskey" do assert Signature.key_id_to_actor_id("https://example.com/users/1234/publickey") == diff --git a/test/pleroma/upload/filter/exif/read_description_test.exs b/test/pleroma/upload/filter/exif/read_description_test.exs new file mode 100644 index 000000000..07e3ce710 --- /dev/null +++ b/test/pleroma/upload/filter/exif/read_description_test.exs @@ -0,0 +1,85 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2026 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Upload.Filter.Exif.ReadDescriptionTest do + use Pleroma.DataCase, async: true + + alias Pleroma.Upload.Filter.Exif.ReadDescription + + defp upload(fixture, attrs \\ []) do + path = Path.absname(Path.join("test/fixtures", fixture)) + + struct!( + %Pleroma.Upload{ + name: fixture, + content_type: "image/" <> String.trim_leading(Path.extname(fixture), "."), + path: path, + tempfile: path, + description: nil + }, + attrs + ) + end + + test "keeps description when not empty" do + upload = + upload("image_with_imagedescription_and_caption-abstract.jpg", + description: "Some description" + ) + + assert ReadDescription.filter(upload) == {:ok, :noop} + end + + test "otherwise returns ImageDescription when present" do + upload = upload("image_with_imagedescription_and_caption-abstract.jpg") + + assert ReadDescription.filter(upload) == + {:ok, :filtered, %{upload | description: "a descriptive white pixel"}} + end + + test "ignores stray data after the image" do + upload = upload("image_with_imagedescription_and_caption-abstract_and_stray_data_after.png") + + assert {:ok, :filtered, %{description: "a descriptive white pixel"}} = + ReadDescription.filter(upload) + + assert ReadDescription.filter(upload("image_with_stray_data_after.png")) == {:ok, :noop} + end + + test "otherwise returns iptc:Caption-Abstract when present" do + upload = upload("image_with_caption-abstract.jpg") + + assert ReadDescription.filter(upload) == + {:ok, :filtered, %{upload | description: "an abstract white pixel"}} + end + + test "reads ImageDescription from WebP and HEIC" do + assert {:ok, :filtered, %{description: "a webp with a location"}} = + ReadDescription.filter(upload("strip_location_gps.webp")) + + assert {:ok, :filtered, %{description: "a heic with a location"}} = + ReadDescription.filter(upload("strip_location_gps.heic")) + end + + test "does nothing when there is no description" do + assert ReadDescription.filter(upload("image_with_no_description.jpg")) == {:ok, :noop} + end + + test "ignores descriptions that exceed the maximum length" do + clear_config([:instance, :description_limit], 5) + + upload = upload("image_with_imagedescription_and_caption-abstract.jpg") + assert ReadDescription.filter(upload) == {:ok, :noop} + end + + test "ignores content with only whitespace" do + upload = upload("image_with_imagedescription_and_caption-abstract_whitespaces.jpg") + assert ReadDescription.filter(upload) == {:ok, :noop} + end + + test "does nothing when the file can't be read" do + upload = %{upload("image.jpg") | tempfile: Path.absname("test/fixtures/non-existant_tmp.jpg")} + assert ReadDescription.filter(upload) == {:ok, :noop} + end +end diff --git a/test/pleroma/upload/filter/exif/strip_location_test.exs b/test/pleroma/upload/filter/exif/strip_location_test.exs new file mode 100644 index 000000000..f1bb3b87c --- /dev/null +++ b/test/pleroma/upload/filter/exif/strip_location_test.exs @@ -0,0 +1,72 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2026 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Upload.Filter.Exif.StripLocationTest do + use Pleroma.DataCase, async: true + + import Pleroma.Tests.ExifHelpers + + alias Pleroma.Upload.Filter.Exif.StripLocation + + for {fixture, content_type} <- [ + {"DSCN0010.jpg", "image/jpeg"}, + {"DSCN0010.png", "image/png"}, + {"strip_location_gps.webp", "image/webp"}, + {"strip_location_gps.heic", "image/heic"}, + {"strip_location_gps_grid.heic", "image/heic"}, + {"strip_location_gps.tiff", "image/tiff"} + ] do + test "strips GPS data from #{fixture} (#{content_type})" do + upload = upload_copy(unquote(fixture), unquote(content_type)) + assert gps_tags(upload.tempfile) =~ "GPS Latitude" + + assert StripLocation.filter(upload) == {:ok, :filtered} + assert gps_tags(upload.tempfile) == "" + end + end + + test "strips GPS data from XMP, including DJI drone positions" do + for fixture <- ["xmp_gps.jpg", "dji_gps.jpg"] do + upload = upload_copy(fixture, "image/jpeg") + assert exiftool_xmp(upload.tempfile) =~ "GPSLatitude" + + assert StripLocation.filter(upload) == {:ok, :filtered} + refute exiftool_xmp(upload.tempfile) =~ ~r/GPS|AbsoluteAltitude/ + end + end + + defp exiftool_xmp(path) do + {output, 0} = System.cmd("exiftool", ["-m", "-s", "-xmp:all", path]) + output + end + + test "keeps other EXIF data in HEIC" do + upload = upload_copy("strip_location_gps.heic", "image/heic") + + assert StripLocation.filter(upload) == {:ok, :filtered} + assert tag(upload.tempfile, "ImageDescription") == "a heic with a location" + assert tag(upload.tempfile, "ImageSize") == "64x48" + end + + test "does nothing when there is no GPS data" do + upload = upload_copy("image.jpg", "image/jpeg") + original = File.read!(upload.tempfile) + + assert StripLocation.filter(upload) == {:ok, :noop} + assert File.read!(upload.tempfile) == original + end + + test "accepts PNG files with data after IEND" do + upload = upload_copy("image_with_stray_data_after.png", "image/png") + + assert {:ok, _} = StripLocation.filter(upload) + end + + test "ignores non-image uploads" do + upload = upload_copy("DSCN0010.jpg", "application/octet-stream") + + assert StripLocation.filter(upload) == {:ok, :noop} + assert gps_tags(upload.tempfile) =~ "GPS Latitude" + end +end diff --git a/test/pleroma/upload/filter/exiftool/read_description_test.exs b/test/pleroma/upload/filter/exiftool/read_description_test.exs deleted file mode 100644 index 9a1bd61d7..000000000 --- a/test/pleroma/upload/filter/exiftool/read_description_test.exs +++ /dev/null @@ -1,144 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2021 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Upload.Filter.Exiftool.ReadDescriptionTest do - use Pleroma.DataCase - alias Pleroma.Upload.Filter - - @uploads %Pleroma.Upload{ - name: "image_with_imagedescription_and_caption-abstract.jpg", - content_type: "image/jpeg", - path: Path.absname("test/fixtures/image_with_imagedescription_and_caption-abstract.jpg"), - tempfile: Path.absname("test/fixtures/image_with_imagedescription_and_caption-abstract.jpg"), - description: nil - } - - test "keeps description when not empty" do - uploads = %Pleroma.Upload{ - name: "image_with_imagedescription_and_caption-abstract.jpg", - content_type: "image/jpeg", - path: Path.absname("test/fixtures/image_with_imagedescription_and_caption-abstract.jpg"), - tempfile: - Path.absname("test/fixtures/image_with_imagedescription_and_caption-abstract.jpg"), - description: "Some description" - } - - assert Filter.Exiftool.ReadDescription.filter(uploads) == - {:ok, :noop} - end - - test "otherwise returns ImageDescription when present" do - uploads_after = %Pleroma.Upload{ - name: "image_with_imagedescription_and_caption-abstract.jpg", - content_type: "image/jpeg", - path: Path.absname("test/fixtures/image_with_imagedescription_and_caption-abstract.jpg"), - tempfile: - Path.absname("test/fixtures/image_with_imagedescription_and_caption-abstract.jpg"), - description: "a descriptive white pixel" - } - - assert Filter.Exiftool.ReadDescription.filter(@uploads) == - {:ok, :filtered, uploads_after} - end - - test "Ignores warnings" do - uploads = %Pleroma.Upload{ - name: "image_with_imagedescription_and_caption-abstract_and_stray_data_after.png", - content_type: "image/png", - path: - Path.absname( - "test/fixtures/image_with_imagedescription_and_caption-abstract_and_stray_data_after.png" - ), - tempfile: - Path.absname( - "test/fixtures/image_with_imagedescription_and_caption-abstract_and_stray_data_after.png" - ) - } - - assert {:ok, :filtered, %{description: "a descriptive white pixel"}} = - Filter.Exiftool.ReadDescription.filter(uploads) - - uploads = %Pleroma.Upload{ - name: "image_with_stray_data_after.png", - content_type: "image/png", - path: Path.absname("test/fixtures/image_with_stray_data_after.png"), - tempfile: Path.absname("test/fixtures/image_with_stray_data_after.png") - } - - assert {:ok, :filtered, %{description: nil}} = Filter.Exiftool.ReadDescription.filter(uploads) - end - - test "otherwise returns iptc:Caption-Abstract when present" do - upload = %Pleroma.Upload{ - name: "image_with_caption-abstract.jpg", - content_type: "image/jpeg", - path: Path.absname("test/fixtures/image_with_caption-abstract.jpg"), - tempfile: Path.absname("test/fixtures/image_with_caption-abstract.jpg"), - description: nil - } - - upload_after = %Pleroma.Upload{ - name: "image_with_caption-abstract.jpg", - content_type: "image/jpeg", - path: Path.absname("test/fixtures/image_with_caption-abstract.jpg"), - tempfile: Path.absname("test/fixtures/image_with_caption-abstract.jpg"), - description: "an abstract white pixel" - } - - assert Filter.Exiftool.ReadDescription.filter(upload) == - {:ok, :filtered, upload_after} - end - - test "otherwise returns nil" do - uploads = %Pleroma.Upload{ - name: "image_with_no_description.jpg", - content_type: "image/jpeg", - path: Path.absname("test/fixtures/image_with_no_description.jpg"), - tempfile: Path.absname("test/fixtures/image_with_no_description.jpg"), - description: nil - } - - assert Filter.Exiftool.ReadDescription.filter(uploads) == - {:ok, :filtered, uploads} - end - - test "Return nil when image description from EXIF data exceeds the maximum length" do - clear_config([:instance, :description_limit], 5) - - assert Filter.Exiftool.ReadDescription.filter(@uploads) == - {:ok, :filtered, @uploads} - end - - test "Ignores content with only whitespace" do - uploads = %Pleroma.Upload{ - name: "non-existant.jpg", - content_type: "image/jpeg", - path: - Path.absname( - "test/fixtures/image_with_imagedescription_and_caption-abstract_whitespaces.jpg" - ), - tempfile: - Path.absname( - "test/fixtures/image_with_imagedescription_and_caption-abstract_whitespaces.jpg" - ), - description: nil - } - - assert Filter.Exiftool.ReadDescription.filter(uploads) == - {:ok, :filtered, uploads} - end - - test "Return nil when image description from EXIF data can't be read" do - uploads = %Pleroma.Upload{ - name: "non-existant.jpg", - content_type: "image/jpeg", - path: Path.absname("test/fixtures/non-existant.jpg"), - tempfile: Path.absname("test/fixtures/non-existant_tmp.jpg"), - description: nil - } - - assert Filter.Exiftool.ReadDescription.filter(uploads) == - {:ok, :filtered, uploads} - end -end diff --git a/test/pleroma/upload/filter/exiftool/strip_location_test.exs b/test/pleroma/upload/filter/exiftool/strip_location_test.exs deleted file mode 100644 index 485060215..000000000 --- a/test/pleroma/upload/filter/exiftool/strip_location_test.exs +++ /dev/null @@ -1,51 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2022 Pleroma Authors -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Upload.Filter.Exiftool.StripLocationTest do - use Pleroma.DataCase, async: true - alias Pleroma.Upload.Filter - - test "apply exiftool filter" do - assert Pleroma.Utils.command_available?("exiftool") - - ~w{jpg png} - |> Enum.map(fn type -> - File.cp!( - "test/fixtures/DSCN0010.#{type}", - "test/fixtures/DSCN0010_tmp.#{type}" - ) - - upload = %Pleroma.Upload{ - name: "image_with_GPS_data.#{type}", - content_type: "image/jpeg", - path: Path.absname("test/fixtures/DSCN0010.#{type}"), - tempfile: Path.absname("test/fixtures/DSCN0010_tmp.#{type}") - } - - assert Filter.Exiftool.StripLocation.filter(upload) == {:ok, :filtered} - - {exif_original, 0} = System.cmd("exiftool", ["-m", "test/fixtures/DSCN0010.#{type}"]) - {exif_filtered, 0} = System.cmd("exiftool", ["-m", "test/fixtures/DSCN0010_tmp.#{type}"]) - - assert String.match?(exif_original, ~r/GPS/) - refute String.match?(exif_filtered, ~r/GPS/) - end) - end - - test "verify webp, heic, svg files are skipped" do - uploads = - ~w{webp heic svg svg+xml} - |> Enum.map(fn type -> - %Pleroma.Upload{ - name: "sample.#{type}", - content_type: "image/#{type}" - } - end) - - uploads - |> Enum.each(fn upload -> - assert Filter.Exiftool.StripLocation.filter(upload) == {:ok, :noop} - end) - end -end diff --git a/test/pleroma/upload/filter/exiftool_test.exs b/test/pleroma/upload/filter/exiftool_test.exs new file mode 100644 index 000000000..c3ee3c59c --- /dev/null +++ b/test/pleroma/upload/filter/exiftool_test.exs @@ -0,0 +1,31 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2026 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Upload.Filter.ExiftoolTest do + # Instances may still configure the deprecated Exiftool filter names. + # They must keep working, including for formats exiftool used to skip. + use Pleroma.DataCase, async: true + + import Pleroma.Tests.ExifHelpers + + for filter <- [Pleroma.Upload.Filter.Exiftool, Pleroma.Upload.Filter.Exiftool.StripLocation] do + test "#{inspect(filter)} strips GPS data from HEIC uploads" do + upload = upload_copy("strip_location_gps.heic", "image/heic") + assert gps_tags(upload.tempfile) =~ "GPS Latitude" + + assert Pleroma.Upload.Filter.filter([unquote(filter)], upload) == {:ok, upload} + assert gps_tags(upload.tempfile) == "" + end + end + + test "Exiftool.ReadDescription reads the description" do + upload = upload_copy("strip_location_gps.heic", "image/heic", description: nil) + + assert {:ok, %{description: "a heic with a location"}} = + Pleroma.Upload.Filter.filter( + [Pleroma.Upload.Filter.Exiftool.ReadDescription], + upload + ) + end +end diff --git a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs index 13a4ee108..f5c0e6a82 100644 --- a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs +++ b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs @@ -9,6 +9,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do alias Pleroma.Activity alias Pleroma.Delivery alias Pleroma.Object + alias Pleroma.Signature alias Pleroma.Tests.ObanHelpers alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub @@ -49,6 +50,16 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do |> put_req_header("signature", "keyId=\"#{actor_id}#main-key\"") end + defp put_fake_signature(conn, actor_id, date \\ Signature.signed_date()) do + conn + |> put_req_header("date", date) + |> put_req_header("digest", "SHA-256=fake-digest") + |> put_req_header( + "signature", + "keyId=\"#{actor_id}#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest content-type\",signature=\"fake-signature\"" + ) + end + defp expect_signature_retry_from(%User{} = signer) do signer_json = UserView.render("user.json", %{user: signer}) |> Map.delete("featured") @@ -792,12 +803,12 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do } } - Mox.expect(Pleroma.StubbedHTTPSignaturesMock, :validate_conn, fn _conn -> false end) + Mox.stub(Pleroma.StubbedHTTPSignaturesMock, :validate_conn, fn _conn -> false end) conn = conn |> put_req_header("content-type", "application/activity+json") - |> put_req_header("date", "Fri, 29 May 2026 13:33:31 GMT") + |> put_req_header("date", Signature.signed_date()) |> put_req_header("content-digest", "sha-256=:fake-digest:") |> put_req_header( "signature-input", @@ -832,12 +843,12 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do legacy_signature = "keyId=\"https://activitypubbot.example/user/ok#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest content-type\",signature=\"fake-signature\"" - Mox.expect(Pleroma.StubbedHTTPSignaturesMock, :validate_conn, fn _conn -> false end) + Mox.stub(Pleroma.StubbedHTTPSignaturesMock, :validate_conn, fn _conn -> false end) conn = conn |> put_req_header("content-type", "application/activity+json") - |> put_req_header("date", "Fri, 29 May 2026 13:33:31 GMT") + |> put_req_header("date", Signature.signed_date()) |> put_req_header("digest", "SHA-256=fake-digest") |> put_req_header("signature", "sig1=:fake-signature:") |> Map.update!(:req_headers, &[{"signature", legacy_signature} | &1]) @@ -871,7 +882,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do conn = conn |> put_req_header("content-type", "application/activity+json") - |> put_req_header("date", "Fri, 29 May 2026 13:33:31 GMT") + |> put_req_header("date", Signature.signed_date()) |> put_req_header("digest", "SHA-256=fake-digest") |> put_req_header( "signature-input", @@ -881,12 +892,44 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do "signature", "keyId=\"https://activitypubbot.example/user/ok#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest content-type\",signature=\"fake-signature\"" ) - |> post("/inbox", data) + |> post("/inbox", Jason.encode!(data)) assert "ok" == json_response(conn, 200) assert [_] = all_enqueued(worker: SignatureRetryWorker) end + test "refuses a replayed signed POST instead of queueing a retry", %{conn: conn} do + data = File.read!("test/fixtures/mastodon-post-activity.json") |> Jason.decode!() + stale = NaiveDateTime.add(NaiveDateTime.utc_now(), -3 * 3600) + + conn = + conn + |> put_req_header("content-type", "application/activity+json") + |> put_fake_signature(data["actor"], Signature.signed_date(stale)) + |> post("/inbox", Jason.encode!(data)) + + assert "error, invalid HTTP signature (date_too_old)" == json_response(conn, 401) + assert [] == all_enqueued(worker: SignatureRetryWorker) + assert [] == all_enqueued(worker: ReceiverWorker) + end + + test "refuses a signed POST that does not cover the digest", %{conn: conn} do + data = File.read!("test/fixtures/mastodon-post-activity.json") |> Jason.decode!() + + conn = + conn + |> put_req_header("content-type", "application/activity+json") + |> put_req_header("date", Signature.signed_date()) + |> put_req_header( + "signature", + "keyId=\"#{data["actor"]}#main-key\",headers=\"(request-target) host date\",signature=\"fake\"" + ) + |> post("/inbox", Jason.encode!(data)) + + assert "error, invalid HTTP signature (digest_not_signed)" == json_response(conn, 401) + assert [] == all_enqueued(worker: SignatureRetryWorker) + end + test "does not create a forged post after failed signature retry", %{conn: conn} do alice = insert(:user, local: false, ap_id: "https://one.com/users/alice") bob = insert(:user, local: false, ap_id: "https://two.com/users/bob") @@ -918,8 +961,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do conn |> assign(:valid_signature, false) |> put_req_header("content-type", "application/activity+json") - |> put_req_header("signature", "keyId=\"https://one.com/users/alice#main-key\"") - |> post("/inbox", data) + |> put_fake_signature(alice.ap_id) + |> post("/inbox", Jason.encode!(data)) assert "ok" == json_response(conn, 200) @@ -950,8 +993,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do conn |> assign(:valid_signature, false) |> put_req_header("content-type", "application/activity+json") - |> put_req_header("signature", "keyId=\"https://one.com/users/alice#main-key\"") - |> post("/inbox", data) + |> put_fake_signature(alice.ap_id) + |> post("/inbox", Jason.encode!(data)) assert "ok" == json_response(conn, 200) @@ -982,8 +1025,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do conn |> assign(:valid_signature, false) |> put_req_header("content-type", "application/activity+json") - |> put_req_header("signature", "keyId=\"https://one.com/users/alice#main-key\"") - |> post("/inbox", data) + |> put_fake_signature(alice.ap_id) + |> post("/inbox", Jason.encode!(data)) assert "ok" == json_response(conn, 200) @@ -1025,13 +1068,13 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do conn = conn |> put_req_header("content-type", "application/activity+json") - |> put_req_header("date", "Thu, 25 Jul 2024 13:33:31 GMT") + |> put_req_header("date", Signature.signed_date()) |> put_req_header("digest", "SHA-256=fake-digest") |> put_req_header( "signature", "keyId=\"#{alice.ap_id}#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest content-type\",signature=\"fake-signature\"" ) - |> post("/inbox", data) + |> post("/inbox", Jason.encode!(data)) assert conn.assigns.valid_signature == false assert "ok" == json_response(conn, 200) @@ -1063,13 +1106,13 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do conn = conn |> put_req_header("content-type", "application/activity+json") - |> put_req_header("date", "Thu, 25 Jul 2024 13:33:31 GMT") + |> put_req_header("date", Signature.signed_date()) |> put_req_header("digest", "SHA-256=fake-digest") |> put_req_header( "signature", "keyId=\"#{alice.ap_id}#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest content-type\",signature=\"fake-signature\"" ) - |> post("/inbox", data) + |> post("/inbox", Jason.encode!(data)) assert conn.assigns.valid_signature == false assert "ok" == json_response(conn, 200) @@ -1829,6 +1872,30 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do assert Activity.get_by_ap_id(data["id"]) end + test "it accepts activities that do not address the inbox owner", %{conn: conn} do + user = insert(:user) + actor = insert(:user, local: false) + + data = %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "type" => "Follow", + "id" => "#{actor.ap_id}/follows/1", + "actor" => actor.ap_id, + "object" => user.ap_id, + "to" => ["https://www.w3.org/ns/activitystreams#Public"] + } + + conn = + conn + |> assign_valid_signature_for_actor(data["actor"]) + |> put_req_header("content-type", "application/activity+json") + |> post("/users/#{user.nickname}/inbox", data) + + assert "ok" == json_response(conn, 200) + ObanHelpers.perform(all_enqueued(worker: ReceiverWorker)) + assert Activity.get_by_ap_id(data["id"]) + end + test "it returns an error when receiving an activity sent to a deactivated user", %{ conn: conn, data: data @@ -2642,6 +2709,43 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do assert result == "Character limit (5 characters) exceeded, contains 11 characters" end + + test "it is rate limited", %{conn: conn, activity: activity} do + clear_config([:rate_limit, :statuses_actions], {60_000, 2}) + user = insert(:user) + + conn = + conn + |> assign(:user, user) + |> put_req_header("content-type", "application/activity+json") + + for _ <- 1..2 do + conn + |> post("/users/#{user.nickname}/outbox", activity) + |> json_response(201) + end + + assert conn + |> post("/users/#{user.nickname}/outbox", activity) + |> json_response(429) == %{"error" => "Throttled"} + end + + test "it shares the rate limit with the Mastodon API", %{activity: activity} do + clear_config([:rate_limit, :statuses_actions], {60_000, 2}) + %{user: user, conn: conn} = oauth_access(["write"]) + + for _ <- 1..2 do + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses", %{status: "cofe"}) + |> json_response(200) + end + + assert conn + |> put_req_header("content-type", "application/activity+json") + |> post("/users/#{user.nickname}/outbox", activity) + |> json_response(429) == %{"error" => "Throttled"} + end end describe "/relay/followers" do diff --git a/test/pleroma/web/activity_pub/activity_pub_test.exs b/test/pleroma/web/activity_pub/activity_pub_test.exs index c118cd8f1..bd7c97820 100644 --- a/test/pleroma/web/activity_pub/activity_pub_test.exs +++ b/test/pleroma/web/activity_pub/activity_pub_test.exs @@ -184,6 +184,34 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do assert user.follower_address == "http://mastodon.example.org/users/admin/followers" end + test "it records the user's instance" do + refute Repo.get_by(Pleroma.Instances.Instance, host: "mastodon.example.org") + + {:ok, _user} = ActivityPub.make_user_from_ap_id("http://mastodon.example.org/users/admin") + + assert %{unreachable_since: nil} = + Repo.get_by(Pleroma.Instances.Instance, host: "mastodon.example.org") + end + + test "it records the instance of a known user when refreshing it" do + insert(:user, local: false, ap_id: "http://mastodon.example.org/users/admin") + refute Repo.get_by(Pleroma.Instances.Instance, host: "mastodon.example.org") + + {:ok, _user} = ActivityPub.make_user_from_ap_id("http://mastodon.example.org/users/admin") + + assert Repo.get_by(Pleroma.Instances.Instance, host: "mastodon.example.org") + end + + test "it keeps an existing instance record" do + unreachable_since = ~N[2020-01-01 00:00:00.000000] + insert(:instance, host: "mastodon.example.org", unreachable_since: unreachable_since) + + {:ok, _user} = ActivityPub.make_user_from_ap_id("http://mastodon.example.org/users/admin") + + assert %{unreachable_since: ^unreachable_since} = + Repo.get_by(Pleroma.Instances.Instance, host: "mastodon.example.org") + end + test "it returns a user that is invisible" do user_id = "http://mastodon.example.org/users/relay" {:ok, user} = ActivityPub.make_user_from_ap_id(user_id) diff --git a/test/pleroma/web/activity_pub/mrf_test.exs b/test/pleroma/web/activity_pub/mrf_test.exs index d897e529d..83c0b0ae5 100644 --- a/test/pleroma/web/activity_pub/mrf_test.exs +++ b/test/pleroma/web/activity_pub/mrf_test.exs @@ -6,8 +6,6 @@ defmodule Pleroma.Web.ActivityPub.MRFTest do use ExUnit.Case use Pleroma.Tests.Helpers - import ExUnit.CaptureLog - alias Pleroma.Web.ActivityPub.MRF defp regexes_match!([], []), do: true @@ -21,14 +19,14 @@ defmodule Pleroma.Web.ActivityPub.MRFTest do test "subdomains_regex/1" do regexes = MRF.subdomains_regex(["unsafe.tld", "*.unsafe.tld"]) - assert regexes_match!(regexes, [~r/^unsafe.tld$/i, ~r/^(.*\.)*unsafe.tld$/i]) + assert regexes_match!(regexes, [~r/^unsafe\.tld$/i, ~r/^(.*\.)?unsafe\.tld$/i]) end describe "subdomain_match/2" do test "common domains" do regexes = MRF.subdomains_regex(["unsafe.tld", "unsafe2.tld"]) - assert regexes_match!(regexes, [~r/^unsafe.tld$/i, ~r/^unsafe2.tld$/i]) + assert regexes_match!(regexes, [~r/^unsafe\.tld$/i, ~r/^unsafe2\.tld$/i]) assert MRF.subdomain_match?(regexes, "unsafe.tld") assert MRF.subdomain_match?(regexes, "unsafe2.tld") @@ -39,7 +37,7 @@ defmodule Pleroma.Web.ActivityPub.MRFTest do test "wildcard domains with one subdomain" do regexes = MRF.subdomains_regex(["*.unsafe.tld"]) - assert regexes_match!(regexes, [~r/^(.*\.)*unsafe.tld$/i]) + assert regexes_match!(regexes, [~r/^(.*\.)?unsafe\.tld$/i]) assert MRF.subdomain_match?(regexes, "unsafe.tld") assert MRF.subdomain_match?(regexes, "sub.unsafe.tld") @@ -50,7 +48,7 @@ defmodule Pleroma.Web.ActivityPub.MRFTest do test "wildcard domains with two subdomains" do regexes = MRF.subdomains_regex(["*.unsafe.tld"]) - assert regexes_match!(regexes, [~r/^(.*\.)*unsafe.tld$/i]) + assert regexes_match!(regexes, [~r/^(.*\.)?unsafe\.tld$/i]) assert MRF.subdomain_match?(regexes, "unsafe.tld") assert MRF.subdomain_match?(regexes, "sub.sub.unsafe.tld") @@ -61,7 +59,7 @@ defmodule Pleroma.Web.ActivityPub.MRFTest do test "matches are case-insensitive" do regexes = MRF.subdomains_regex(["UnSafe.TLD", "UnSAFE2.Tld"]) - assert regexes_match!(regexes, [~r/^UnSafe.TLD$/i, ~r/^UnSAFE2.Tld$/i]) + assert regexes_match!(regexes, [~r/^UnSafe\.TLD$/i, ~r/^UnSAFE2\.Tld$/i]) assert MRF.subdomain_match?(regexes, "UNSAFE.TLD") assert MRF.subdomain_match?(regexes, "UNSAFE2.TLD") @@ -72,11 +70,20 @@ defmodule Pleroma.Web.ActivityPub.MRFTest do refute MRF.subdomain_match?(regexes, "example.com") end - test "logs sensible error on accidental wildcard" do - assert_raise Regex.CompileError, fn -> - assert capture_log(MRF.subdomains_regex(["*unsafe.tld"])) =~ - "MRF: Invalid subdomain Regex: *unsafe.tld" - end + test "dots only match dots" do + regexes = MRF.subdomains_regex(["unsafe.tld", "*.other.tld"]) + + refute MRF.subdomain_match?(regexes, "unsafextld") + refute MRF.subdomain_match?(regexes, "otherxtld") + refute MRF.subdomain_match?(regexes, "sub.otherxtld") + end + + test "accidental wildcards and regex syntax match nothing" do + regexes = MRF.subdomains_regex(["*unsafe.tld", "bad(.tld"]) + + refute MRF.subdomain_match?(regexes, "unsafe.tld") + refute MRF.subdomain_match?(regexes, "sub.unsafe.tld") + refute MRF.subdomain_match?(regexes, "bad.tld") end end diff --git a/test/pleroma/web/activity_pub/publisher_test.exs b/test/pleroma/web/activity_pub/publisher_test.exs index e8b6ecf38..0c9035b4e 100644 --- a/test/pleroma/web/activity_pub/publisher_test.exs +++ b/test/pleroma/web/activity_pub/publisher_test.exs @@ -137,7 +137,7 @@ defmodule Pleroma.Web.ActivityPub.PublisherTest do assert Publisher.determine_inbox(activity, user) == "http://example.com/inbox" end - test "it returns inbox for messages involving single recipients in total" do + test "it returns sharedInbox for messages to a single recipient" do user = insert(:user, %{ shared_inbox: "http://example.com/inbox", @@ -148,6 +148,46 @@ defmodule Pleroma.Web.ActivityPub.PublisherTest do data: %{"to" => [user.ap_id], "cc" => []} } + assert Publisher.determine_inbox(activity, user) == "http://example.com/inbox" + end + + test "it returns sharedInbox for followers-only messages" do + user = + insert(:user, %{ + shared_inbox: "http://example.com/inbox", + inbox: "http://example.com/personal-inbox" + }) + + author = insert(:user) + + activity = %Activity{ + data: %{"to" => [author.follower_address], "cc" => []} + } + + assert Publisher.determine_inbox(activity, user) == "http://example.com/inbox" + end + + test "it returns inbox for messages with bcc or bto, which needs personal inboxes" do + user = + insert(:user, %{ + shared_inbox: "http://example.com/inbox", + inbox: "http://example.com/personal-inbox" + }) + + for field <- ["bcc", "bto"] do + activity = %Activity{ + data: %{"to" => [@as_public], "cc" => [], field => [user.ap_id]} + } + + assert Publisher.determine_inbox(activity, user) == "http://example.com/personal-inbox" + end + end + + test "it returns inbox when the user has no sharedInbox" do + user = insert(:user, %{shared_inbox: nil, inbox: "http://example.com/personal-inbox"}) + + activity = %Activity{data: %{"to" => [@as_public], "cc" => []}} + assert Publisher.determine_inbox(activity, user) == "http://example.com/personal-inbox" end end diff --git a/test/pleroma/web/activity_pub/transmogrifier/emoji_react_handling_test.exs b/test/pleroma/web/activity_pub/transmogrifier/emoji_react_handling_test.exs index f2e1cefa3..2e1841559 100644 --- a/test/pleroma/web/activity_pub/transmogrifier/emoji_react_handling_test.exs +++ b/test/pleroma/web/activity_pub/transmogrifier/emoji_react_handling_test.exs @@ -86,6 +86,31 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.EmojiReactHandlingTest do ) end + test "it works for incoming custom emoji reactions with a single tag object" do + user = insert(:user) + other_user = insert(:user, local: false) + {:ok, activity} = CommonAPI.post(user, %{status: "hello"}) + + data = + File.read!("test/fixtures/custom-emoji-reaction.json") + |> Jason.decode!() + |> Map.put("object", activity.data["object"]) + |> Map.put("actor", other_user.ap_id) + + [tag] = data["tag"] + data = Map.put(data, "tag", tag) + + {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) + + assert data["content"] == ":hanapog:" + assert [%{"name" => "hanapog"}] = data["tag"] + + object = Object.get_by_ap_id(data["object"]) + url = tag["icon"]["url"] + + assert match?([["hanapog", _, ^url]], object.data["reactions"]) + end + test "it works for incoming unqualified emoji reactions" do user = insert(:user) other_user = insert(:user, local: false) diff --git a/test/pleroma/web/activity_pub/transmogrifier/question_handling_test.exs b/test/pleroma/web/activity_pub/transmogrifier/question_handling_test.exs index 2a4e78cf0..7da4e9541 100644 --- a/test/pleroma/web/activity_pub/transmogrifier/question_handling_test.exs +++ b/test/pleroma/web/activity_pub/transmogrifier/question_handling_test.exs @@ -103,6 +103,25 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.QuestionHandlingTest do assert Enum.sort(object.data["oneOf"]) == Enum.sort(options) end + test "Question activity with a single, non-array tag" do + tag = %{ + "href" => "https://gts.example/tags/caturday", + "name" => "#caturday", + "type" => "Hashtag" + } + + data = + File.read!("test/fixtures/mastodon-question-activity.json") + |> Jason.decode!() + |> Kernel.put_in(["object", "tag"], tag) + + {:ok, %Activity{local: false} = activity} = Transmogrifier.handle_incoming(data) + object = Object.normalize(activity, fetch: false) + + assert tag in object.data["tag"] + assert Object.hashtags(object) == ["caturday"] + end + test "Mastodon Question activity with custom emojis" do options = [ %{ diff --git a/test/pleroma/web/activity_pub/transmogrifier_test.exs b/test/pleroma/web/activity_pub/transmogrifier_test.exs index bedb466a4..0e4a4c2db 100644 --- a/test/pleroma/web/activity_pub/transmogrifier_test.exs +++ b/test/pleroma/web/activity_pub/transmogrifier_test.exs @@ -147,6 +147,53 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do assert activity.data["type"] == "Move" end + test "it refetches a cached remote Move target that lacks the alias" do + old_user = insert(:user, local: false, ap_id: "https://old.example/users/old") + + new_user = + insert(:user, + local: false, + ap_id: "https://new.example/users/new", + nickname: "new@new.example", + last_refreshed_at: NaiveDateTime.utc_now() + ) + + actor = %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "id" => new_user.ap_id, + "type" => "Person", + "preferredUsername" => "new", + "inbox" => new_user.ap_id <> "/inbox", + "outbox" => new_user.ap_id <> "/outbox", + "followers" => new_user.ap_id <> "/followers", + "following" => new_user.ap_id <> "/following", + "alsoKnownAs" => [old_user.ap_id] + } + + Tesla.Mock.mock(fn + %{method: :get, url: "https://new.example/users/new"} -> + %Tesla.Env{ + status: 200, + headers: [{"content-type", "application/activity+json"}], + body: Jason.encode!(actor) + } + + _ -> + %Tesla.Env{status: 404} + end) + + message = %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "type" => "Move", + "actor" => old_user.ap_id, + "object" => old_user.ap_id, + "target" => new_user.ap_id + } + + assert {:ok, %Activity{data: %{"type" => "Move"}}} = Transmogrifier.handle_incoming(message) + assert old_user.ap_id in User.get_cached_by_ap_id(new_user.ap_id).also_known_as + end + test "it fixes both the Create and object contexts in a reply" do insert(:user, ap_id: "https://mk.absturztau.be/users/8ozbzjs3o8") insert(:user, ap_id: "https://p.helene.moe/users/helene") diff --git a/test/pleroma/web/activity_pub/utils_test.exs b/test/pleroma/web/activity_pub/utils_test.exs index 93234a015..88c8a283b 100644 --- a/test/pleroma/web/activity_pub/utils_test.exs +++ b/test/pleroma/web/activity_pub/utils_test.exs @@ -406,74 +406,6 @@ defmodule Pleroma.Web.ActivityPub.UtilsTest do end end - describe "recipient_in_message/3" do - test "returns true when recipient in `to`" do - recipient = insert(:user) - actor = insert(:user) - assert Utils.recipient_in_message(recipient, actor, %{"to" => recipient.ap_id}) - - assert Utils.recipient_in_message( - recipient, - actor, - %{"to" => [recipient.ap_id], "cc" => ""} - ) - end - - test "returns true when recipient in `cc`" do - recipient = insert(:user) - actor = insert(:user) - assert Utils.recipient_in_message(recipient, actor, %{"cc" => recipient.ap_id}) - - assert Utils.recipient_in_message( - recipient, - actor, - %{"cc" => [recipient.ap_id], "to" => ""} - ) - end - - test "returns true when recipient in `bto`" do - recipient = insert(:user) - actor = insert(:user) - assert Utils.recipient_in_message(recipient, actor, %{"bto" => recipient.ap_id}) - - assert Utils.recipient_in_message( - recipient, - actor, - %{"bcc" => "", "bto" => [recipient.ap_id]} - ) - end - - test "returns true when recipient in `bcc`" do - recipient = insert(:user) - actor = insert(:user) - assert Utils.recipient_in_message(recipient, actor, %{"bcc" => recipient.ap_id}) - - assert Utils.recipient_in_message( - recipient, - actor, - %{"bto" => "", "bcc" => [recipient.ap_id]} - ) - end - - test "returns true when message without addresses fields" do - recipient = insert(:user) - actor = insert(:user) - assert Utils.recipient_in_message(recipient, actor, %{"bccc" => recipient.ap_id}) - - assert Utils.recipient_in_message( - recipient, - actor, - %{"btod" => "", "bccc" => [recipient.ap_id]} - ) - end - - test "returns false" do - recipient = insert(:user) - actor = insert(:user) - refute Utils.recipient_in_message(recipient, actor, %{"to" => "ap_id"}) - end - end - describe "lazy_put_activity_defaults/2" do test "returns map with id and published data" do note_activity = insert(:note_activity) diff --git a/test/pleroma/web/admin_api/controllers/config_controller_test.exs b/test/pleroma/web/admin_api/controllers/config_controller_test.exs index e4c8d866f..a035d558f 100644 --- a/test/pleroma/web/admin_api/controllers/config_controller_test.exs +++ b/test/pleroma/web/admin_api/controllers/config_controller_test.exs @@ -60,6 +60,28 @@ defmodule Pleroma.Web.AdminAPI.ConfigControllerTest do assert key2 == inspect(config2.key) end + test "does not crash on a setting with an unknown module key", %{conn: conn} do + now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) + + Pleroma.Repo.insert_all("config", [ + %{ + group: ":pleroma", + key: "Pleroma.Nowhere.Module", + value: :erlang.term_to_binary(enabled: true), + inserted_at: now, + updated_at: now + } + ]) + + capture_log(fn -> + for path <- ["/api/pleroma/admin/config", "/api/pleroma/admin/config?only_db=true"] do + conn + |> get(path) + |> json_response_and_validate_schema(200) + end + end) + end + test "db is added to settings that are in db", %{conn: conn} do _config = insert(:config, key: ":instance", value: [name: "Some name"]) diff --git a/test/pleroma/web/api_spec/cast_and_validate_test.exs b/test/pleroma/web/api_spec/cast_and_validate_test.exs new file mode 100644 index 000000000..b7ee673e1 --- /dev/null +++ b/test/pleroma/web/api_spec/cast_and_validate_test.exs @@ -0,0 +1,24 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.CastAndValidateTest do + use Pleroma.Web.ConnCase, async: false + + describe "permissive mode" do + setup do: clear_config([Pleroma.Web.ApiSpec.CastAndValidate, :strict], false) + + test "drops several unexpected query params", %{conn: conn} do + assert conn + |> get("/api/v1/timelines/public?foo=1&bar=2&baz=3&local=true") + |> json_response_and_validate_schema(200) + end + + test "still rejects an invalid known param", %{conn: conn} do + assert %{"error" => _} = + conn + |> get("/api/v1/timelines/public?foo=1&bar=2&limit=lots") + |> json_response(400) + end + end +end diff --git a/test/pleroma/web/common_api_test.exs b/test/pleroma/web/common_api_test.exs index 5d0ad4572..6c2b5ec91 100644 --- a/test/pleroma/web/common_api_test.exs +++ b/test/pleroma/web/common_api_test.exs @@ -338,6 +338,26 @@ defmodule Pleroma.Web.CommonAPITest do assert {:ok, :no_activity} == CommonAPI.unblock(blocked, blocker) refute User.blocks?(blocker, blocked) end + + test "it unblocks and does not federate if outgoing blocks are disabled" do + clear_config([:instance, :federating], true) + clear_config([:activitypub, :outgoing_blocks], false) + + blocked = insert(:user) + blocker = insert(:user) + + with_mock Pleroma.Web.Federator, + publish: fn _ -> nil end do + assert {:ok, block} = CommonAPI.block(blocked, blocker) + assert block.local + assert User.blocks?(blocker, blocked) + + assert {:ok, unblock} = CommonAPI.unblock(blocked, blocker) + assert unblock.local + refute User.blocks?(blocker, blocked) + assert_not_called(Pleroma.Web.Federator.publish(:_)) + end + end end describe "deletion" do diff --git a/test/pleroma/web/mastodon_api/controllers/instance_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/instance_controller_test.exs index 461b46066..f9d42d981 100644 --- a/test/pleroma/web/mastodon_api/controllers/instance_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/instance_controller_test.exs @@ -63,8 +63,8 @@ defmodule Pleroma.Web.MastodonAPI.InstanceControllerTest do user2 = insert(:user, %{local: true}) {:ok, _user2} = User.set_activation(user2, false) - insert(:user, %{local: false, nickname: "u@peer1.com"}) - insert(:user, %{local: false, nickname: "u@peer2.com"}) + insert(:instance, host: "peer1.com") + insert(:instance, host: "peer2.com") {:ok, _} = Pleroma.Web.CommonAPI.post(user, %{status: "cofe"}) @@ -83,8 +83,11 @@ defmodule Pleroma.Web.MastodonAPI.InstanceControllerTest do end test "get peers", %{conn: conn} do - insert(:user, %{local: false, nickname: "u@peer1.com"}) - insert(:user, %{local: false, nickname: "u@peer2.com"}) + # Peers come from the instances table, which remote users add to. + insert(:instance, host: "peer1.com") + insert(:instance, host: "peer2.com") + # Favicon lookups for local accounts can add our own host. + insert(:instance, host: Pleroma.Web.Endpoint.host()) Pleroma.Stats.force_update() diff --git a/test/pleroma/web/mastodon_api/controllers/marker_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/marker_controller_test.exs index 4050528ff..ff85031f0 100644 --- a/test/pleroma/web/mastodon_api/controllers/marker_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/marker_controller_test.exs @@ -8,6 +8,7 @@ defmodule Pleroma.Web.MastodonAPI.MarkerControllerTest do alias Pleroma.Notification alias Pleroma.Repo alias Pleroma.Web.CommonAPI + alias Pleroma.Web.CommonAPI.Utils import Pleroma.Factory @@ -33,7 +34,7 @@ defmodule Pleroma.Web.MastodonAPI.MarkerControllerTest do assert response == %{ "notifications" => %{ "last_read_id" => "69420", - "updated_at" => NaiveDateTime.to_iso8601(marker.updated_at), + "updated_at" => Utils.to_masto_date(marker.updated_at), "version" => 0, "pleroma" => %{"unread_count" => 7} } @@ -107,7 +108,7 @@ defmodule Pleroma.Web.MastodonAPI.MarkerControllerTest do assert response == %{ "notifications" => %{ "last_read_id" => "69888", - "updated_at" => NaiveDateTime.to_iso8601(marker.updated_at), + "updated_at" => Utils.to_masto_date(marker.updated_at), "version" => 0, "pleroma" => %{"unread_count" => 0} } diff --git a/test/pleroma/web/mastodon_api/controllers/search_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/search_controller_test.exs index f0c9c1901..368c07f35 100644 --- a/test/pleroma/web/mastodon_api/controllers/search_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/search_controller_test.exs @@ -328,7 +328,9 @@ defmodule Pleroma.Web.MastodonAPI.SearchControllerTest do assert status["id"] == to_string(activity.id) end - test "search fetches remote statuses and prefers them over other results", %{conn: conn} do + test "search fetches remote statuses and prefers them over other results" do + %{conn: conn} = oauth_access(["read:search"]) + {:ok, %{id: activity_id}} = CommonAPI.post(insert(:user), %{ status: "check out http://mastodon.example.org/@admin/99541947525187367" @@ -336,7 +338,9 @@ defmodule Pleroma.Web.MastodonAPI.SearchControllerTest do %{"url" => result_url, "id" => result_id} = conn - |> get("/api/v1/search?q=http://mastodon.example.org/@admin/99541947525187367") + |> get( + "/api/v1/search?resolve=true&q=http://mastodon.example.org/@admin/99541947525187367" + ) |> json_response_and_validate_schema(200) |> Map.get("statuses") |> List.first() @@ -345,6 +349,28 @@ defmodule Pleroma.Web.MastodonAPI.SearchControllerTest do assert match?(^result_url, "http://mastodon.example.org/@admin/99541947525187367") end + test "search fetches remote statuses only with resolve, when logged in, on the first page", + %{conn: anonymous} do + %{conn: conn} = oauth_access(["read:search"]) + url = "http://mastodon.example.org/@admin/99541947525187367" + + for {conn, params} <- [ + {conn, ""}, + {conn, "&resolve=false"}, + {conn, "&resolve=true&offset=5"}, + {anonymous, "&resolve=true"} + ] do + assert %{"statuses" => []} = + conn + |> get("/api/v1/search?q=#{url}#{params}") + |> json_response_and_validate_schema(200) + end + + refute Pleroma.Object.get_by_ap_id( + "http://mastodon.example.org/users/admin/statuses/99541947525187367" + ) + end + test "search doesn't show statuses that it shouldn't", %{conn: conn} do {:ok, activity} = CommonAPI.post(insert(:user), %{ diff --git a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs index 6ee79689d..6574f82f8 100644 --- a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs @@ -2243,6 +2243,31 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do |> json_response_and_validate_schema(404) == %{"error" => "Record not found"} end + describe "status_id_action rate limit" do + setup do: clear_config([:rate_limit, :status_id_action], {60_000, 2}) + setup do: clear_config([:rate_limit, :statuses_actions], {60_000, 100}) + setup do: oauth_access(["write:statuses", "write:favourites"]) + + for {action, undo} <- [{"favourite", "unfavourite"}, {"reblog", "unreblog"}], + strict <- [true, false] do + test "#{action} is limited per status, not per user (strict: #{strict})", %{conn: conn} do + clear_config([Pleroma.Web.ApiSpec.CastAndValidate, :strict], unquote(strict)) + conn = put_req_header(conn, "content-type", "application/json") + statuses = insert_list(3, :note_activity) + + assert Enum.map( + statuses, + &post(conn, "/api/v1/statuses/#{&1.id}/#{unquote(action)}").status + ) == + [200, 200, 200] + + status = hd(statuses) + assert post(conn, "/api/v1/statuses/#{status.id}/#{unquote(undo)}").status == 200 + assert post(conn, "/api/v1/statuses/#{status.id}/#{unquote(action)}").status == 429 + end + end + end + describe "pinned statuses" do setup do: oauth_access(["write:accounts"]) @@ -3068,6 +3093,30 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do } = response end + test "context shows local-only replies to local users only" do + poster = insert(:user) + replier = insert(:user) + %{conn: conn} = oauth_access(["read:statuses"]) + %{conn: remote_conn} = oauth_access(["read:statuses"], user: insert(:user, local: false)) + + {:ok, %{id: id1}} = CommonAPI.post(poster, %{status: "1"}) + + {:ok, %{id: id2}} = + CommonAPI.post(replier, %{status: "2", visibility: "local", in_reply_to_status_id: id1}) + + assert %{"descendants" => [%{"id" => ^id2}]} = + conn + |> get("/api/v1/statuses/#{id1}/context") + |> json_response_and_validate_schema(:ok) + + for conn <- [build_conn(), remote_conn] do + assert %{"descendants" => []} = + conn + |> get("/api/v1/statuses/#{id1}/context") + |> json_response_and_validate_schema(:ok) + end + end + test "context doesn't leak priv posts" do %{user: user, conn: conn} = oauth_access(["read:statuses"]) stranger = insert(:user) diff --git a/test/pleroma/web/mastodon_api/views/marker_view_test.exs b/test/pleroma/web/mastodon_api/views/marker_view_test.exs index 43f8c4302..4f4eeca82 100644 --- a/test/pleroma/web/mastodon_api/views/marker_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/marker_view_test.exs @@ -4,6 +4,7 @@ defmodule Pleroma.Web.MastodonAPI.MarkerViewTest do use Pleroma.DataCase, async: true + alias Pleroma.Web.CommonAPI.Utils alias Pleroma.Web.MastodonAPI.MarkerView import Pleroma.Factory @@ -14,16 +15,25 @@ defmodule Pleroma.Web.MastodonAPI.MarkerViewTest do assert MarkerView.render("markers.json", %{markers: [marker1, marker2]}) == %{ "home" => %{ last_read_id: "42", - updated_at: NaiveDateTime.to_iso8601(marker2.updated_at), + updated_at: Utils.to_masto_date(marker2.updated_at), version: 0, pleroma: %{unread_count: 0} }, "notifications" => %{ last_read_id: "17", - updated_at: NaiveDateTime.to_iso8601(marker1.updated_at), + updated_at: Utils.to_masto_date(marker1.updated_at), version: 0, pleroma: %{unread_count: 5} } } end + + test "renders updated_at in the Mastodon date format" do + marker = insert(:marker, timeline: "home") + + %{"home" => %{updated_at: updated_at}} = + MarkerView.render("markers.json", %{markers: [marker]}) + + assert updated_at =~ ~r/\.\d{3}Z$/ + end end diff --git a/test/pleroma/web/mastodon_api/views/status_view_test.exs b/test/pleroma/web/mastodon_api/views/status_view_test.exs index 6d1bda193..b121cf32c 100644 --- a/test/pleroma/web/mastodon_api/views/status_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/status_view_test.exs @@ -788,7 +788,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do test "put the url advertised in the Activity in to the url attribute" do id = "https://wedistribute.org/wp-json/pterotype/v1/object/85810" - [activity] = Activity.search(nil, id) + [activity] = Activity.search(insert(:user), id, resolve: true) status = StatusView.render("show.json", %{activity: activity}) diff --git a/test/pleroma/web/media_proxy/media_proxy_controller_test.exs b/test/pleroma/web/media_proxy/media_proxy_controller_test.exs index f7e52483c..71ad19c09 100644 --- a/test/pleroma/web/media_proxy/media_proxy_controller_test.exs +++ b/test/pleroma/web/media_proxy/media_proxy_controller_test.exs @@ -252,22 +252,27 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyControllerTest do assert redirected_to(response, 301) == media_proxy_url end - test "with `static` param and non-GIF image preview requested, " <> - "redirects to media preview proxy URI without `static` param", + test "with `static` param and non-GIF image preview requested, responds with a preview", %{ conn: conn, url: url, media_proxy_url: media_proxy_url } do + assert_dependencies_installed() + Tesla.Mock.mock(fn %{method: :head, url: ^media_proxy_url} -> %Tesla.Env{status: 200, body: "", headers: [{"content-type", "image/jpeg"}]} + + %{method: :get, url: ^media_proxy_url} -> + %Tesla.Env{status: 200, body: File.read!("test/fixtures/image.jpg")} end) response = get(conn, url <> "?static=true") - assert response.status == 302 - assert redirected_to(response) == url + assert response.status == 200 + assert Conn.get_resp_header(response, "content-type") == ["image/jpeg"] + assert response.resp_body != "" end test "with :min_content_length setting not matched by Content-Length header, " <> diff --git a/test/pleroma/web/media_proxy_test.exs b/test/pleroma/web/media_proxy_test.exs index ab707a061..433a0fe95 100644 --- a/test/pleroma/web/media_proxy_test.exs +++ b/test/pleroma/web/media_proxy_test.exs @@ -45,6 +45,18 @@ defmodule Pleroma.Web.MediaProxyTest do assert MediaProxy.url(local_root) == local_root end + test "ignores data url" do + assert MediaProxy.url("data:image/png;base64,AAAA") == "data:image/png;base64,AAAA" + assert MediaProxy.url("DATA:image/png;base64,AAAA") == "DATA:image/png;base64,AAAA" + end + + test "preview_url ignores data url" do + clear_config([:media_preview_proxy, :enabled], true) + + assert MediaProxy.preview_url("data:image/png;base64,AAAA") == + "data:image/png;base64,AAAA" + end + test "encodes and decodes URL" do url = "https://pleroma.soykaf.com/static/logo.png" encoded = MediaProxy.url(url) diff --git a/test/pleroma/web/pleroma_api/controllers/emoji_reaction_controller_test.exs b/test/pleroma/web/pleroma_api/controllers/emoji_reaction_controller_test.exs index 8fcdb233b..12b037a21 100644 --- a/test/pleroma/web/pleroma_api/controllers/emoji_reaction_controller_test.exs +++ b/test/pleroma/web/pleroma_api/controllers/emoji_reaction_controller_test.exs @@ -477,4 +477,53 @@ defmodule Pleroma.Web.PleromaAPI.EmojiReactionControllerTest do |> get("/api/v1/pleroma/statuses/#{activity_id}/reactions/🐈") |> json_response_and_validate_schema(404) == %{"error" => "Record not found"} end + + describe "rate limiting" do + test "PUT and DELETE share a per-status limit", %{conn: conn} do + clear_config([:rate_limit, :status_id_action], {60_000, 2}) + user = insert(:user) + %{id: activity_id} = insert(:note_activity) + %{id: other_activity_id} = insert(:note_activity) + conn = prepare_conn_of_user(conn, user) + + conn + |> put("/api/v1/pleroma/statuses/#{activity_id}/reactions/☕") + |> json_response_and_validate_schema(200) + + conn + |> delete("/api/v1/pleroma/statuses/#{activity_id}/reactions/☕") + |> json_response_and_validate_schema(200) + + assert conn + |> put("/api/v1/pleroma/statuses/#{activity_id}/reactions/🐈") + |> json_response(429) == %{"error" => "Throttled"} + + conn + |> put("/api/v1/pleroma/statuses/#{other_activity_id}/reactions/🐈") + |> json_response_and_validate_schema(200) + end + + test "PUT and DELETE count against statuses_actions", %{conn: conn} do + clear_config([:rate_limit, :statuses_actions], {60_000, 2}) + user = insert(:user) + [first, second, third] = insert_list(3, :note_activity) + conn = prepare_conn_of_user(conn, user) + + conn + |> put("/api/v1/pleroma/statuses/#{first.id}/reactions/☕") + |> json_response_and_validate_schema(200) + + conn + |> delete("/api/v1/pleroma/statuses/#{first.id}/reactions/☕") + |> json_response_and_validate_schema(200) + + assert conn + |> put("/api/v1/pleroma/statuses/#{second.id}/reactions/☕") + |> json_response(429) == %{"error" => "Throttled"} + + assert conn + |> delete("/api/v1/pleroma/statuses/#{third.id}/reactions/☕") + |> json_response(429) == %{"error" => "Throttled"} + end + end end diff --git a/test/pleroma/web/plugs/http_signature_plug_mrf_test.exs b/test/pleroma/web/plugs/http_signature_plug_mrf_test.exs index 1ee81b1a0..0101e4ab6 100644 --- a/test/pleroma/web/plugs/http_signature_plug_mrf_test.exs +++ b/test/pleroma/web/plugs/http_signature_plug_mrf_test.exs @@ -26,7 +26,11 @@ defmodule Pleroma.Web.Plugs.HTTPSignaturePlugMRFTest do |> expect(:validate_conn, fn _ -> true end) build_conn(:get, "/doesntmattter", %{"actor" => actor}) - |> put_req_header("signature", "keyId=\"#{actor}#main-key") + |> put_req_header("date", Pleroma.Signature.signed_date()) + |> put_req_header( + "signature", + "keyId=\"#{actor}#main-key\",headers=\"(request-target) host date\",signature=\"fake\"" + ) |> put_format("activity+json") |> HTTPSignaturePlug.call(%{}) end diff --git a/test/pleroma/web/plugs/http_signature_plug_policy_test.exs b/test/pleroma/web/plugs/http_signature_plug_policy_test.exs new file mode 100644 index 000000000..14279d4bc --- /dev/null +++ b/test/pleroma/web/plugs/http_signature_plug_policy_test.exs @@ -0,0 +1,119 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Plugs.HTTPSignaturePlugPolicyTest do + # Not async: changes global config and uses global HTTP mocks. + use Pleroma.Web.ConnCase + + alias Pleroma.Keys + alias Pleroma.Object.Fetcher + alias Pleroma.Repo + alias Pleroma.StubbedHTTPSignaturesMock, as: HTTPSignaturesMock + alias Pleroma.User + alias Pleroma.Web.ActivityPub.InternalFetchActor + alias Pleroma.Web.ActivityPub.Publisher + alias Pleroma.Web.Plugs.DigestPlug + alias Pleroma.Web.Plugs.HTTPSignaturePlug + + import ExUnit.CaptureLog + import Mox + import Phoenix.Controller, only: [put_format: 2] + import Pleroma.Factory + import Plug.Conn + + defp incoming_conn(method, url, headers, body \\ "") do + %URI{host: host, path: path} = URI.parse(url) + + headers = Enum.map(headers, fn {name, value} -> {String.downcase(name), value} end) + + Plug.Test.conn(method, path, body) + |> Map.put(:req_headers, [{"host", host} | headers]) + |> put_format("activity+json") + end + + # Local users have no public_key column set; publish it like a remote copy would. + defp store_public_key(%User{} = user) do + {:ok, _, public_key} = Keys.keys_from_pem(user.keys) + entry = :public_key.pem_entry_encode(:SubjectPublicKeyInfo, public_key) + + user + |> Ecto.Changeset.change(public_key: :public_key.pem_encode([entry])) + |> Repo.update!() + |> User.set_cache() + end + + defp capture_request(method) do + test_pid = self() + + Tesla.Mock.mock_global(fn %{method: ^method, headers: headers, body: body} = env -> + send(test_pid, {:request, headers, body}) + %{env | status: 200, body: ""} + end) + end + + test "accepts deliveries signed by Pleroma's publisher" do + stub_with(Pleroma.Web.ActivityPub.TransmogrifierMock, Pleroma.Web.ActivityPub.Transmogrifier) + capture_request(:post) + + activity = insert(:note_activity) + store_public_key(User.get_by_ap_id(activity.data["actor"])) + inbox = "https://remote.example/inbox" + + {:ok, _} = + %{inbox: inbox, activity_id: activity.id} + |> Publisher.prepare_one() + |> Publisher.publish_one() + + assert_received {:request, headers, body} + + {:ok, _, conn} = + incoming_conn(:post, inbox, [{"content-length", "#{byte_size(body)}"} | headers], body) + |> DigestPlug.read_body([]) + + conn = HTTPSignaturePlug.call(conn, %{}) + + assert conn.assigns.valid_signature == true + refute conn.halted + end + + test "accepts object fetches signed by Pleroma's fetcher" do + clear_config([:activitypub, :sign_object_fetches], true) + capture_request(:get) + store_public_key(InternalFetchActor.get_actor()) + + url = "https://remote.example/objects/1" + Fetcher.fetch_and_contain_remote_object_from_id(url) + + assert_received {:request, headers, _body} + + conn = + incoming_conn(:get, url, headers) + |> HTTPSignaturePlug.call(%{}) + + assert conn.assigns.valid_signature == true + end + + test "only logs policy violations when the policy is set to :log" do + clear_config([:activitypub, :signature_policy], :log) + + HTTPSignaturesMock + |> expect(:validate_conn, fn _ -> true end) + + log = + capture_log(fn -> + conn = + incoming_conn(:post, "https://pleroma.example/inbox", [ + {"date", Pleroma.Signature.signed_date()}, + {"signature", + "keyId=\"https://remote.example/users/a#main-key\",headers=\"(request-target) host date\",signature=\"fake\""} + ]) + |> HTTPSignaturePlug.call(%{}) + + assert conn.assigns.valid_signature == true + refute conn.halted + end) + + assert log =~ "digest_not_signed" + end +end diff --git a/test/pleroma/web/plugs/http_signature_plug_test.exs b/test/pleroma/web/plugs/http_signature_plug_test.exs index 9d07270bb..bdab88939 100644 --- a/test/pleroma/web/plugs/http_signature_plug_test.exs +++ b/test/pleroma/web/plugs/http_signature_plug_test.exs @@ -13,6 +13,15 @@ defmodule Pleroma.Web.Plugs.HTTPSignaturePlugTest do import Phoenix.Controller, only: [put_format: 2] import Plug.Conn + defp put_signature(conn, actor, signed_headers \\ "(request-target) host date") do + conn + |> put_req_header("date", Pleroma.Signature.signed_date()) + |> put_req_header( + "signature", + "keyId=\"#{actor}#main-key\",headers=\"#{signed_headers}\",signature=\"fake\"" + ) + end + test "it calls HTTPSignatures to check validity if the actor signed it" do params = %{"actor" => "http://mastodon.example.org/users/admin"} conn = build_conn(:get, "/doesntmattter", params) @@ -22,10 +31,7 @@ defmodule Pleroma.Web.Plugs.HTTPSignaturePlugTest do conn = conn - |> put_req_header( - "signature", - "keyId=\"http://mastodon.example.org/users/admin#main-key" - ) + |> put_signature("http://mastodon.example.org/users/admin") |> put_format("activity+json") |> HTTPSignaturePlug.call(%{}) @@ -51,10 +57,7 @@ defmodule Pleroma.Web.Plugs.HTTPSignaturePlugTest do conn = orig_conn - |> put_req_header( - "signature", - "keyId=\"http://mastodon.example.org/users/admin#main-key" - ) + |> put_signature("http://mastodon.example.org/users/admin") |> HTTPSignaturePlug.call(%{}) assert conn.assigns.valid_signature == false @@ -71,10 +74,7 @@ defmodule Pleroma.Web.Plugs.HTTPSignaturePlugTest do conn = orig_conn - |> put_req_header( - "signature", - "keyId=\"http://mastodon.example.org/users/admin#main-key" - ) + |> put_signature("http://mastodon.example.org/users/admin") |> HTTPSignaturePlug.call(%{}) assert conn.assigns.valid_signature == true @@ -108,10 +108,7 @@ defmodule Pleroma.Web.Plugs.HTTPSignaturePlugTest do conn = conn |> Map.put(:remote_ip, {192, 168, 0, 1}) - |> put_req_header( - "signature", - "keyId=\"http://mastodon.example.org/users/admin#main-key" - ) + |> put_signature("http://mastodon.example.org/users/admin") |> HTTPSignaturePlug.call(%{}) assert conn.remote_ip == {192, 168, 0, 1} @@ -131,10 +128,7 @@ defmodule Pleroma.Web.Plugs.HTTPSignaturePlugTest do conn = build_conn(:get, "/doesntmattter", %{"actor" => "http://mastodon.example.org/users/admin"}) - |> put_req_header( - "signature", - "keyId=\"http://mastodon.example.org/users/admin#main-key" - ) + |> put_signature("http://mastodon.example.org/users/admin") |> put_format("activity+json") |> HTTPSignaturePlug.call(%{}) @@ -152,14 +146,84 @@ defmodule Pleroma.Web.Plugs.HTTPSignaturePlugTest do conn = build_conn(:get, "/doesntmattter", %{"actor" => "http://allowed.example.org/users/admin"}) - |> put_req_header( - "signature", - "keyId=\"http://allowed.example.org/users/admin#main-key" - ) + |> put_signature("http://allowed.example.org/users/admin") |> put_format("activity+json") |> HTTPSignaturePlug.call(%{}) assert conn.assigns.valid_signature == true assert conn.halted == false end + + describe "signature policy" do + test "treats a signed GET with a stale date as unsigned" do + HTTPSignaturesMock + |> expect(:validate_conn, 0, fn _ -> true end) + + stale = NaiveDateTime.add(NaiveDateTime.utc_now(), -3 * 3600) + + conn = + build_conn(:get, "/doesntmattter") + |> put_signature("http://mastodon.example.org/users/admin") + |> put_req_header("date", Pleroma.Signature.signed_date(stale)) + |> put_format("activity+json") + |> HTTPSignaturePlug.call(%{}) + + assert conn.assigns.valid_signature == false + assert conn.assigns.signature_error == :date_too_old + refute conn.halted + end + + test "marks a signed POST that does not cover the digest as invalid" do + HTTPSignaturesMock + |> expect(:validate_conn, 0, fn _ -> true end) + + conn = + build_conn(:post, "/inbox") + |> put_signature("http://mastodon.example.org/users/admin") + |> put_format("activity+json") + |> HTTPSignaturePlug.call(%{}) + + assert conn.assigns.valid_signature == false + assert conn.assigns.signature_error == :digest_not_signed + refute conn.halted + end + + test "uses the digest computed from the body for signed POSTs" do + HTTPSignaturesMock + |> expect(:validate_conn, fn conn -> + get_req_header(conn, "digest") == ["SHA-256=computed"] + end) + + conn = + build_conn(:post, "/inbox") + |> put_signature( + "http://mastodon.example.org/users/admin", + "(request-target) host date digest" + ) + |> put_req_header("digest", "SHA-256=claimed") + |> assign(:digest, "SHA-256=computed") + |> put_format("activity+json") + |> HTTPSignaturePlug.call(%{}) + + assert conn.assigns.valid_signature == true + end + + test "marks a signed POST whose body was not hashed as invalid" do + HTTPSignaturesMock + |> expect(:validate_conn, 0, fn _ -> true end) + + conn = + build_conn(:post, "/inbox") + |> put_signature( + "http://mastodon.example.org/users/admin", + "(request-target) host date digest" + ) + |> put_req_header("digest", "SHA-256=claimed") + |> put_format("activity+json") + |> HTTPSignaturePlug.call(%{}) + + assert conn.assigns.valid_signature == false + assert conn.assigns.signature_error == :body_not_hashed + end + end end diff --git a/test/pleroma/web/web_finger_test.exs b/test/pleroma/web/web_finger_test.exs index da10abdd2..cf7cfa8b6 100644 --- a/test/pleroma/web/web_finger_test.exs +++ b/test/pleroma/web/web_finger_test.exs @@ -305,4 +305,30 @@ defmodule Pleroma.Web.WebFingerTest do {:ok, _data} = WebFinger.finger("a@sub.mastodon.example") end end + + describe "rate limiting" do + setup do + # the module setup stubs NullCache, the backoff needs a real cache + Mox.stub_with(Pleroma.CachexMock, Pleroma.CachexProxy) + test_pid = self() + + Tesla.Mock.mock_global(fn %{url: url} -> + send(test_pid, {:request, url}) + %Tesla.Env{status: 429, headers: [{"retry-after", "600"}]} + end) + end + + test "backs off from a host that rate limits host-meta" do + assert {:error, :ratelimit} = WebFinger.finger("alice@ratelimited.example") + assert_received {:request, "https://ratelimited.example/.well-known/host-meta"} + refute_received {:request, _} + + # a rate-limited host-meta lookup must not be cached as "no LRDD template" + assert {:ok, nil} = Cachex.get(:host_meta_cache, "ratelimited.example") + + assert {:error, :ratelimit} = WebFinger.finger("bob@ratelimited.example") + assert {:error, :ratelimit} = WebFinger.find_lrdd_template("ratelimited.example") + refute_received {:request, _} + end + end end diff --git a/test/pleroma/workers/receiver_worker_test.exs b/test/pleroma/workers/receiver_worker_test.exs index ea05f38f1..855ae3e2a 100644 --- a/test/pleroma/workers/receiver_worker_test.exs +++ b/test/pleroma/workers/receiver_worker_test.exs @@ -133,6 +133,28 @@ defmodule Pleroma.Workers.ReceiverWorkerTest do end end + describe "rate-limited fetches" do + setup do + Cachex.put(:http_backoff_cache, "ratelimited.example", true) + + params = + insert(:note_activity).data + |> Map.put("actor", "https://ratelimited.example/users/alice") + + %{args: %{"op" => "incoming_ap_doc", "params" => params}} + end + + test "snooze while the actor's host is backed off", %{args: args} do + assert {:snooze, 300} = perform_job(ReceiverWorker, args) + end + + test "stop snoozing two hours after the job was inserted", %{args: args} do + inserted_at = DateTime.add(DateTime.utc_now(), -121, :minute) + + assert {:error, :ratelimit} = perform_job(ReceiverWorker, args, inserted_at: inserted_at) + end + end + test "cancels due to origin containment" do params = insert(:note_activity).data diff --git a/test/pleroma/workers/remote_fetcher_worker_test.exs b/test/pleroma/workers/remote_fetcher_worker_test.exs index a3b900a9b..9945ca143 100644 --- a/test/pleroma/workers/remote_fetcher_worker_test.exs +++ b/test/pleroma/workers/remote_fetcher_worker_test.exs @@ -13,6 +13,7 @@ defmodule Pleroma.Workers.RemoteFetcherWorkerTest do @unauthorized_object "https://unauthorized.example.com/" @depth_object "https://depth.example.com/" @content_type_object "https://bad_content_type.example.com/" + @ratelimited_object "https://ratelimited.example.com/" describe "RemoteFetcherWorker" do setup do @@ -37,6 +38,11 @@ defmodule Pleroma.Workers.RemoteFetcherWorkerTest do status: 200 } + %{method: :get, url: @ratelimited_object} -> + %Tesla.Env{ + status: 429 + } + %{method: :get, url: @content_type_object} -> %Tesla.Env{ status: 200, @@ -73,6 +79,14 @@ defmodule Pleroma.Workers.RemoteFetcherWorkerTest do }) end + test "retries jobs for a rate-limited host" do + assert {:error, :ratelimit} = + perform_job(RemoteFetcherWorker, %{ + "op" => "fetch_remote", + "id" => @ratelimited_object + }) + end + test "does not retry jobs for when object returns wrong content type" do assert {:cancel, _} = perform_job(RemoteFetcherWorker, %{ diff --git a/test/pleroma/workers/signature_retry_worker_test.exs b/test/pleroma/workers/signature_retry_worker_test.exs index 3806ecac9..56835bbbc 100644 --- a/test/pleroma/workers/signature_retry_worker_test.exs +++ b/test/pleroma/workers/signature_retry_worker_test.exs @@ -306,6 +306,24 @@ defmodule Pleroma.Workers.SignatureRetryWorkerTest do refute Activity.get_by_ap_id(create["id"]) end + test "snoozes while the payload actor's host is backed off" do + Cachex.put(:http_backoff_cache, "ratelimited.example", true) + alice = build(:user, local: false, ap_id: "https://ratelimited.example/users/alice") + + params = %{ + "type" => "Create", + "actor" => alice.ap_id, + "id" => "https://ratelimited.example/activities/1", + "object" => "https://ratelimited.example/objects/1" + } + + job = failed_signature_job(params, signature_headers_for(alice)) + assert {:snooze, 300} = SignatureRetryWorker.perform(job) + + old_job = %{job | inserted_at: DateTime.add(DateTime.utc_now(), -121, :minute)} + assert {:error, :ratelimit} = SignatureRetryWorker.perform(old_job) + end + test "processes the activity after refetching a valid matching signature" do alice = insert(:user, local: false, ap_id: "https://one.com/users/alice") diff --git a/test/support/exif_helpers.ex b/test/support/exif_helpers.ex new file mode 100644 index 000000000..3a8a3b84e --- /dev/null +++ b/test/support/exif_helpers.ex @@ -0,0 +1,44 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2026 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Tests.ExifHelpers do + @moduledoc """ + Helpers for upload filter tests that use exiftool as an independent oracle. + """ + + @doc "Copies a fixture to a unique temporary file and returns an upload for it." + def upload_copy(fixture, content_type, attrs \\ []) do + source = Path.absname(Path.join("test/fixtures", fixture)) + + tempfile = + Path.join( + System.tmp_dir!(), + "pleroma_exif_#{System.unique_integer([:positive])}#{Path.extname(fixture)}" + ) + + File.cp!(source, tempfile) + ExUnit.Callbacks.on_exit(fn -> File.rm(tempfile) end) + + struct!( + %Pleroma.Upload{ + name: fixture, + content_type: content_type, + path: source, + tempfile: tempfile + }, + attrs + ) + end + + @doc "Returns the GPS tags exiftool finds in a file, as text." + def gps_tags(path), do: exiftool!(["-gps:all", path]) + + @doc "Returns the value of one tag as exiftool reads it." + def tag(path, tag), do: exiftool!(["-s3", "-#{tag}", path]) + + defp exiftool!(args) do + {output, 0} = System.cmd("exiftool", ["-m" | args]) + String.trim(output) + end +end diff --git a/tools/check-changelog b/tools/check-changelog index d09a68895..f2a81d427 100644 --- a/tools/check-changelog +++ b/tools/check-changelog @@ -6,7 +6,9 @@ git config --global --add safe.directory "$(pwd)" echo "looking for change log" git remote add upstream https://git.pleroma.social/pleroma/pleroma.git -git fetch upstream ${CI_COMMIT_TARGET_BRANCH}:refs/remotes/upstream/${CI_COMMIT_TARGET_BRANCH} +# Only the tip's trees are needed to list added files: no history, tags or file contents. +git fetch --depth=1 --no-tags --filter=blob:none upstream \ + ${CI_COMMIT_TARGET_BRANCH}:refs/remotes/upstream/${CI_COMMIT_TARGET_BRANCH} git diff --raw --no-renames upstream/${CI_COMMIT_TARGET_BRANCH} HEAD -- changelog.d | \ grep ' A\t' | grep '\.\(skip\|add\|remove\|fix\|security\|change\)$'