diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 47b66ae69..941514e18 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -16,9 +16,15 @@ workflow: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_BRANCH == "develop" - if: $CI_COMMIT_BRANCH == "stable" + - if: $CI_PIPELINE_SOURCE == "web" + - if: $CI_COMMIT_TAG - if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS when: never +# Default artifacts configuration +.default_artifacts: &default_artifacts + expire_in: 30 days + cache: &global_cache_policy key: $CI_JOB_IMAGE-$CI_COMMIT_SHORT_SHA paths: @@ -56,6 +62,7 @@ check-changelog: before_script: '' after_script: '' cache: {} + artifacts: *default_artifacts script: - apk add git - sh ./tools/check-changelog @@ -71,6 +78,7 @@ check-changelog: .using-ci-base: tags: - amd64 + artifacts: *default_artifacts build-1.15.8-otp-26: extends: @@ -101,8 +109,12 @@ spec-build: artifacts: paths: - spec.json + reports: + dotenv: build.env + expire_in: 42 years script: - mix pleroma.openapi_spec spec.json + - echo "SPEC_BUILD_JOB_ID=$CI_JOB_ID" >> build.env benchmark: extends: @@ -153,6 +165,7 @@ unit-testing-1.15.8-otp-26: - su testuser -c "HOME=/home/testuser mix pleroma.test_runner --cover --preload-modules" coverage: '/^Line total: ([^ ]*%)$/' artifacts: + expire_in: 30 days reports: coverage_report: coverage_format: cobertura @@ -171,6 +184,7 @@ unit-testing-1.18.3-otp-27: formatting-1.15: extends: .build_changes_policy + artifacts: *default_artifacts image: &formatting_elixir elixir:1.15-alpine stage: lint cache: *testing_cache_policy @@ -185,6 +199,7 @@ formatting-1.15: cycles-1.15: extends: .build_changes_policy + artifacts: *default_artifacts image: *formatting_elixir stage: lint cache: {} @@ -208,7 +223,7 @@ dialyzer: - .using-ci-base stage: lint allow_failure: true - when: manual + when: manual cache: *testing_cache_policy tags: - feld @@ -217,15 +232,13 @@ dialyzer: docs-deploy: stage: deploy - cache: *testing_cache_policy - image: alpine:latest + trigger: + project: pleroma/docs + branch: master + strategy: depend only: - stable@pleroma/pleroma - develop@pleroma/pleroma - before_script: - - apk add curl - script: - - curl --fail-with-body -X POST -F"token=$DOCS_PIPELINE_TRIGGER" -F'ref=master' -F"variables[BRANCH]=$CI_COMMIT_REF_NAME" https://git.pleroma.social/api/v4/projects/673/trigger/pipeline review_app: image: alpine:3.9 stage: deploy @@ -241,6 +254,7 @@ review_app: except: - master - develop + artifacts: *default_artifacts script: - echo "$CI_ENVIRONMENT_SLUG" - mkdir -p ~/.ssh @@ -257,21 +271,19 @@ review_app: spec-deploy: stage: deploy - artifacts: - paths: - - spec.json + trigger: + project: pleroma/api-docs + branch: master + strategy: depend only: - develop@pleroma/pleroma - image: alpine:latest - before_script: - - apk add curl - script: - - curl --fail-with-body -X POST -F"token=$API_DOCS_PIPELINE_TRIGGER" -F'ref=master' -F"variables[BRANCH]=$CI_COMMIT_REF_NAME" -F"variables[JOB_REF]=$CI_JOB_ID" https://git.pleroma.social/api/v4/projects/1130/trigger/pipeline - + variables: + SPEC_BUILD_JOB_ID: $SPEC_BUILD_JOB_ID stop_review_app: image: alpine:3.9 stage: deploy + artifacts: *default_artifacts before_script: - apk update && apk add openssh-client git when: manual diff --git a/changelog.d/authorized_fetch.fix b/changelog.d/authorized_fetch.fix new file mode 100644 index 000000000..1db8e88c9 --- /dev/null +++ b/changelog.d/authorized_fetch.fix @@ -0,0 +1 @@ +Fix fetching public keys with authorized fetch enabled \ No newline at end of file diff --git a/changelog.d/blocked-muted-swagger.change b/changelog.d/blocked-muted-swagger.change new file mode 100644 index 000000000..12bba8612 --- /dev/null +++ b/changelog.d/blocked-muted-swagger.change @@ -0,0 +1 @@ +Use separate schemas for muted/blocked accounts lists \ No newline at end of file diff --git a/changelog.d/changelog-checker.skip b/changelog.d/changelog-checker.skip new file mode 100644 index 000000000..e910a649f --- /dev/null +++ b/changelog.d/changelog-checker.skip @@ -0,0 +1 @@ +Fix CI changelog checker diff --git a/changelog.d/ci-artifacts.skip b/changelog.d/ci-artifacts.skip new file mode 100644 index 000000000..e69de29bb diff --git a/changelog.d/description.skip b/changelog.d/description.skip new file mode 100644 index 000000000..bbcfb2e13 --- /dev/null +++ b/changelog.d/description.skip @@ -0,0 +1 @@ +Use :list_behaviour_implementations for LanguageDetector and Translation providers diff --git a/changelog.d/endorsements-api.change b/changelog.d/endorsements-api.change new file mode 100644 index 000000000..279392c66 --- /dev/null +++ b/changelog.d/endorsements-api.change @@ -0,0 +1 @@ +Support new Mastodon API for endorsed accounts diff --git a/changelog.d/fediindex.change b/changelog.d/fediindex.change new file mode 100644 index 000000000..b9bef2762 --- /dev/null +++ b/changelog.d/fediindex.change @@ -0,0 +1 @@ +Allow FediIndex crawler bot by default \ No newline at end of file diff --git a/changelog.d/filter-user-capabilities.add b/changelog.d/filter-user-capabilities.add new file mode 100644 index 000000000..fe2459210 --- /dev/null +++ b/changelog.d/filter-user-capabilities.add @@ -0,0 +1 @@ +Allow filtering users with `accepts_chat_messages` capability \ No newline at end of file diff --git a/changelog.d/instance-view-timeline-access.add b/changelog.d/instance-view-timeline-access.add new file mode 100644 index 000000000..eb414e786 --- /dev/null +++ b/changelog.d/instance-view-timeline-access.add @@ -0,0 +1 @@ +Add `timelines_access` to InstanceView diff --git a/changelog.d/local-nickname-regex.fix b/changelog.d/local-nickname-regex.fix new file mode 100644 index 000000000..81ddd9cff --- /dev/null +++ b/changelog.d/local-nickname-regex.fix @@ -0,0 +1 @@ +Use end-of-string in regex for local `get_by_nickname` diff --git a/changelog.d/lookup-restrict-unauthenticated.fix b/changelog.d/lookup-restrict-unauthenticated.fix new file mode 100644 index 000000000..a062b9361 --- /dev/null +++ b/changelog.d/lookup-restrict-unauthenticated.fix @@ -0,0 +1 @@ +Respect restrict_unauthenticated in /api/v1/accounts/lookup diff --git a/changelog.d/mastodon-quotes-updates.change b/changelog.d/mastodon-quotes-updates.change new file mode 100644 index 000000000..4c01ec106 --- /dev/null +++ b/changelog.d/mastodon-quotes-updates.change @@ -0,0 +1 @@ +Use Mastodon-compatible route for quotes list and param for quotes count diff --git a/changelog.d/mrf-inlinequotes-mastodon.fix b/changelog.d/mrf-inlinequotes-mastodon.fix new file mode 100644 index 000000000..638b3fde3 --- /dev/null +++ b/changelog.d/mrf-inlinequotes-mastodon.fix @@ -0,0 +1 @@ +MRF InlineQuotePolicy: Don't inline quoted post URL in Mastodon quote posts diff --git a/changelog.d/nodeinfo-content-type.fix b/changelog.d/nodeinfo-content-type.fix new file mode 100644 index 000000000..255fab475 --- /dev/null +++ b/changelog.d/nodeinfo-content-type.fix @@ -0,0 +1 @@ +Fix NodeInfo content-type diff --git a/changelog.d/normalize-actor-image-hrefs.fix b/changelog.d/normalize-actor-image-hrefs.fix new file mode 100644 index 000000000..33d222391 --- /dev/null +++ b/changelog.d/normalize-actor-image-hrefs.fix @@ -0,0 +1 @@ +Add Actor images normalization from array of urls to string diff --git a/changelog.d/notification-cleanup.skip b/changelog.d/notification-cleanup.skip new file mode 100644 index 000000000..e69de29bb diff --git a/changelog.d/notification-view-deduplicate.skip b/changelog.d/notification-view-deduplicate.skip new file mode 100644 index 000000000..769352692 --- /dev/null +++ b/changelog.d/notification-view-deduplicate.skip @@ -0,0 +1 @@ +remove duplicated code from notificationview diff --git a/changelog.d/order-favourites-reblogs.change b/changelog.d/order-favourites-reblogs.change new file mode 100644 index 000000000..67c235d62 --- /dev/null +++ b/changelog.d/order-favourites-reblogs.change @@ -0,0 +1 @@ +Order favourites and reblogs list from newest to oldest diff --git a/changelog.d/outgoing-follow-requests.add b/changelog.d/outgoing-follow-requests.add new file mode 100644 index 000000000..a898bcf6e --- /dev/null +++ b/changelog.d/outgoing-follow-requests.add @@ -0,0 +1 @@ +Add /api/v1/pleroma/outgoing_follow_requests diff --git a/changelog.d/pin-chats.fix b/changelog.d/pin-chats.fix new file mode 100644 index 000000000..e7520ceaf --- /dev/null +++ b/changelog.d/pin-chats.fix @@ -0,0 +1 @@ +Allow to pin/unpip chats diff --git a/changelog.d/plaroma.skip b/changelog.d/plaroma.skip new file mode 100644 index 000000000..184ca07e0 --- /dev/null +++ b/changelog.d/plaroma.skip @@ -0,0 +1 @@ +i don't think it's called plaroma \ No newline at end of file diff --git a/changelog.d/preferred-frontend.add b/changelog.d/preferred-frontend.add new file mode 100644 index 000000000..145e9451b --- /dev/null +++ b/changelog.d/preferred-frontend.add @@ -0,0 +1 @@ +Allow users to select preferred frontend diff --git a/changelog.d/remote-url.fix b/changelog.d/remote-url.fix new file mode 100644 index 000000000..9be84a878 --- /dev/null +++ b/changelog.d/remote-url.fix @@ -0,0 +1 @@ +`remote_url` links to unproxied URL diff --git a/changelog.d/rich-media-user-agent.add b/changelog.d/rich-media-user-agent.add new file mode 100644 index 000000000..5c1e2b134 --- /dev/null +++ b/changelog.d/rich-media-user-agent.add @@ -0,0 +1 @@ +Allow setting custom user-agent for fetching rich media content diff --git a/changelog.d/rss-redirect.change b/changelog.d/rss-redirect.change new file mode 100644 index 000000000..cd8b099aa --- /dev/null +++ b/changelog.d/rss-redirect.change @@ -0,0 +1 @@ +Redirect /users/:nickname.rss to /users/:nickname/feed.rss instead of .atom \ No newline at end of file diff --git a/changelog.d/scrobbles-scope.change b/changelog.d/scrobbles-scope.change new file mode 100644 index 000000000..3c31eadcc --- /dev/null +++ b/changelog.d/scrobbles-scope.change @@ -0,0 +1 @@ +Add `write:scrobbles` and `read:scrobbles` scope for scrobbling diff --git a/changelog.d/scrubber-inline-quotes-mastodon.add b/changelog.d/scrubber-inline-quotes-mastodon.add new file mode 100644 index 000000000..a8006e423 --- /dev/null +++ b/changelog.d/scrubber-inline-quotes-mastodon.add @@ -0,0 +1 @@ +Scrubber: Allow `quote-inline` class in

tags used by Mastodon quotes \ No newline at end of file diff --git a/changelog.d/scrubber-span-classes.change b/changelog.d/scrubber-span-classes.change new file mode 100644 index 000000000..4ba5dfa91 --- /dev/null +++ b/changelog.d/scrubber-span-classes.change @@ -0,0 +1 @@ +Allow "invisible" and "ellipsis" classes for span tags to match Mastodon behavior diff --git a/changelog.d/status-push-notification.fix b/changelog.d/status-push-notification.fix new file mode 100644 index 000000000..ed0bbff33 --- /dev/null +++ b/changelog.d/status-push-notification.fix @@ -0,0 +1 @@ +Send push notifications for statuses from subscribed accounts diff --git a/changelog.d/stream-marker-updates.add b/changelog.d/stream-marker-updates.add new file mode 100644 index 000000000..e9fda3e59 --- /dev/null +++ b/changelog.d/stream-marker-updates.add @@ -0,0 +1 @@ +Stream marker updates diff --git a/changelog.d/translation-provider-mozhi.add b/changelog.d/translation-provider-mozhi.add new file mode 100644 index 000000000..c3cf5940a --- /dev/null +++ b/changelog.d/translation-provider-mozhi.add @@ -0,0 +1 @@ +Support Mozhi translation provider diff --git a/changelog.d/translation-provider-translatelocally.add b/changelog.d/translation-provider-translatelocally.add new file mode 100644 index 000000000..635e80061 --- /dev/null +++ b/changelog.d/translation-provider-translatelocally.add @@ -0,0 +1 @@ +Support translateLocally translation provider diff --git a/changelog.d/url-encoding-pt2.fix b/changelog.d/url-encoding-pt2.fix new file mode 100644 index 000000000..bc6857e02 --- /dev/null +++ b/changelog.d/url-encoding-pt2.fix @@ -0,0 +1 @@ +Fix sometimes incorrect URI percent encoding diff --git a/config/description.exs b/config/description.exs index c61a344e8..c388d17c3 100644 --- a/config/description.exs +++ b/config/description.exs @@ -2131,6 +2131,11 @@ config :pleroma, :config_description, [ description: "Amount of milliseconds after which the HTTP request is forcibly terminated.", suggestions: [5_000] + }, + %{ + key: :user_agent, + type: :string, + description: "Custom User-Agent header to be used when fetching rich media content." } ] }, @@ -3328,6 +3333,12 @@ config :pleroma, :config_description, [ description: "A map containing available frontends and parameters for their installation.", children: frontend_options + }, + %{ + key: :pickable, + type: {:list, :string}, + description: + "A list containing all frontends users can pick as their preference, format is :name/:ref, e.g pleroma-fe/stable." } ] }, @@ -3534,9 +3545,7 @@ config :pleroma, :config_description, [ %{ key: :provider, type: :module, - suggestions: [ - Pleroma.Language.LanguageDetector.Fasttext - ] + suggestions: {:list_behaviour_implementations, Pleroma.Language.LanguageDetector.Provider} }, %{ group: {:subgroup, Pleroma.Language.LanguageDetector.Fasttext}, @@ -3556,10 +3565,7 @@ config :pleroma, :config_description, [ %{ key: :provider, type: :module, - suggestions: [ - Pleroma.Language.Translation.Deepl, - Pleroma.Language.Translation.Libretranslate - ] + suggestions: {:list_behaviour_implementations, Pleroma.Language.Translation.Provider} }, %{ group: {:subgroup, Pleroma.Language.Translation.Deepl}, @@ -3588,6 +3594,27 @@ config :pleroma, :config_description, [ label: "LibreTranslate API Key", type: :string, suggestions: ["YOUR_API_KEY"] + }, + %{ + group: {:subgroup, Pleroma.Language.Translation.TranslateLocally}, + key: :intermediary_language, + label: + "translateLocally intermediary language (used when direct source->target model is not available)", + type: :string, + suggestions: ["en"] + }, + %{ + group: {:subgroup, Pleroma.Language.Translation.Mozhi}, + key: :base_url, + label: "Mozhi instance URL", + type: :string + }, + %{ + group: {:subgroup, Pleroma.Language.Translation.Mozhi}, + key: :engine, + label: "Engine used for Mozhi", + type: :string, + suggestions: ["libretranslate"] } ] } diff --git a/docs/development/API/chats.md b/docs/development/API/chats.md index f50144c86..ee2169adf 100644 --- a/docs/development/API/chats.md +++ b/docs/development/API/chats.md @@ -66,9 +66,9 @@ Returned data: "username": "somenick", ... }, - "id" : "1", - "unread" : 2, - "last_message" : {...}, // The last message in that chat + "id": "1", + "unread": 2, + "last_message": {...}, // The last message in that chat "updated_at": "2020-04-21T15:11:46.000Z" } ``` @@ -93,8 +93,8 @@ Returned data: "username": "somenick", ... }, - "id" : "1", - "unread" : 0, + "id": "1", + "unread": 0, "updated_at": "2020-04-21T15:11:46.000Z" } ``` @@ -111,7 +111,7 @@ The modified chat message ### Getting a list of Chats -`GET /api/v1/pleroma/chats` +`GET /api/v2/pleroma/chats` This will return a list of chats that you have been involved in, sorted by their last update (so new chats will be at the top). @@ -119,6 +119,7 @@ last update (so new chats will be at the top). Parameters: - with_muted: Include chats from muted users (boolean). +- pinned: Include only pinned chats (boolean). Returned data: @@ -130,16 +131,16 @@ Returned data: "username": "somenick", ... }, - "id" : "1", - "unread" : 2, - "last_message" : {...}, // The last message in that chat + "id": "1", + "unread": 2, + "last_message": {...}, // The last message in that chat "updated_at": "2020-04-21T15:11:46.000Z" } ] ``` The recipient of messages that are sent to this chat is given by their AP ID. -No pagination is implemented for now. +The usual pagination options are implemented. ### Getting the messages for a Chat @@ -226,6 +227,32 @@ Deleting a chat message for given Chat id works like this: Returned data is the deleted message. +### Pinning a chat + +Pinning a chat works like this: + +`POST /api/v1/pleroma/chats/:id/pin` + +Returned data: + +```json +{ + "account": { + "id": "someflakeid", + "username": "somenick", + ... + }, + "id": "1", + "unread": 0, + "updated_at": "2020-04-21T15:11:46.000Z", + "pinned": true, +} +``` + +To unpin a pinned chat, use: + +`POST /api/v1/pleroma/chats/:id/unpin` + ### Notifications There's a new `pleroma:chat_mention` notification, which has this form. It is not given out in the notifications endpoint by default, you need to explicitly request it with `include_types[]=pleroma:chat_mention`: diff --git a/docs/development/API/differences_in_mastoapi_responses.md b/docs/development/API/differences_in_mastoapi_responses.md index fa7e3e5ab..052b2716b 100644 --- a/docs/development/API/differences_in_mastoapi_responses.md +++ b/docs/development/API/differences_in_mastoapi_responses.md @@ -39,7 +39,6 @@ Has these additional fields under the `pleroma` object: - `emoji_reactions`: A list with emoji / reaction maps. The format is `{name: "☕", count: 1, me: true}`. Contains no information about the reacting users, for that use the `/statuses/:id/reactions` endpoint. - `parent_visible`: If the parent of this post is visible to the user or not. - `pinned_at`: a datetime (iso8601) when status was pinned, `null` otherwise. -- `quotes_count`: the count of status quotes. - `bookmark_folder`: the ID of the folder bookmark is stored within (if any). - `list_id`: the ID of the list the post is addressed to (if any, only returned to author). diff --git a/docs/development/API/pleroma_api.md b/docs/development/API/pleroma_api.md index b17f61cbb..7946ba1f6 100644 --- a/docs/development/API/pleroma_api.md +++ b/docs/development/API/pleroma_api.md @@ -684,6 +684,7 @@ Audio scrobbling in Pleroma is **deprecated**. ### Creates a new Listen activity for an account * Method `POST` * Authentication: required +* OAuth scope: `write:scrobbles` * Params: * `title`: the title of the media playing * `album`: the album of the media playing [optional] diff --git a/docs/installation/optional/media_graphics_packages.md b/docs/installation/optional/media_graphics_packages.md index ad01d47d1..89125d1c6 100644 --- a/docs/installation/optional/media_graphics_packages.md +++ b/docs/installation/optional/media_graphics_packages.md @@ -16,7 +16,7 @@ Note: the packages are not required with the current default settings of Pleroma It is required for the following Pleroma features: -* `Pleroma.Upload.Filters.Mogrify`, `Pleroma.Upload.Filters.Mogrifun` upload filters (related config: `Plaroma.Upload/filters` in `config/config.exs`) +* `Pleroma.Upload.Filters.Mogrify`, `Pleroma.Upload.Filters.Mogrifun` upload filters (related config: `Pleroma.Upload/filters` in `config/config.exs`) * Media preview proxy for still images (related config: `media_preview_proxy/enabled` in `config/config.exs`) ## `ffmpeg` @@ -33,5 +33,5 @@ It is required for the following Pleroma features: It is required for the following Pleroma features: -* `Pleroma.Upload.Filters.Exiftool.StripLocation` upload filter (related config: `Plaroma.Upload/filters` in `config/config.exs`) -* `Pleroma.Upload.Filters.Exiftool.ReadDescription` upload filter (related config: `Plaroma.Upload/filters` in `config/config.exs`) +* `Pleroma.Upload.Filters.Exiftool.StripLocation` upload filter (related config: `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/lib/pleroma/chat.ex b/lib/pleroma/chat.ex index 5c4dbc1ff..926176bfa 100644 --- a/lib/pleroma/chat.ex +++ b/lib/pleroma/chat.ex @@ -25,6 +25,8 @@ defmodule Pleroma.Chat do belongs_to(:user, User, type: FlakeId.Ecto.CompatType) field(:recipient, :string) + field(:pinned, :boolean) + timestamps() end @@ -94,4 +96,16 @@ defmodule Pleroma.Chat do order_by: [desc: c.updated_at] ) end + + def pin(%__MODULE__{} = chat) do + chat + |> cast(%{pinned: true}, [:pinned]) + |> Repo.update() + end + + def unpin(%__MODULE__{} = chat) do + chat + |> cast(%{pinned: false}, [:pinned]) + |> Repo.update() + end end diff --git a/lib/pleroma/constants.ex b/lib/pleroma/constants.ex index 92ca11494..f02607273 100644 --- a/lib/pleroma/constants.ex +++ b/lib/pleroma/constants.ex @@ -132,6 +132,13 @@ defmodule Pleroma.Constants do do: ~r/^[^[:cntrl:] ()<>@,;:\\"\/\[\]?=]+\/[^[:cntrl:] ()<>@,;:\\"\/\[\]?=]+(; .*)?$/ ) + # List of allowed chars in the path segment of a URI + # unreserved, sub-delims, ":", "@" and "/" allowed as the separator in path + # https://datatracker.ietf.org/doc/html/rfc3986 + const(uri_path_allowed_reserved_chars, + do: ~c"!$&'()*+,;=/:@" + ) + const(upload_object_types, do: ["Document", "Image"]) const(activity_json_canonical_mime_type, diff --git a/lib/pleroma/following_relationship.ex b/lib/pleroma/following_relationship.ex index 495488dfd..653feb32f 100644 --- a/lib/pleroma/following_relationship.ex +++ b/lib/pleroma/following_relationship.ex @@ -157,6 +157,16 @@ defmodule Pleroma.FollowingRelationship do |> Repo.all() end + def get_outgoing_follow_requests(%User{id: id}) do + __MODULE__ + |> join(:inner, [r], f in assoc(r, :following)) + |> where([r], r.state == ^:follow_pending) + |> where([r], r.follower_id == ^id) + |> where([r, f], f.is_active == true) + |> select([r, f], f) + |> Repo.all() + end + def following?(%User{id: follower_id}, %User{id: followed_id}) do __MODULE__ |> where(follower_id: ^follower_id, following_id: ^followed_id, state: ^:follow_accept) diff --git a/lib/pleroma/http.ex b/lib/pleroma/http.ex index 9a0868d33..bdeb2171e 100644 --- a/lib/pleroma/http.ex +++ b/lib/pleroma/http.ex @@ -131,31 +131,4 @@ defmodule Pleroma.HTTP do defp default_middleware, do: [Tesla.Middleware.FollowRedirects, Pleroma.Tesla.Middleware.EncodeUrl] - - def encode_url(url) when is_binary(url) do - URI.parse(url) - |> then(fn parsed -> - path = encode_path(parsed.path) - query = encode_query(parsed.query) - - %{parsed | path: path, query: query} - end) - |> URI.to_string() - end - - defp encode_path(nil), do: nil - - defp encode_path(path) when is_binary(path) do - path - |> URI.decode() - |> URI.encode() - end - - defp encode_query(nil), do: nil - - defp encode_query(query) when is_binary(query) do - query - |> URI.decode_query() - |> URI.encode_query() - end end diff --git a/lib/pleroma/http/adapter_helper/hackney.ex b/lib/pleroma/http/adapter_helper/hackney.ex index f3be1f3d0..f3451cf9c 100644 --- a/lib/pleroma/http/adapter_helper/hackney.ex +++ b/lib/pleroma/http/adapter_helper/hackney.ex @@ -16,7 +16,12 @@ defmodule Pleroma.HTTP.AdapterHelper.Hackney do config_opts = Pleroma.Config.get([:http, :adapter], []) + url_encoding = + Keyword.new() + |> Keyword.put(:path_encode_fun, fn path -> path end) + @defaults + |> Keyword.merge(url_encoding) |> Keyword.merge(config_opts) |> Keyword.merge(connection_opts) |> add_scheme_opts(uri) diff --git a/lib/pleroma/language/translation/mozhi.ex b/lib/pleroma/language/translation/mozhi.ex new file mode 100644 index 000000000..958f2ef57 --- /dev/null +++ b/lib/pleroma/language/translation/mozhi.ex @@ -0,0 +1,109 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2024 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Language.Translation.Mozhi do + import Pleroma.Web.Utils.Guards, only: [not_empty_string: 1] + + alias Pleroma.Language.Translation.Provider + + use Provider + + @behaviour Provider + + @name "Mozhi" + + @impl Provider + def configured?, do: not_empty_string(base_url()) and not_empty_string(engine()) + + @impl Provider + def translate(content, source_language, target_language) do + endpoint = + base_url() + |> URI.merge("/api/translate") + |> URI.to_string() + + case Pleroma.HTTP.get( + endpoint <> + "?" <> + URI.encode_query(%{ + engine: engine(), + text: content, + from: source_language, + to: target_language + }), + [{"Accept", "application/json"}] + ) do + {:ok, %{status: 200} = res} -> + %{ + "translated-text" => content, + "source_language" => source_language + } = Jason.decode!(res.body) + + {:ok, + %{ + content: content, + detected_source_language: source_language, + provider: @name + }} + + _ -> + {:error, :internal_server_error} + end + end + + @impl Provider + def supported_languages(type) when type in [:source, :target] do + path = + case type do + :source -> "/api/source_languages" + :target -> "/api/target_languages" + end + + endpoint = + base_url() + |> URI.merge(path) + |> URI.to_string() + + case Pleroma.HTTP.get( + endpoint <> + "?" <> + URI.encode_query(%{ + engine: engine() + }), + [{"Accept", "application/json"}] + ) do + {:ok, %{status: 200} = res} -> + languages = + Jason.decode!(res.body) + |> Enum.map(fn %{"Id" => language} -> language end) + + {:ok, languages} + + _ -> + {:error, :internal_server_error} + end + end + + @impl Provider + def languages_matrix do + with {:ok, source_languages} <- supported_languages(:source), + {:ok, target_languages} <- supported_languages(:target) do + {:ok, + Map.new(source_languages, fn language -> {language, target_languages -- [language]} end)} + else + {:error, error} -> {:error, error} + end + end + + @impl Provider + def name, do: @name + + defp base_url do + Pleroma.Config.get([__MODULE__, :base_url]) + end + + defp engine do + Pleroma.Config.get([__MODULE__, :engine]) + end +end diff --git a/lib/pleroma/language/translation/translate_locally.ex b/lib/pleroma/language/translation/translate_locally.ex new file mode 100644 index 000000000..7eaa95e7b --- /dev/null +++ b/lib/pleroma/language/translation/translate_locally.ex @@ -0,0 +1,129 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2024 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Language.Translation.TranslateLocally do + alias Pleroma.Language.Translation.Provider + + use Provider + + @behaviour Provider + + @name "translateLocally" + + @impl Provider + def missing_dependencies do + if Pleroma.Utils.command_available?("translateLocally") do + [] + else + ["translateLocally"] + end + end + + @impl Provider + def configured?, do: is_map(models()) + + @impl Provider + def translate(content, source_language, target_language) do + model = + models() + |> Map.get(source_language, %{}) + |> Map.get(target_language) + + models = + if model do + [model] + else + [ + models() + |> Map.get(source_language, %{}) + |> Map.get(intermediary_language()), + models() + |> Map.get(intermediary_language(), %{}) + |> Map.get(target_language) + ] + end + + translated_content = + Enum.reduce(models, content, fn model, content -> + text_path = Path.join(System.tmp_dir!(), "translateLocally-#{Ecto.UUID.generate()}") + + File.write(text_path, content) + + translated_content = + case System.cmd("translateLocally", ["-m", model, "-i", text_path, "--html"]) do + {content, _} -> content + _ -> nil + end + + File.rm(text_path) + + translated_content + end) + + {:ok, + %{ + content: translated_content, + detected_source_language: source_language, + provider: @name + }} + end + + @impl Provider + def supported_languages(:source) do + languages = + languages_matrix() + |> elem(1) + |> Map.keys() + + {:ok, languages} + end + + @impl Provider + def supported_languages(:target) do + languages = + languages_matrix() + |> elem(1) + |> Map.values() + |> List.flatten() + |> Enum.uniq() + + {:ok, languages} + end + + @impl Provider + def languages_matrix do + languages = + models() + |> Map.to_list() + |> Enum.map(fn {key, value} -> {key, Map.keys(value)} end) + |> Enum.into(%{}) + + matrix = + if intermediary_language() do + languages + |> Map.to_list() + |> Enum.map(fn {key, value} -> + with_intermediary = + (((value ++ languages[intermediary_language()]) + |> Enum.uniq()) -- + [key]) + |> Enum.sort() + + {key, with_intermediary} + end) + |> Enum.into(%{}) + else + languages + end + + {:ok, matrix} + end + + @impl Provider + def name, do: @name + + defp models, do: Pleroma.Config.get([__MODULE__, :models]) + + defp intermediary_language, do: Pleroma.Config.get([__MODULE__, :intermediary_language]) +end diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 80844ed71..b8693c3a8 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -282,10 +282,15 @@ defmodule Pleroma.Notification do select: n.id ) - Multi.new() - |> Multi.update_all(:ids, query, set: [seen: true, updated_at: NaiveDateTime.utc_now()]) - |> Marker.multi_set_last_read_id(user, "notifications") - |> Repo.transaction() + {:ok, %{marker: marker}} = + Multi.new() + |> Multi.update_all(:ids, query, set: [seen: true, updated_at: NaiveDateTime.utc_now()]) + |> Marker.multi_set_last_read_id(user, "notifications") + |> Repo.transaction() + + Streamer.stream(["user", "user:notification"], marker) + + {:ok, %{marker: marker}} end @spec read_one(User.t(), String.t()) :: @@ -526,9 +531,7 @@ defmodule Pleroma.Notification do %Activity{data: %{"type" => "Create"}} = activity, local_only ) do - notification_enabled_ap_ids = - [] - |> Utils.maybe_notify_subscribers(activity) + notification_enabled_ap_ids = Utils.get_notified_subscribers(activity) potential_receivers = User.get_users_from_set(notification_enabled_ap_ids, local_only: local_only) diff --git a/lib/pleroma/reverse_proxy.ex b/lib/pleroma/reverse_proxy.ex index cd58f29e4..bb55a4984 100644 --- a/lib/pleroma/reverse_proxy.ex +++ b/lib/pleroma/reverse_proxy.ex @@ -3,6 +3,8 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.ReverseProxy do + alias Pleroma.Utils.URIEncoding + @range_headers ~w(range if-range) @keep_req_headers ~w(accept accept-encoding cache-control if-modified-since) ++ ~w(if-unmodified-since if-none-match) ++ @range_headers @@ -155,11 +157,12 @@ defmodule Pleroma.ReverseProxy do end defp request(method, url, headers, opts) do - Logger.debug("#{__MODULE__} #{method} #{url} #{inspect(headers)}") method = method |> String.downcase() |> String.to_existing_atom() url = maybe_encode_url(url) + Logger.debug("#{__MODULE__} #{method} #{url} #{inspect(headers)}") + case client().request(method, url, headers, "", opts) do {:ok, code, headers, client} when code in @valid_resp_codes -> {:ok, code, downcase_headers(headers), client} @@ -459,9 +462,9 @@ defmodule Pleroma.ReverseProxy do # Also do it for test environment defp maybe_encode_url(url) do case Application.get_env(:tesla, :adapter) do - Tesla.Adapter.Hackney -> Pleroma.HTTP.encode_url(url) - {Tesla.Adapter.Finch, _} -> Pleroma.HTTP.encode_url(url) - Tesla.Mock -> Pleroma.HTTP.encode_url(url) + Tesla.Adapter.Hackney -> URIEncoding.encode_url(url) + {Tesla.Adapter.Finch, _} -> URIEncoding.encode_url(url) + Tesla.Mock -> URIEncoding.encode_url(url) _ -> url end end diff --git a/lib/pleroma/reverse_proxy/client/hackney.ex b/lib/pleroma/reverse_proxy/client/hackney.ex index d3e986912..0aa5f5715 100644 --- a/lib/pleroma/reverse_proxy/client/hackney.ex +++ b/lib/pleroma/reverse_proxy/client/hackney.ex @@ -7,6 +7,11 @@ defmodule Pleroma.ReverseProxy.Client.Hackney do @impl true def request(method, url, headers, body, opts \\ []) do + opts = + Keyword.put_new(opts, :path_encode_fun, fn path -> + path + end) + :hackney.request(method, url, headers, body, opts) end diff --git a/lib/pleroma/signature.ex b/lib/pleroma/signature.ex index 47d9d46f6..fca61799b 100644 --- a/lib/pleroma/signature.ex +++ b/lib/pleroma/signature.ex @@ -54,7 +54,7 @@ defmodule Pleroma.Signature do def fetch_public_key(conn) do with {:ok, actor_id} <- get_actor_id(conn), - {:ok, public_key} <- User.get_public_key_for_ap_id(actor_id) do + {:ok, public_key} <- User.get_or_fetch_public_key_for_ap_id(actor_id) do {:ok, public_key} else e -> diff --git a/lib/pleroma/tesla/middleware/encode_url.ex b/lib/pleroma/tesla/middleware/encode_url.ex index 32c559d3b..677577c2f 100644 --- a/lib/pleroma/tesla/middleware/encode_url.ex +++ b/lib/pleroma/tesla/middleware/encode_url.ex @@ -17,7 +17,7 @@ defmodule Pleroma.Tesla.Middleware.EncodeUrl do @impl Tesla.Middleware def call(%Tesla.Env{url: url} = env, next, _) do - url = Pleroma.HTTP.encode_url(url) + url = Pleroma.Utils.URIEncoding.encode_url(url) env = %{env | url: url} diff --git a/lib/pleroma/upload.ex b/lib/pleroma/upload.ex index b0aef2592..06d8005bc 100644 --- a/lib/pleroma/upload.ex +++ b/lib/pleroma/upload.ex @@ -35,6 +35,7 @@ defmodule Pleroma.Upload do """ alias Ecto.UUID alias Pleroma.Maps + alias Pleroma.Utils.URIEncoding alias Pleroma.Web.ActivityPub.Utils require Logger @@ -230,11 +231,18 @@ defmodule Pleroma.Upload do tmp_path end + # Encoding the whole path here is fine since the path is in a + # UUID/ form. + # The file at this point isn't %-encoded, so the path shouldn't + # be decoded first like Pleroma.Utils.URIEncoding.encode_url/1 does. defp url_from_spec(%__MODULE__{name: name}, base_url, {:file, path}) do + encode_opts = [bypass_decode: true, bypass_parse: true] + path = - URI.encode(path, &char_unescaped?/1) <> + URIEncoding.encode_url(path, encode_opts) <> if Pleroma.Config.get([__MODULE__, :link_name], false) do - "?name=#{URI.encode(name, &char_unescaped?/1)}" + enum = %{name: name} + "?#{URI.encode_query(enum)}" else "" end diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 468e124b5..904e9e056 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -233,8 +233,8 @@ defmodule Pleroma.User do for {_relationship_type, [{_outgoing_relation, outgoing_relation_target}, _]} <- @user_relationships_config do # `def blocked_users_relation/2`, `def muted_users_relation/2`, - # `def reblog_muted_users_relation/2`, `def notification_muted_users/2`, - # `def subscriber_users/2`, `def endorsed_users_relation/2` + # `def reblog_muted_users_relation/2`, `def notification_muted_users_relation/2`, + # `def subscriber_users_relation/2`, `def endorsed_users_relation/2` def unquote(:"#{outgoing_relation_target}_relation")(user, restrict_deactivated? \\ false) do target_users_query = assoc(user, unquote(outgoing_relation_target)) @@ -288,6 +288,7 @@ defmodule Pleroma.User do defdelegate following?(follower, followed), to: FollowingRelationship defdelegate following_ap_ids(user), to: FollowingRelationship defdelegate get_follow_requests(user), to: FollowingRelationship + defdelegate get_outgoing_follow_requests(user), to: FollowingRelationship defdelegate search(query, opts \\ []), to: User.Search @doc """ @@ -1357,7 +1358,7 @@ defmodule Pleroma.User do @spec get_by_nickname(String.t()) :: User.t() | nil def get_by_nickname(nickname) do Repo.get_by(User, nickname: nickname) || - if Regex.match?(~r(@#{Pleroma.Web.Endpoint.host()})i, nickname) do + if Regex.match?(~r(@#{Pleroma.Web.Endpoint.host()}$)i, nickname) do Repo.get_by(User, nickname: local_nickname(nickname)) end end @@ -2307,6 +2308,15 @@ defmodule Pleroma.User do def public_key(_), do: {:error, "key not found"} + def get_or_fetch_public_key_for_ap_id(ap_id) do + with {:ok, %User{} = user} <- get_or_fetch_by_ap_id(ap_id), + {:ok, public_key} <- public_key(user) do + {:ok, public_key} + else + _ -> :error + end + end + def get_public_key_for_ap_id(ap_id) do with %User{} = user <- get_cached_by_ap_id(ap_id), {:ok, public_key} <- public_key(user) do diff --git a/lib/pleroma/user/search.ex b/lib/pleroma/user/search.ex index a7fb8fb83..851745714 100644 --- a/lib/pleroma/user/search.ex +++ b/lib/pleroma/user/search.ex @@ -16,6 +16,7 @@ defmodule Pleroma.User.Search do following = Keyword.get(opts, :following, false) result_limit = Keyword.get(opts, :limit, @limit) offset = Keyword.get(opts, :offset, 0) + capabilities = Keyword.get(opts, :capabilities, []) for_user = Keyword.get(opts, :for_user) @@ -32,7 +33,7 @@ defmodule Pleroma.User.Search do results = query_string - |> search_query(for_user, following, top_user_ids) + |> search_query(for_user, following, top_user_ids, capabilities) |> Pagination.fetch_paginated(%{"offset" => offset, "limit" => result_limit}, :offset) results @@ -80,7 +81,7 @@ defmodule Pleroma.User.Search do end end - defp search_query(query_string, for_user, following, top_user_ids) do + defp search_query(query_string, for_user, following, top_user_ids, capabilities) do for_user |> base_query(following) |> filter_blocked_user(for_user) @@ -94,6 +95,7 @@ defmodule Pleroma.User.Search do |> subquery() |> order_by(desc: :search_rank) |> maybe_restrict_local(for_user) + |> maybe_restrict_accepting_chat_messages(capabilities) |> filter_deactivated_users() end @@ -214,6 +216,14 @@ defmodule Pleroma.User.Search do end end + defp maybe_restrict_accepting_chat_messages(query, capabilities) do + if "accepts_chat_messages" in capabilities do + from(q in query, where: q.accepts_chat_messages == true) + else + query + end + end + defp limit, do: Pleroma.Config.get([:instance, :limit_to_local_content], :unauthenticated) defp restrict_local(q), do: where(q, [u], u.local == true) diff --git a/lib/pleroma/utils/uri_encoding.ex b/lib/pleroma/utils/uri_encoding.ex new file mode 100644 index 000000000..dc6b387fa --- /dev/null +++ b/lib/pleroma/utils/uri_encoding.ex @@ -0,0 +1,142 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2025 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Utils.URIEncoding do + @moduledoc """ + Utility functions for dealing with URI encoding of paths and queries + with support for query-encoding quirks. + """ + require Pleroma.Constants + + # We don't always want to decode the path first, like is the case in + # Pleroma.Upload.url_from_spec/3. + @doc """ + Wraps URI encoding/decoding functions from Elixir's standard library to fix usually unintended side-effects. + + Supports two URL processing options in the optional 2nd argument with the default being `false`: + + * `bypass_parse` - Bypasses `URI.parse` stage, useful when it's not desirable to parse to URL first + before encoding it. Supports only encoding as the Path segment of a URI. + * `bypass_decode` - Bypasses `URI.decode` stage for the Path segment of a URI. Used when a URL + has to be double %-encoded for internal reasons. + + Options must be specified as a Keyword with tuples with booleans, otherwise + `{:error, :invalid_opts}` is returned. Example: + `encode_url(url, [bypass_parse: true, bypass_decode: true])` + """ + @spec encode_url(String.t(), Keyword.t()) :: String.t() | {:error, :invalid_opts} + def encode_url(url, opts \\ []) when is_binary(url) and is_list(opts) do + bypass_parse = Keyword.get(opts, :bypass_parse, false) + bypass_decode = Keyword.get(opts, :bypass_decode, false) + + with true <- is_boolean(bypass_parse), + true <- is_boolean(bypass_decode) do + cond do + bypass_parse -> + encode_path(url, bypass_decode) + + true -> + URI.parse(url) + |> then(fn parsed -> + path = encode_path(parsed.path, bypass_decode) + + query = encode_query(parsed.query, parsed.host) + + %{parsed | path: path, query: query} + end) + |> URI.to_string() + end + else + _ -> {:error, :invalid_opts} + end + end + + defp encode_path(nil, _bypass_decode), do: nil + + # URI.encode/2 deliberately does not encode all chars that are forbidden + # in the path component of a URI. It only encodes chars that are forbidden + # in the whole URI. A predicate in the 2nd argument is used to fix that here. + # URI.encode/2 uses the predicate function to determine whether each byte + # (in an integer representation) should be encoded or not. + defp encode_path(path, bypass_decode) when is_binary(path) do + path = + cond do + bypass_decode -> + path + + true -> + URI.decode(path) + end + + path + |> URI.encode(fn byte -> + URI.char_unreserved?(byte) || + Enum.any?( + Pleroma.Constants.uri_path_allowed_reserved_chars(), + fn char -> + char == byte + end + ) + end) + end + + # Order of kv pairs in query is not preserved when using URI.decode_query. + # URI.query_decoder/2 returns a stream which so far appears to not change order. + # Immediately switch to a list to prevent breakage for sites that expect + # the order of query keys to be always the same. + defp encode_query(query, host) when is_binary(query) do + query + |> URI.query_decoder() + |> Enum.to_list() + |> do_encode_query(host) + end + + defp encode_query(nil, _), do: nil + + # Always uses www_form encoding. + # Taken from Elixir's URI module. + defp do_encode_query(enumerable, host) do + Enum.map_join(enumerable, "&", &maybe_apply_query_quirk(&1, host)) + end + + # https://git.pleroma.social/pleroma/pleroma/-/issues/1055 + defp maybe_apply_query_quirk({key, value}, "i.guim.co.uk" = _host) do + case key do + "precrop" -> + query_encode_kv_pair({key, value}, ~c":,") + + key -> + query_encode_kv_pair({key, value}) + end + end + + defp maybe_apply_query_quirk({key, value}, _), do: query_encode_kv_pair({key, value}) + + # Taken from Elixir's URI module and modified to support quirks. + defp query_encode_kv_pair({key, value}, rules \\ []) when is_list(rules) do + cond do + length(rules) > 0 -> + # URI.encode_query/2 does not appear to follow spec and encodes all parts + # of our URI path Constant. This appears to work outside of edge-cases + # like The Guardian Rich Media Cards, keeping behavior same as with + # URI.encode_query/2 unless otherwise specified via rules. + (URI.encode_www_form(Kernel.to_string(key)) <> + "=" <> + URI.encode(value, fn byte -> + URI.char_unreserved?(byte) || + Enum.any?( + rules, + fn char -> + char == byte + end + ) + end)) + |> String.replace("%20", "+") + + true -> + URI.encode_www_form(Kernel.to_string(key)) <> + "=" <> URI.encode_www_form(Kernel.to_string(value)) + end + end +end diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 3e5239a28..edf2e1fa7 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -1569,7 +1569,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do defp get_actor_url(_url), do: nil - defp normalize_image(%{"url" => url} = data) do + defp normalize_image(%{"url" => url} = data) when is_binary(url) do %{ "type" => "Image", "url" => [%{"href" => url}] @@ -1577,6 +1577,13 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do |> maybe_put_description(data) end + defp normalize_image(%{"url" => urls}) when is_list(urls) do + url = urls |> List.first() + + %{"url" => url} + |> normalize_image() + end + defp normalize_image(urls) when is_list(urls), do: urls |> List.first() |> normalize_image() defp normalize_image(_), do: nil diff --git a/lib/pleroma/web/activity_pub/mrf/inline_quote_policy.ex b/lib/pleroma/web/activity_pub/mrf/inline_quote_policy.ex index 469d06ef6..51dcd3918 100644 --- a/lib/pleroma/web/activity_pub/mrf/inline_quote_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/inline_quote_policy.ex @@ -18,6 +18,8 @@ defmodule Pleroma.Web.ActivityPub.MRF.InlineQuotePolicy do content =~ quote_url -> true # Does the content already have a .quote-inline span? content =~ "" -> true + # Does the content already have a .quote-inline p? (Mastodon) + content =~ "

" -> true # No inline quote found true -> false end diff --git a/lib/pleroma/web/api_spec.ex b/lib/pleroma/web/api_spec.ex index 3e0ac3704..5006167ea 100644 --- a/lib/pleroma/web/api_spec.ex +++ b/lib/pleroma/web/api_spec.ex @@ -151,7 +151,8 @@ defmodule Pleroma.Web.ApiSpec do "Suggestions", "Announcements", "Remote interaction", - "Others" + "Others", + "Preferred frontends" ] } ] diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index 5a19e0fbb..07eb9328b 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -398,6 +398,28 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do } end + def endorsements_operation do + %Operation{ + tags: ["Retrieve account information"], + summary: "Endorsements", + description: "Returns endorsed accounts", + operationId: "AccountController.endorsements", + parameters: [ + with_relationships_param(), + %Reference{"$ref": "#/components/parameters/accountIdOrNickname"} + ], + responses: %{ + 200 => + Operation.response( + "Array of Accounts", + "application/json", + array_of_accounts() + ), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + def remove_from_followers_operation do %Operation{ tags: ["Account actions"], @@ -461,7 +483,7 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do security: [%{"oAuth" => ["follow", "read:mutes"]}], parameters: [with_relationships_param() | pagination_params()], responses: %{ - 200 => Operation.response("Accounts", "application/json", array_of_accounts()) + 200 => Operation.response("Accounts", "application/json", array_of_muted_accounts()) } } end @@ -475,7 +497,7 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do security: [%{"oAuth" => ["read:blocks"]}], parameters: [with_relationships_param() | pagination_params()], responses: %{ - 200 => Operation.response("Accounts", "application/json", array_of_accounts()) + 200 => Operation.response("Accounts", "application/json", array_of_blocked_accounts()) } } end @@ -495,16 +517,17 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do ], responses: %{ 200 => Operation.response("Account", "application/json", Account), + 401 => Operation.response("Error", "application/json", ApiError), 404 => Operation.response("Error", "application/json", ApiError) } } end - def endorsements_operation do + def own_endorsements_operation do %Operation{ tags: ["Retrieve account information"], summary: "Endorsements", - operationId: "AccountController.endorsements", + operationId: "AccountController.own_endorsements", description: "Returns endorsed accounts", security: [%{"oAuth" => ["read:accounts"]}], responses: %{ @@ -874,6 +897,54 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do } end + def array_of_muted_accounts do + %Schema{ + title: "ArrayOfMutedAccounts", + type: :array, + items: %Schema{ + title: "MutedAccount", + description: "Response schema for a muted account", + allOf: [ + Account, + %Schema{ + type: :object, + properties: %{ + mute_expires_at: %Schema{type: :string, format: "date-time", nullable: true} + } + } + ] + }, + example: [ + Account.schema().example + |> Map.put("mute_expires_at", "2025-11-29T16:23:13Z") + ] + } + end + + def array_of_blocked_accounts do + %Schema{ + title: "ArrayOfBlockedAccounts", + type: :array, + items: %Schema{ + title: "BlockedAccount", + description: "Response schema for a blocked account", + allOf: [ + Account, + %Schema{ + type: :object, + properties: %{ + block_expires_at: %Schema{type: :string, format: "date-time", nullable: true} + } + } + ] + }, + example: [ + Account.schema().example + |> Map.put("block_expires_at", "2025-11-29T16:23:13Z") + ] + } + end + defp array_of_relationships do %Schema{ title: "ArrayOfRelationships", diff --git a/lib/pleroma/web/api_spec/operations/chat_operation.ex b/lib/pleroma/web/api_spec/operations/chat_operation.ex index f56e57a41..76c2db57e 100644 --- a/lib/pleroma/web/api_spec/operations/chat_operation.ex +++ b/lib/pleroma/web/api_spec/operations/chat_operation.ex @@ -142,7 +142,8 @@ defmodule Pleroma.Web.ApiSpec.ChatOperation do :query, BooleanLike.schema(), "Include chats from muted users" - ) + ), + Operation.parameter(:pinned, :query, BooleanLike.schema(), "Include only pinned chats") ], responses: %{ 200 => Operation.response("The chats of the user", "application/json", chats_response()) @@ -166,7 +167,8 @@ defmodule Pleroma.Web.ApiSpec.ChatOperation do :query, BooleanLike.schema(), "Include chats from muted users" - ) + ), + Operation.parameter(:pinned, :query, BooleanLike.schema(), "Include only pinned chats") | pagination_params() ], responses: %{ @@ -257,6 +259,44 @@ defmodule Pleroma.Web.ApiSpec.ChatOperation do } end + def pin_operation do + %Operation{ + tags: ["Chats"], + summary: "Pin a chat", + operationId: "ChatController.pin", + parameters: [ + Operation.parameter(:id, :path, :string, "The id of the chat", required: true) + ], + responses: %{ + 200 => Operation.response("The existing chat", "application/json", Chat) + }, + security: [ + %{ + "oAuth" => ["write:chats"] + } + ] + } + end + + def unpin_operation do + %Operation{ + tags: ["Chats"], + summary: "Unpin a chat", + operationId: "ChatController.unpin", + parameters: [ + Operation.parameter(:id, :path, :string, "The id of the chat", required: true) + ], + responses: %{ + 200 => Operation.response("The existing chat", "application/json", Chat) + }, + security: [ + %{ + "oAuth" => ["write:chats"] + } + ] + } + end + def chats_response do %Schema{ title: "ChatsResponse", diff --git a/lib/pleroma/web/api_spec/operations/pleroma_account_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_account_operation.ex index b8b37d7cf..b144c2ac0 100644 --- a/lib/pleroma/web/api_spec/operations/pleroma_account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/pleroma_account_operation.ex @@ -64,25 +64,6 @@ defmodule Pleroma.Web.ApiSpec.PleromaAccountOperation do } end - def endorsements_operation do - %Operation{ - tags: ["Retrieve account information"], - summary: "Endorsements", - description: "Returns endorsed accounts", - operationId: "PleromaAPI.AccountController.endorsements", - parameters: [with_relationships_param(), id_param()], - responses: %{ - 200 => - Operation.response( - "Array of Accounts", - "application/json", - AccountOperation.array_of_accounts() - ), - 404 => Operation.response("Not Found", "application/json", ApiError) - } - } - end - def subscribe_operation do %Operation{ deprecated: true, diff --git a/lib/pleroma/web/api_spec/operations/pleroma_follow_request_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_follow_request_operation.ex new file mode 100644 index 000000000..b5a413490 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/pleroma_follow_request_operation.ex @@ -0,0 +1,31 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2024 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.PleromaFollowRequestOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.Account + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def outgoing_operation do + %Operation{ + tags: ["Follow requests"], + summary: "Retrieve outgoing follow requests", + security: [%{"oAuth" => ["read:follows", "follow"]}], + operationId: "PleromaFollowRequestController.outgoing", + responses: %{ + 200 => + Operation.response("Array of Account", "application/json", %Schema{ + type: :array, + items: Account, + example: [Account.schema().example] + }) + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/pleroma_frontend_settings_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_frontend_settings_operation.ex new file mode 100644 index 000000000..923e4fcc9 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/pleroma_frontend_settings_operation.ex @@ -0,0 +1,65 @@ +defmodule Pleroma.Web.ApiSpec.PleromaFrontendSettingsOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + import Pleroma.Web.ApiSpec.Helpers + + @spec open_api_operation(atom) :: Operation.t() + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def available_frontends_operation do + %Operation{ + tags: ["Preferred frontends"], + summary: "Frontend settings profiles", + description: "List frontend setting profiles", + operationId: "PleromaAPI.FrontendSettingsController.available_frontends", + responses: %{ + 200 => + Operation.response("Frontends", "application/json", %Schema{ + type: :array, + items: %Schema{ + type: :string + } + }) + } + } + end + + def update_preferred_frontend_operation do + %Operation{ + tags: ["Preferred frontends"], + summary: "Update preferred frontend setting", + description: "Store preferred frontend in cookies", + operationId: "PleromaAPI.FrontendSettingsController.update_preferred_frontend", + requestBody: + request_body( + "Frontend", + %Schema{ + type: :object, + required: [:frontend_name], + properties: %{ + frontend_name: %Schema{ + type: :string, + description: "Frontend name" + } + } + }, + required: true + ), + responses: %{ + 200 => + Operation.response("Preferred frontend", "application/json", %Schema{ + type: :object, + properties: %{ + frontend_name: %Schema{ + type: :string, + description: "Frontend name" + } + } + }) + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/pleroma_scrobble_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_scrobble_operation.ex index 6f77584a8..a08b7b580 100644 --- a/lib/pleroma/web/api_spec/operations/pleroma_scrobble_operation.ex +++ b/lib/pleroma/web/api_spec/operations/pleroma_scrobble_operation.ex @@ -20,7 +20,7 @@ defmodule Pleroma.Web.ApiSpec.PleromaScrobbleOperation do %Operation{ tags: ["Scrobbles"], summary: "Creates a new Listen activity for an account", - security: [%{"oAuth" => ["write"]}], + security: [%{"oAuth" => ["write:scrobbles"]}], operationId: "PleromaAPI.ScrobbleController.create", deprecated: true, requestBody: request_body("Parameters", create_request(), required: true), @@ -39,7 +39,7 @@ defmodule Pleroma.Web.ApiSpec.PleromaScrobbleOperation do parameters: [ %Reference{"$ref": "#/components/parameters/accountIdOrNickname"} | pagination_params() ], - security: [%{"oAuth" => ["read"]}], + security: [%{"oAuth" => ["read:scrobbles"]}], responses: %{ 200 => Operation.response("Array of Scrobble", "application/json", %Schema{ diff --git a/lib/pleroma/web/api_spec/operations/pleroma_status_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_status_operation.ex index 77c604952..c0919f726 100644 --- a/lib/pleroma/web/api_spec/operations/pleroma_status_operation.ex +++ b/lib/pleroma/web/api_spec/operations/pleroma_status_operation.ex @@ -19,7 +19,8 @@ defmodule Pleroma.Web.ApiSpec.PleromaStatusOperation do %Operation{ tags: ["Retrieve status information"], summary: "Quoted by", - description: "View quotes for a given status", + deprecated: true, + description: "View quotes for a given status. Use /api/v1/statuses/:id/quotes instead.", operationId: "PleromaAPI.StatusController.quotes", parameters: [id_param() | pagination_params()], security: [%{"oAuth" => ["read:statuses"]}], diff --git a/lib/pleroma/web/api_spec/operations/search_operation.ex b/lib/pleroma/web/api_spec/operations/search_operation.ex index c2afe8e18..95d9b063a 100644 --- a/lib/pleroma/web/api_spec/operations/search_operation.ex +++ b/lib/pleroma/web/api_spec/operations/search_operation.ex @@ -46,6 +46,12 @@ defmodule Pleroma.Web.ApiSpec.SearchOperation do :query, %Schema{allOf: [BooleanLike], default: false}, "Only include accounts that the user is following" + ), + Operation.parameter( + :capabilities, + :query, + %Schema{type: :array, items: %Schema{type: :string, enum: ["accepts_chat_messages"]}}, + "Only include accounts with given capabilities" ) ], responses: %{ diff --git a/lib/pleroma/web/api_spec/operations/status_operation.ex b/lib/pleroma/web/api_spec/operations/status_operation.ex index 02026122f..fed37f51d 100644 --- a/lib/pleroma/web/api_spec/operations/status_operation.ex +++ b/lib/pleroma/web/api_spec/operations/status_operation.ex @@ -549,6 +549,27 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do } end + def quotes_operation do + %Operation{ + tags: ["Retrieve status information"], + summary: "Quoted by", + description: "View quotes for a given status", + operationId: "StatusController.quotes", + parameters: [id_param() | pagination_params()], + security: [%{"oAuth" => ["read:statuses"]}], + responses: %{ + 200 => + Operation.response( + "Array of Status", + "application/json", + array_of_statuses() + ), + 403 => Operation.response("Forbidden", "application/json", ApiError), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + def array_of_statuses do %Schema{type: :array, items: Status, example: [Status.schema().example]} end diff --git a/lib/pleroma/web/api_spec/schemas/account.ex b/lib/pleroma/web/api_spec/schemas/account.ex index 19827e996..7d0b83afe 100644 --- a/lib/pleroma/web/api_spec/schemas/account.ex +++ b/lib/pleroma/web/api_spec/schemas/account.ex @@ -33,8 +33,6 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Account do header: %Schema{type: :string, format: :uri}, id: FlakeID, locked: %Schema{type: :boolean}, - mute_expires_at: %Schema{type: :string, format: "date-time", nullable: true}, - block_expires_at: %Schema{type: :string, format: "date-time", nullable: true}, note: %Schema{type: :string, format: :html}, statuses_count: %Schema{type: :integer}, url: %Schema{type: :string, format: :uri}, diff --git a/lib/pleroma/web/api_spec/schemas/chat.ex b/lib/pleroma/web/api_spec/schemas/chat.ex index affa25a95..ce08896b5 100644 --- a/lib/pleroma/web/api_spec/schemas/chat.ex +++ b/lib/pleroma/web/api_spec/schemas/chat.ex @@ -17,7 +17,8 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Chat do account: %Schema{type: :object}, unread: %Schema{type: :integer}, last_message: ChatMessage, - updated_at: %Schema{type: :string, format: :"date-time"} + updated_at: %Schema{type: :string, format: :"date-time"}, + pinned: %Schema{type: :boolean} }, example: %{ "account" => %{ @@ -69,7 +70,8 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Chat do "id" => "1", "unread" => 2, "last_message" => ChatMessage.schema().example, - "updated_at" => "2020-04-21T15:06:45.000Z" + "updated_at" => "2020-04-21T15:06:45.000Z", + "pinned" => false } }) end diff --git a/lib/pleroma/web/api_spec/schemas/status.ex b/lib/pleroma/web/api_spec/schemas/status.ex index 25548d75b..ac1735e35 100644 --- a/lib/pleroma/web/api_spec/schemas/status.ex +++ b/lib/pleroma/web/api_spec/schemas/status.ex @@ -219,7 +219,9 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do }, quotes_count: %Schema{ type: :integer, - description: "How many statuses quoted this status" + deprecated: true, + description: + "How many statuses quoted this status. Deprecated, use `quotes_count` from parent object instead." }, local: %Schema{ type: :boolean, @@ -259,6 +261,10 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do } }, poll: %Schema{allOf: [Poll], nullable: true, description: "The poll attached to the status"}, + quotes_count: %Schema{ + type: :integer, + description: "How many statuses quoted this status." + }, reblog: %Schema{ allOf: [%OpenApiSpex.Reference{"$ref": "#/components/schemas/Status"}], nullable: true, @@ -385,6 +391,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do "quotes_count" => 0 }, "poll" => nil, + "quotes_count" => 0, "reblog" => nil, "reblogged" => false, "reblogs_count" => 0, diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index 52c08f00f..91bf9c502 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -402,28 +402,6 @@ defmodule Pleroma.Web.CommonAPI.Utils do def maybe_notify_mentioned_recipients(recipients, _), do: recipients - def maybe_notify_subscribers( - recipients, - %Activity{data: %{"actor" => actor, "type" => "Create"}} = activity - ) do - # Do not notify subscribers if author is making a reply - with %Object{data: object} <- Object.normalize(activity, fetch: false), - nil <- object["inReplyTo"], - %User{} = user <- User.get_cached_by_ap_id(actor) do - subscriber_ids = - user - |> User.subscriber_users() - |> Enum.filter(&Visibility.visible_for_user?(activity, &1)) - |> Enum.map(& &1.ap_id) - - recipients ++ subscriber_ids - else - _e -> recipients - end - end - - def maybe_notify_subscribers(recipients, _), do: recipients - def maybe_notify_followers(recipients, %Activity{data: %{"type" => "Move"}} = activity) do with %User{} = user <- User.get_cached_by_ap_id(activity.actor) do user @@ -437,6 +415,27 @@ defmodule Pleroma.Web.CommonAPI.Utils do def maybe_notify_followers(recipients, _), do: recipients + def get_notified_subscribers( + %Activity{data: %{"actor" => actor, "type" => "Create"}} = activity + ) do + # Do not notify subscribers if author is making a reply + with %Object{data: object} <- Object.normalize(activity, fetch: false), + nil <- object["inReplyTo"], + %User{} = user <- User.get_cached_by_ap_id(actor) do + subscriber_ids = + user + |> User.subscriber_users() + |> Enum.filter(&Visibility.visible_for_user?(activity, &1)) + |> Enum.map(& &1.ap_id) + + subscriber_ids + else + _e -> [] + end + end + + def get_notified_subscribers(_), do: [] + def maybe_extract_mentions(%{"tag" => tag}) do tag |> Enum.filter(fn x -> is_map(x) && x["type"] == "Mention" end) diff --git a/lib/pleroma/web/fallback/redirect_controller.ex b/lib/pleroma/web/fallback/redirect_controller.ex index 6637848a9..60fc15b9e 100644 --- a/lib/pleroma/web/fallback/redirect_controller.ex +++ b/lib/pleroma/web/fallback/redirect_controller.ex @@ -30,7 +30,7 @@ defmodule Pleroma.Web.Fallback.RedirectController do end def redirector(conn, _params, code \\ 200) do - {:ok, index_content} = File.read(index_file_path()) + {:ok, index_content} = File.read(index_file_path(conn)) response = index_content @@ -51,7 +51,7 @@ defmodule Pleroma.Web.Fallback.RedirectController do end def redirector_with_meta(conn, params) do - {:ok, index_content} = File.read(index_file_path()) + {:ok, index_content} = File.read(index_file_path(conn)) tags = build_tags(conn, params) preloads = preload_data(conn, params) @@ -69,7 +69,7 @@ defmodule Pleroma.Web.Fallback.RedirectController do end def redirector_with_preload(conn, params) do - {:ok, index_content} = File.read(index_file_path()) + {:ok, index_content} = File.read(index_file_path(conn)) preloads = preload_data(conn, params) response = @@ -91,8 +91,10 @@ defmodule Pleroma.Web.Fallback.RedirectController do |> text("") end - defp index_file_path do - Pleroma.Web.Plugs.InstanceStatic.file_path("index.html") + defp index_file_path(conn) do + frontend_type = Pleroma.Web.Plugs.FrontendStatic.preferred_or_fallback(conn, :primary) + + Pleroma.Web.Plugs.InstanceStatic.file_path("index.html", frontend_type) end defp build_tags(conn, params) do diff --git a/lib/pleroma/web/feed/user_controller.ex b/lib/pleroma/web/feed/user_controller.ex index 304313068..8a52d98e0 100644 --- a/lib/pleroma/web/feed/user_controller.ex +++ b/lib/pleroma/web/feed/user_controller.ex @@ -28,9 +28,12 @@ defmodule Pleroma.Web.Feed.UserController do ActivityPubController.call(conn, :user) end - def feed_redirect(conn, %{"nickname" => nickname}) do + def feed_redirect(%{assigns: assigns} = conn, %{"nickname" => nickname}) do + format = Map.get(assigns, :format, "atom") + format = if format in ["atom", "rss"], do: format, else: "atom" + with {_, %User{} = user} <- {:fetch_user, User.get_cached_by_nickname(nickname)} do - redirect(conn, external: "#{Routes.user_feed_url(conn, :feed, user.nickname)}.atom") + redirect(conn, external: "#{Routes.user_feed_url(conn, :feed, user.nickname)}.#{format}") end end diff --git a/lib/pleroma/web/frontend_switcher/frontend_switcher_controller.ex b/lib/pleroma/web/frontend_switcher/frontend_switcher_controller.ex new file mode 100644 index 000000000..18752c63c --- /dev/null +++ b/lib/pleroma/web/frontend_switcher/frontend_switcher_controller.ex @@ -0,0 +1,20 @@ +defmodule Pleroma.Web.FrontendSwitcher.FrontendSwitcherController do + use Pleroma.Web, :controller + alias Pleroma.Config + + @doc "GET /frontend_switcher" + def switch(conn, _params) do + pickable = Config.get([:frontends, :pickable], []) + + conn + |> put_view(Pleroma.Web.FrontendSwitcher.FrontendSwitcherView) + |> render("switch.html", choices: pickable) + end + + @doc "POST /frontend_switcher" + def do_switch(conn, params) do + conn + |> put_resp_cookie("preferred_frontend", params["frontend"]) + |> html(~s()) + end +end diff --git a/lib/pleroma/web/frontend_switcher/frontend_switcher_view.ex b/lib/pleroma/web/frontend_switcher/frontend_switcher_view.ex new file mode 100644 index 000000000..284477431 --- /dev/null +++ b/lib/pleroma/web/frontend_switcher/frontend_switcher_view.ex @@ -0,0 +1,5 @@ +defmodule Pleroma.Web.FrontendSwitcher.FrontendSwitcherView do + use Pleroma.Web, :view + + import Phoenix.HTML.Form +end diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index d374e8c01..6dc731ed4 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -31,14 +31,14 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do plug(Pleroma.Web.ApiSpec.CastAndValidate, replace_params: false) - plug(:skip_auth when action in [:create, :lookup]) + plug(:skip_auth when action in [:create]) plug(:skip_public_check when action in [:show, :statuses]) plug( OAuthScopesPlug, %{fallback: :proceed_unauthenticated, scopes: ["read:accounts"]} - when action in [:show, :followers, :following] + when action in [:show, :followers, :following, :lookup, :endorsements] ) plug( @@ -50,7 +50,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do plug( OAuthScopesPlug, %{scopes: ["read:accounts"]} - when action in [:verify_credentials, :endorsements] + when action in [:verify_credentials, :endorsements, :own_endorsements] ) plug( @@ -89,7 +89,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do @relationship_actions [:follow, :unfollow, :remove_from_followers] @needs_account ~W( followers following lists follow unfollow mute unmute block unblock - note endorse unendorse remove_from_followers + note endorse unendorse endorsements remove_from_followers )a plug( @@ -555,6 +555,22 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do end end + @doc "GET /api/v1/accounts/:id/endorsements" + def endorsements(%{assigns: %{user: for_user, account: user}} = conn, params) do + users = + user + |> User.endorsed_users_relation(_restrict_deactivated = true) + |> Pleroma.Repo.all() + + conn + |> render("index.json", + for: for_user, + users: users, + as: :user, + embed_relationships: embed_relationships?(params) + ) + end + @doc "POST /api/v1/accounts/:id/remove_from_followers" def remove_from_followers(%{assigns: %{user: %{id: id}, account: %{id: id}}}, _params) do {:error, "Can not unfollow yourself"} @@ -619,8 +635,13 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do end @doc "GET /api/v1/accounts/lookup" - def lookup(%{private: %{open_api_spex: %{params: %{acct: nickname}}}} = conn, _params) do - with %User{} = user <- User.get_by_nickname(nickname) do + def lookup( + %{assigns: %{user: for_user}, private: %{open_api_spex: %{params: %{acct: nickname}}}} = + conn, + _params + ) do + with %User{} = user <- User.get_by_nickname(nickname), + :visible <- User.visible_for(user, for_user) do render(conn, "show.json", user: user, skip_visibility_check: true @@ -631,7 +652,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do end @doc "GET /api/v1/endorsements" - def endorsements(%{assigns: %{user: user}} = conn, params) do + def own_endorsements(%{assigns: %{user: user}} = conn, params) do users = user |> User.endorsed_users_relation(_restrict_deactivated = true) diff --git a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex index 53f1216fd..1bfd98eb3 100644 --- a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex @@ -91,6 +91,7 @@ defmodule Pleroma.Web.MastodonAPI.SearchController do limit: min(params[:limit], @search_limit), offset: params[:offset], type: params[:type], + capabilities: params[:capabilities], author: get_author(params), embed_relationships: ControllerHelper.embed_relationships?(params), for_user: user diff --git a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex index 32874d464..5b2cd50bc 100644 --- a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex @@ -9,6 +9,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do only: [try_render: 3, add_link_headers: 2] require Ecto.Query + require Pleroma.Constants alias Pleroma.Activity alias Pleroma.Bookmark @@ -41,7 +42,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do :show, :context, :show_history, - :show_source + :show_source, + :quotes ] ) @@ -488,6 +490,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do users = User |> Ecto.Query.where([u], u.ap_id in ^likes) + |> Ecto.Query.order_by([u], fragment("array_position(?, ?)", ^likes, u.ap_id)) |> Repo.all() |> Enum.filter(&(not User.blocks?(user, &1))) @@ -523,6 +526,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do users = User |> Ecto.Query.where([u], u.ap_id in ^announces) + |> Ecto.Query.order_by([u], fragment("array_position(?, ?)", ^announces, u.ap_id)) |> Repo.all() |> Enum.filter(&(not User.blocks?(user, &1))) @@ -629,6 +633,45 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do ) end + @doc "GET /api/v1/statuses/:id/quotes" + def quotes( + %{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: id} = params}}} = + conn, + _ + ) do + with %Activity{object: object} = activity <- Activity.get_by_id_with_object(id), + true <- Visibility.visible_for_user?(activity, user) do + params = + params + |> Map.put(:type, "Create") + |> Map.put(:blocking_user, user) + |> Map.put(:quote_url, object.data["id"]) + + recipients = + if user do + [Pleroma.Constants.as_public()] ++ [user.ap_id | User.following(user)] + else + [Pleroma.Constants.as_public()] + end + + activities = + recipients + |> ActivityPub.fetch_activities(params) + |> Enum.reverse() + + conn + |> add_link_headers(activities) + |> render("index.json", + activities: activities, + for: user, + as: :activity + ) + else + nil -> {:error, :not_found} + false -> {:error, :not_found} + end + end + defp put_application(params, %{assigns: %{token: %Token{user: %User{} = user} = token}} = _conn) do if user.disclose_client do %{client_name: client_name, website: website} = Repo.preload(token, :app).app diff --git a/lib/pleroma/web/mastodon_api/views/instance_view.ex b/lib/pleroma/web/mastodon_api/views/instance_view.ex index 1b6f26af7..57372248f 100644 --- a/lib/pleroma/web/mastodon_api/views/instance_view.ex +++ b/lib/pleroma/web/mastodon_api/views/instance_view.ex @@ -146,6 +146,7 @@ defmodule Pleroma.Web.MastodonAPI.InstanceView do "pleroma_emoji_reactions", "pleroma_custom_emoji_reactions", "pleroma_chat_messages", + "pleroma:pin_chats", if Config.get([:instance, :show_reactions]) do "exposable_reactions" end, @@ -257,10 +258,34 @@ defmodule Pleroma.Web.MastodonAPI.InstanceView do vapid: %{ public_key: Keyword.get(Pleroma.Web.Push.vapid_config(), :public_key) }, - translation: %{enabled: Pleroma.Language.Translation.configured?()} + translation: %{enabled: Pleroma.Language.Translation.configured?()}, + timelines_access: %{ + live_feeds: timelines_access(), + hashtag_feeds: timelines_access(), + # not implemented in Pleroma + trending_link_feeds: %{ + local: "disabled", + remote: "disabled" + } + } }) end + defp timelines_access do + %{ + local: timeline_access(:local), + remote: timeline_access(:federated) + } + end + + defp timeline_access(kind) do + if Config.restrict_unauthenticated_access?(:timelines, kind) do + "authenticated" + else + "public" + end + end + defp pleroma_configuration(instance) do base_urls = %{} diff --git a/lib/pleroma/web/mastodon_api/views/notification_view.ex b/lib/pleroma/web/mastodon_api/views/notification_view.ex index c277af98b..27532c42a 100644 --- a/lib/pleroma/web/mastodon_api/views/notification_view.ex +++ b/lib/pleroma/web/mastodon_api/views/notification_view.ex @@ -106,27 +106,15 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do } case notification.type do - "mention" -> + type when type in ["mention", "status", "poll"] -> put_status(response, activity, reading_user, status_render_opts) - "status" -> - put_status(response, activity, reading_user, status_render_opts) - - "favourite" -> - put_status(response, parent_activity_fn.(), reading_user, status_render_opts) - - "reblog" -> - put_status(response, parent_activity_fn.(), reading_user, status_render_opts) - - "update" -> + type when type in ["favourite", "reblog", "update"] -> put_status(response, parent_activity_fn.(), reading_user, status_render_opts) "move" -> put_target(response, activity, reading_user, %{}) - "poll" -> - put_status(response, activity, reading_user, status_render_opts) - "pleroma:emoji_reaction" -> response |> put_status(parent_activity_fn.(), reading_user, status_render_opts) diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 4b5ac9c3b..c1a996f62 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -447,6 +447,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do application: build_application(object.data["generator"]), language: get_language(object), emojis: build_emojis(object.data["emoji"]), + quotes_count: object.data["quotesCount"] || 0, pleroma: %{ local: activity.local, conversation_id: get_context_id(activity), @@ -602,7 +603,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do def render("attachment.json", %{attachment: attachment}) do [attachment_url | _] = attachment["url"] media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image" - href = attachment_url["href"] |> MediaProxy.url() + href_remote = attachment_url["href"] + href = href_remote |> MediaProxy.url() href_preview = attachment_url["href"] |> MediaProxy.preview_url() meta = render("attachment_meta.json", %{attachment: attachment}) @@ -641,7 +643,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do %{ id: attachment_id, url: href, - remote_url: href, + remote_url: href_remote, preview_url: href_preview, text_url: href, type: type, diff --git a/lib/pleroma/web/media_proxy.ex b/lib/pleroma/web/media_proxy.ex index 29882542c..f9376b508 100644 --- a/lib/pleroma/web/media_proxy.ex +++ b/lib/pleroma/web/media_proxy.ex @@ -6,6 +6,7 @@ defmodule Pleroma.Web.MediaProxy do alias Pleroma.Config alias Pleroma.Helpers.UriHelper alias Pleroma.Upload + alias Pleroma.Utils.URIEncoding alias Pleroma.Web.Endpoint alias Pleroma.Web.MediaProxy.Invalidation @@ -99,13 +100,21 @@ defmodule Pleroma.Web.MediaProxy do {base64, sig64} end + # The URL coming into MediaProxy from the outside might have wrong %-encoding + # (like older Pleroma versions). + # This would cause an inconsistency with the encoded URL here and the requested + # URL fixed with Pleroma.Tesla.Middleware.EncodeUrl. + # End result is a failing HEAD request in + # Pleroma.Web.MediaProxy.MediaProxyController.handle_preview/2 def encode_url(url) do + url = URIEncoding.encode_url(url) {base64, sig64} = base64_sig64(url) build_url(sig64, base64, filename(url)) end def encode_preview_url(url, preview_params \\ []) do + url = URIEncoding.encode_url(url) {base64, sig64} = base64_sig64(url) build_preview_url(sig64, base64, filename(url), preview_params) diff --git a/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex b/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex index 85c2393ff..c4e48e83a 100644 --- a/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex +++ b/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex @@ -36,7 +36,7 @@ defmodule Pleroma.Web.Nodeinfo.NodeinfoController do conn |> put_resp_header( "content-type", - "application/json; profile=http://nodeinfo.diaspora.software/ns/schema/2.0#; charset=utf-8" + "application/json; profile=\"http://nodeinfo.diaspora.software/ns/schema/#{version}#\"; charset=utf-8" ) |> json(node_info) end diff --git a/lib/pleroma/web/pleroma_api/controllers/account_controller.ex b/lib/pleroma/web/pleroma_api/controllers/account_controller.ex index 591391b60..0b8e0b7ad 100644 --- a/lib/pleroma/web/pleroma_api/controllers/account_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/account_controller.ex @@ -9,7 +9,6 @@ defmodule Pleroma.Web.PleromaAPI.AccountController do only: [ json_response: 3, add_link_headers: 2, - embed_relationships?: 1, assign_account_by_id: 2 ] @@ -45,12 +44,6 @@ defmodule Pleroma.Web.PleromaAPI.AccountController do %{scopes: ["read:favourites"], fallback: :proceed_unauthenticated} when action == :favourites ) - plug( - OAuthScopesPlug, - %{fallback: :proceed_unauthenticated, scopes: ["read:accounts"]} - when action == :endorsements - ) - plug( OAuthScopesPlug, %{scopes: ["read:accounts"]} when action == :birthdays @@ -60,7 +53,7 @@ defmodule Pleroma.Web.PleromaAPI.AccountController do plug( :assign_account_by_id - when action in [:favourites, :endorsements, :subscribe, :unsubscribe] + when action in [:favourites, :subscribe, :unsubscribe] ) defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PleromaAccountOperation @@ -109,22 +102,6 @@ defmodule Pleroma.Web.PleromaAPI.AccountController do ) end - @doc "GET /api/v1/pleroma/accounts/:id/endorsements" - def endorsements(%{assigns: %{user: for_user, account: user}} = conn, params) do - users = - user - |> User.endorsed_users_relation(_restrict_deactivated = true) - |> Pleroma.Repo.all() - - conn - |> render("index.json", - for: for_user, - users: users, - as: :user, - embed_relationships: embed_relationships?(params) - ) - end - @doc "POST /api/v1/pleroma/accounts/:id/subscribe" def subscribe(%{assigns: %{user: user, account: subscription_target}} = conn, _params) do with {:ok, _subscription} <- User.subscribe(user, subscription_target) do diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index 58780ace2..ac9bb2779 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -29,7 +29,9 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do :create, :mark_as_read, :mark_message_as_read, - :delete_message + :delete_message, + :pin, + :unpin ] ) @@ -199,8 +201,16 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do user_id |> Chat.for_user_query() |> where([c], c.recipient not in ^exclude_users) + |> restrict_pinned(params) end + defp restrict_pinned(query, %{pinned: pinned}) when is_boolean(pinned) do + query + |> where([c], c.pinned == ^pinned) + end + + defp restrict_pinned(query, _), do: query + def create(%{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: id}}}} = conn, _) do with %User{ap_id: recipient} <- User.get_cached_by_id(id), {:ok, %Chat{} = chat} <- Chat.get_or_create(user.id, recipient) do @@ -214,6 +224,20 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do end end + def pin(%{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: id}}}} = conn, _) do + with {:ok, chat} <- Chat.get_by_user_and_id(user, id), + {:ok, chat} <- Chat.pin(chat) do + render(conn, "show.json", chat: chat) + end + end + + def unpin(%{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: id}}}} = conn, _) do + with {:ok, chat} <- Chat.get_by_user_and_id(user, id), + {:ok, chat} <- Chat.unpin(chat) do + render(conn, "show.json", chat: chat) + end + end + defp idempotency_key(conn) do case get_req_header(conn, "idempotency-key") do [key] -> key diff --git a/lib/pleroma/web/pleroma_api/controllers/follow_request_controller.ex b/lib/pleroma/web/pleroma_api/controllers/follow_request_controller.ex new file mode 100644 index 000000000..656d477da --- /dev/null +++ b/lib/pleroma/web/pleroma_api/controllers/follow_request_controller.ex @@ -0,0 +1,27 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2024 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.FollowRequestController do + use Pleroma.Web, :controller + + alias Pleroma.User + alias Pleroma.Web.Plugs.OAuthScopesPlug + + plug(Pleroma.Web.ApiSpec.CastAndValidate, replace_params: false) + + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + + plug(OAuthScopesPlug, %{scopes: ["follow", "read:follows"]}) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PleromaFollowRequestOperation + + @doc "GET /api/v1/pleroma/outgoing_follow_requests" + def outgoing(%{assigns: %{user: follower}} = conn, _params) do + follow_requests = User.get_outgoing_follow_requests(follower) + + conn + |> put_view(Pleroma.Web.MastodonAPI.FollowRequestView) + |> render("index.json", for: follower, users: follow_requests, as: :user) + end +end diff --git a/lib/pleroma/web/pleroma_api/controllers/frontend_settings_controller.ex b/lib/pleroma/web/pleroma_api/controllers/frontend_settings_controller.ex new file mode 100644 index 000000000..41531c97e --- /dev/null +++ b/lib/pleroma/web/pleroma_api/controllers/frontend_settings_controller.ex @@ -0,0 +1,37 @@ +defmodule Pleroma.Web.PleromaAPI.FrontendSettingsController do + use Pleroma.Web, :controller + + alias Pleroma.Web.Plugs.OAuthScopesPlug + + plug( + OAuthScopesPlug, + %{fallback: :proceed_unauthenticated, scopes: []} + when action in [ + :available_frontends, + :update_preferred_frontend + ] + ) + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PleromaFrontendSettingsOperation + + action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + + @doc "GET /api/v1/pleroma/preferred_frontend/available" + def available_frontends(conn, _params) do + available = Pleroma.Config.get([:frontends, :pickable]) + + conn + |> json(available) + end + + @doc "PUT /api/v1/pleroma/preferred_frontend" + def update_preferred_frontend( + %{body_params: %{frontend_name: preferred_frontend}} = conn, + _params + ) do + conn + |> put_resp_cookie("preferred_frontend", preferred_frontend) + |> json(%{frontend_name: preferred_frontend}) + end +end diff --git a/lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex b/lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex index 5f5f7643f..207446a84 100644 --- a/lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex @@ -16,10 +16,10 @@ defmodule Pleroma.Web.PleromaAPI.ScrobbleController do plug( OAuthScopesPlug, - %{scopes: ["read"], fallback: :proceed_unauthenticated} when action == :index + %{scopes: ["read:scrobbles"], fallback: :proceed_unauthenticated} when action == :index ) - plug(OAuthScopesPlug, %{scopes: ["write"]} when action == :create) + plug(OAuthScopesPlug, %{scopes: ["write:scrobbles"]} when action == :create) defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PleromaScrobbleOperation diff --git a/lib/pleroma/web/pleroma_api/controllers/status_controller.ex b/lib/pleroma/web/pleroma_api/controllers/status_controller.ex index 482662fdd..c72b2f222 100644 --- a/lib/pleroma/web/pleroma_api/controllers/status_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/status_controller.ex @@ -5,16 +5,9 @@ defmodule Pleroma.Web.PleromaAPI.StatusController do use Pleroma.Web, :controller - import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2] - require Ecto.Query require Pleroma.Constants - alias Pleroma.Activity - alias Pleroma.User - alias Pleroma.Web.ActivityPub.ActivityPub - alias Pleroma.Web.ActivityPub.Visibility - alias Pleroma.Web.MastodonAPI.StatusView alias Pleroma.Web.Plugs.OAuthScopesPlug plug(Pleroma.Web.ApiSpec.CastAndValidate) @@ -29,38 +22,9 @@ defmodule Pleroma.Web.PleromaAPI.StatusController do defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PleromaStatusOperation @doc "GET /api/v1/pleroma/statuses/:id/quotes" - def quotes(%{assigns: %{user: user}} = conn, %{id: id} = params) do - with %Activity{object: object} = activity <- Activity.get_by_id_with_object(id), - true <- Visibility.visible_for_user?(activity, user) do - params = - params - |> Map.put(:type, "Create") - |> Map.put(:blocking_user, user) - |> Map.put(:quote_url, object.data["id"]) - - recipients = - if user do - [Pleroma.Constants.as_public()] ++ [user.ap_id | User.following(user)] - else - [Pleroma.Constants.as_public()] - end - - activities = - recipients - |> ActivityPub.fetch_activities(params) - |> Enum.reverse() - - conn - |> add_link_headers(activities) - |> put_view(StatusView) - |> render("index.json", - activities: activities, - for: user, - as: :activity - ) - else - nil -> {:error, :not_found} - false -> {:error, :not_found} - end + def quotes(conn, _params) do + conn + |> put_view(Pleroma.Web.MastodonAPI.StatusView) + |> Pleroma.Web.MastodonAPI.StatusController.call(:quotes) end end diff --git a/lib/pleroma/web/pleroma_api/views/chat_view.ex b/lib/pleroma/web/pleroma_api/views/chat_view.ex index db6c13c05..d579ca9f6 100644 --- a/lib/pleroma/web/pleroma_api/views/chat_view.ex +++ b/lib/pleroma/web/pleroma_api/views/chat_view.ex @@ -24,7 +24,8 @@ defmodule Pleroma.Web.PleromaAPI.ChatView do last_message: last_message && MessageReferenceView.render("show.json", chat_message_reference: last_message), - updated_at: Utils.to_masto_date(chat.updated_at) + updated_at: Utils.to_masto_date(chat.updated_at), + pinned: chat.pinned } end diff --git a/lib/pleroma/web/plugs/frontend_static.ex b/lib/pleroma/web/plugs/frontend_static.ex index 6ab8e4667..f1df185e3 100644 --- a/lib/pleroma/web/plugs/frontend_static.ex +++ b/lib/pleroma/web/plugs/frontend_static.ex @@ -5,17 +5,23 @@ defmodule Pleroma.Web.Plugs.FrontendStatic do require Pleroma.Constants + @frontend_cookie_name "preferred_frontend" + @moduledoc """ This is a shim to call `Plug.Static` but with runtime `from` configuration`. It dispatches to the different frontends. """ @behaviour Plug - def file_path(path, frontend_type \\ :primary) do - if configuration = Pleroma.Config.get([:frontends, frontend_type]) do - instance_static_path = Pleroma.Config.get([:instance, :static_dir], "instance/static") + defp instance_static_path do + Pleroma.Config.get([:instance, :static_dir], "instance/static") + end + def file_path(path, frontend_type \\ :primary) + + def file_path(path, frontend_type) when is_atom(frontend_type) do + if configuration = Pleroma.Config.get([:frontends, frontend_type]) do Path.join([ - instance_static_path, + instance_static_path(), "frontends", configuration["name"], configuration["ref"], @@ -26,6 +32,15 @@ defmodule Pleroma.Web.Plugs.FrontendStatic do end end + def file_path(path, frontend_type) when is_binary(frontend_type) do + Path.join([ + instance_static_path(), + "frontends", + frontend_type, + path + ]) + end + def init(opts) do opts |> Keyword.put(:from, "__unconfigured_frontend_static_plug") @@ -36,7 +51,8 @@ defmodule Pleroma.Web.Plugs.FrontendStatic do def call(conn, opts) do with false <- api_route?(conn.path_info), false <- invalid_path?(conn.path_info), - frontend_type <- Map.get(opts, :frontend_type, :primary), + fallback_frontend_type <- Map.get(opts, :frontend_type, :primary), + frontend_type <- preferred_or_fallback(conn, fallback_frontend_type), path when not is_nil(path) <- file_path("", frontend_type) do call_static(conn, opts, path) else @@ -45,6 +61,31 @@ defmodule Pleroma.Web.Plugs.FrontendStatic do end end + def preferred_frontend(conn) do + %{req_cookies: cookies} = + conn + |> Plug.Conn.fetch_cookies() + + Map.get(cookies, @frontend_cookie_name) + end + + # Only override primary frontend + def preferred_or_fallback(conn, :primary) do + case preferred_frontend(conn) do + nil -> + :primary + + frontend -> + if Enum.member?(Pleroma.Config.get([:frontends, :pickable], []), frontend) do + frontend + else + :primary + end + end + end + + def preferred_or_fallback(_conn, fallback), do: fallback + defp invalid_path?(list) do invalid_path?(list, :binary.compile_pattern(["/", "\\", ":", "\0"])) end diff --git a/lib/pleroma/web/plugs/instance_static.ex b/lib/pleroma/web/plugs/instance_static.ex index f82b9a098..d2a674d39 100644 --- a/lib/pleroma/web/plugs/instance_static.ex +++ b/lib/pleroma/web/plugs/instance_static.ex @@ -13,11 +13,11 @@ defmodule Pleroma.Web.Plugs.InstanceStatic do """ @behaviour Plug - def file_path(path) do + def file_path(path, frontend_type \\ :primary) do instance_path = Path.join(Pleroma.Config.get([:instance, :static_dir], "instance/static/"), path) - frontend_path = Pleroma.Web.Plugs.FrontendStatic.file_path(path, :primary) + frontend_path = Pleroma.Web.Plugs.FrontendStatic.file_path(path, frontend_type) (File.exists?(instance_path) && instance_path) || (frontend_path && File.exists?(frontend_path) && frontend_path) || diff --git a/lib/pleroma/web/push/subscription.ex b/lib/pleroma/web/push/subscription.ex index 6fc45bd61..c6fe69e84 100644 --- a/lib/pleroma/web/push/subscription.ex +++ b/lib/pleroma/web/push/subscription.ex @@ -26,7 +26,7 @@ defmodule Pleroma.Web.Push.Subscription do end # credo:disable-for-next-line Credo.Check.Readability.MaxLineLength - @supported_alert_types ~w[follow favourite mention reblog poll pleroma:chat_mention pleroma:emoji_reaction]a + @supported_alert_types ~w[follow favourite mention status reblog poll pleroma:chat_mention pleroma:emoji_reaction]a defp alerts(%{data: %{alerts: alerts}}) do alerts = Map.take(alerts, @supported_alert_types) diff --git a/lib/pleroma/web/rich_media/helpers.ex b/lib/pleroma/web/rich_media/helpers.ex index d4be97957..963076510 100644 --- a/lib/pleroma/web/rich_media/helpers.ex +++ b/lib/pleroma/web/rich_media/helpers.ex @@ -126,6 +126,11 @@ defmodule Pleroma.Web.RichMedia.Helpers do end defp req_headers do - [{"user-agent", Pleroma.Application.user_agent() <> "; Bot"}] + user_agent = Config.get([:rich_media, :user_agent], :default) + + case user_agent do + :default -> [{"user-agent", Pleroma.Application.user_agent() <> "; Bot"}] + custom -> [{"user-agent", custom}] + end end end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index cd9cfd3ed..7c1d97f63 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -561,6 +561,18 @@ defmodule Pleroma.Web.Router do get("/apps", AppController, :index) get("/statuses/:id/reactions/:emoji", EmojiReactionController, :index) get("/statuses/:id/reactions", EmojiReactionController, :index) + + get( + "/preferred_frontend/available", + FrontendSettingsController, + :available_frontends + ) + + put( + "/preferred_frontend", + FrontendSettingsController, + :update_preferred_frontend + ) end scope "/api/v0/pleroma", Pleroma.Web.PleromaAPI do @@ -581,6 +593,8 @@ defmodule Pleroma.Web.Router do delete("/chats/:id/messages/:message_id", ChatController, :delete_message) post("/chats/:id/read", ChatController, :mark_as_read) post("/chats/:id/messages/:message_id/read", ChatController, :mark_message_as_read) + post("/chats/:id/pin", ChatController, :pin) + post("/chats/:id/unpin", ChatController, :unpin) get("/conversations/:id/statuses", ConversationController, :statuses) get("/conversations/:id", ConversationController, :show) @@ -603,12 +617,13 @@ defmodule Pleroma.Web.Router do post("/bookmark_folders", BookmarkFolderController, :create) patch("/bookmark_folders/:id", BookmarkFolderController, :update) delete("/bookmark_folders/:id", BookmarkFolderController, :delete) + + get("/outgoing_follow_requests", FollowRequestController, :outgoing) end scope [] do pipe_through(:api) get("/accounts/:id/favourites", AccountController, :favourites) - get("/accounts/:id/endorsements", AccountController, :endorsements) get("/statuses/:id/quotes", StatusController, :quotes) end @@ -637,6 +652,11 @@ defmodule Pleroma.Web.Router do get("/accounts/:id/scrobbles", ScrobbleController, :index) end + scope "/api/v1/pleroma", Pleroma.Web.MastodonAPI do + pipe_through(:api) + get("/accounts/:id/endorsements", AccountController, :endorsements) + end + scope "/api/v2/pleroma", Pleroma.Web.PleromaAPI do scope [] do pipe_through(:authenticated_api) @@ -653,7 +673,7 @@ defmodule Pleroma.Web.Router do get("/accounts/relationships", AccountController, :relationships) get("/accounts/familiar_followers", AccountController, :familiar_followers) get("/accounts/:id/lists", AccountController, :lists) - get("/endorsements", AccountController, :endorsements) + get("/endorsements", AccountController, :own_endorsements) get("/blocks", AccountController, :blocks) get("/mutes", AccountController, :mutes) @@ -667,6 +687,8 @@ defmodule Pleroma.Web.Router do post("/accounts/:id/note", AccountController, :note) post("/accounts/:id/pin", AccountController, :endorse) post("/accounts/:id/unpin", AccountController, :unendorse) + post("/accounts/:id/endorse", AccountController, :endorse) + post("/accounts/:id/unendorse", AccountController, :unendorse) post("/accounts/:id/remove_from_followers", AccountController, :remove_from_followers) get("/conversations", ConversationController, :index) @@ -742,6 +764,7 @@ defmodule Pleroma.Web.Router do post("/statuses/:id/mute", StatusController, :mute_conversation) post("/statuses/:id/unmute", StatusController, :unmute_conversation) post("/statuses/:id/translate", StatusController, :translate) + get("/statuses/:id/quotes", StatusController, :quotes) post("/push/subscription", SubscriptionController, :create) get("/push/subscription", SubscriptionController, :show) @@ -782,6 +805,7 @@ defmodule Pleroma.Web.Router do get("/accounts/:id/statuses", AccountController, :statuses) get("/accounts/:id/followers", AccountController, :followers) get("/accounts/:id/following", AccountController, :following) + get("/accounts/:id/endorsements", AccountController, :endorsements) get("/accounts/:id", AccountController, :show) post("/accounts", AccountController, :create) @@ -894,7 +918,11 @@ defmodule Pleroma.Web.Router do scope "/", Pleroma.Web do pipe_through(:browser) + get("/mailer/unsubscribe/:token", Mailer.SubscriptionController, :unsubscribe) + + get("/frontend_switcher", FrontendSwitcher.FrontendSwitcherController, :switch) + post("/frontend_switcher", FrontendSwitcher.FrontendSwitcherController, :do_switch) end pipeline :ap_service_actor do diff --git a/lib/pleroma/web/streamer.ex b/lib/pleroma/web/streamer.ex index cc149e04c..aba42ee78 100644 --- a/lib/pleroma/web/streamer.ex +++ b/lib/pleroma/web/streamer.ex @@ -10,6 +10,7 @@ defmodule Pleroma.Web.Streamer do alias Pleroma.Chat.MessageReference alias Pleroma.Config alias Pleroma.Conversation.Participation + alias Pleroma.Marker alias Pleroma.Notification alias Pleroma.Object alias Pleroma.User @@ -321,6 +322,16 @@ defmodule Pleroma.Web.Streamer do end) end + defp do_stream(topic, %Marker{} = marker) do + Registry.dispatch(@registry, "#{topic}:#{marker.user_id}", fn list -> + Enum.each(list, fn {pid, _auth} -> + text = StreamerView.render("marker.json", marker) + + send(pid, {:text, text}) + end) + end) + end + defp do_stream(topic, item) do Logger.debug("Trying to push to #{topic}") Logger.debug("Pushing item to #{topic}") diff --git a/lib/pleroma/web/templates/frontend_switcher/frontend_switcher/switch.html.eex b/lib/pleroma/web/templates/frontend_switcher/frontend_switcher/switch.html.eex new file mode 100644 index 000000000..c801c8ee8 --- /dev/null +++ b/lib/pleroma/web/templates/frontend_switcher/frontend_switcher/switch.html.eex @@ -0,0 +1,7 @@ +

Switch frontend

+ +<%= form_for @conn, Routes.frontend_switcher_path(@conn, :do_switch), fn f -> %> + <%= select(f, :frontend, @choices) %> + + <%= submit do: "submit" %> +<% end %> diff --git a/lib/pleroma/web/views/streamer_view.ex b/lib/pleroma/web/views/streamer_view.ex index 079a37351..5806ba9ab 100644 --- a/lib/pleroma/web/views/streamer_view.ex +++ b/lib/pleroma/web/views/streamer_view.ex @@ -7,6 +7,7 @@ defmodule Pleroma.Web.StreamerView do alias Pleroma.Activity alias Pleroma.Conversation.Participation + alias Pleroma.Marker alias Pleroma.Notification alias Pleroma.User alias Pleroma.Web.MastodonAPI.NotificationView @@ -164,6 +165,19 @@ defmodule Pleroma.Web.StreamerView do |> Jason.encode!() end + def render("marker.json", %Marker{} = marker) do + %{ + event: "marker", + payload: + Pleroma.Web.MastodonAPI.MarkerView.render( + "markers.json", + markers: [marker] + ) + |> Jason.encode!() + } + |> Jason.encode!() + end + def render("pleroma_respond.json", %{type: type, result: result} = params) do %{ event: "pleroma:respond", diff --git a/priv/repo/migrations/20220222203933_add_pinned_to_chats.exs b/priv/repo/migrations/20220222203933_add_pinned_to_chats.exs new file mode 100644 index 000000000..2dc461d77 --- /dev/null +++ b/priv/repo/migrations/20220222203933_add_pinned_to_chats.exs @@ -0,0 +1,11 @@ +defmodule Pleroma.Repo.Migrations.AddPinnedToChats do + use Ecto.Migration + + def change do + alter table(:chats) do + add(:pinned, :boolean, default: false, null: false) + end + + create(index(:chats, [:pinned])) + end +end diff --git a/priv/repo/migrations/20230703215000_add_accepts_chat_messages_index_to_users.exs b/priv/repo/migrations/20230703215000_add_accepts_chat_messages_index_to_users.exs new file mode 100644 index 000000000..85a9f27b7 --- /dev/null +++ b/priv/repo/migrations/20230703215000_add_accepts_chat_messages_index_to_users.exs @@ -0,0 +1,7 @@ +defmodule Pleroma.Repo.Migrations.AddAcceptsChatMessagesIndexToUsers do + use Ecto.Migration + + def change do + create(index(:users, [:accepts_chat_messages])) + end +end diff --git a/priv/scrubbers/default.ex b/priv/scrubbers/default.ex index dad9dc1a1..0defdc74e 100644 --- a/priv/scrubbers/default.ex +++ b/priv/scrubbers/default.ex @@ -80,9 +80,13 @@ defmodule Pleroma.HTML.Scrubber.Default do Meta.allow_tag_with_this_attribute_values(:span, "class", [ "h-card", "recipients-inline", - "quote-inline" + "quote-inline", + "invisible", + "ellipsis" ]) + Meta.allow_tag_with_this_attribute_values(:p, "class", ["quote-inline"]) + Meta.allow_tag_with_these_attributes(:span, ["lang"]) Meta.allow_tag_with_this_attribute_values(:code, "class", ["inline"]) diff --git a/priv/static/robots.txt b/priv/static/robots.txt index 25781b7d7..4559309f4 100644 --- a/priv/static/robots.txt +++ b/priv/static/robots.txt @@ -1,2 +1,8 @@ -User-Agent: * -Disallow: +User-agent: * +Disallow: / + +User-agent: FediIndex +Allow: /nodeinfo/2.0 +Allow: /nodeinfo/2.1 +Allow: /nodeinfo/2.2 +Disallow: / \ No newline at end of file diff --git a/test/fixtures/quote_post/mastodon_quote_post.json b/test/fixtures/quote_post/mastodon_quote_post.json new file mode 100644 index 000000000..527a61b91 --- /dev/null +++ b/test/fixtures/quote_post/mastodon_quote_post.json @@ -0,0 +1,93 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + { + "ostatus": "http://ostatus.org#", + "atomUri": "ostatus:atomUri", + "inReplyToAtomUri": "ostatus:inReplyToAtomUri", + "conversation": "ostatus:conversation", + "sensitive": "as:sensitive", + "toot": "http://joinmastodon.org/ns#", + "votersCount": "toot:votersCount", + "quote": "https://w3id.org/fep/044f#quote", + "quoteUri": "http://fedibird.com/ns#quoteUri", + "_misskey_quote": "https://misskey-hub.net/ns#_misskey_quote", + "quoteAuthorization": { + "@id": "https://w3id.org/fep/044f#quoteAuthorization", + "@type": "@id" + }, + "gts": "https://gotosocial.org/ns#", + "interactionPolicy": { + "@id": "gts:interactionPolicy", + "@type": "@id" + }, + "canQuote": { + "@id": "gts:canQuote", + "@type": "@id" + }, + "automaticApproval": { + "@id": "gts:automaticApproval", + "@type": "@id" + }, + "manualApproval": { + "@id": "gts:manualApproval", + "@type": "@id" + } + } + ], + "id": "https://mastodon.social/users/gwynnion/statuses/115345489087257171", + "type": "Note", + "summary": null, + "inReplyTo": null, + "published": "2025-10-09T17:54:47Z", + "url": "https://mastodon.social/@gwynnion/115345489087257171", + "attributedTo": "https://mastodon.social/users/gwynnion", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "https://mastodon.social/users/gwynnion/followers" + ], + "sensitive": false, + "atomUri": "https://mastodon.social/users/gwynnion/statuses/115345489087257171", + "inReplyToAtomUri": null, + "conversation": "https://mastodon.social/contexts/109836797527169643-115345489087257171", + "context": "https://mastodon.social/contexts/109836797527169643-115345489087257171", + "content": "

RE: https://mastodon.social/@404mediaco/115344945575874225

Every age verification system is just a scheme for companies and hackers to steal your identity.

", + "contentMap": { + "en": "

RE: https://mastodon.social/@404mediaco/115344945575874225

Every age verification system is just a scheme for companies and hackers to steal your identity.

" + }, + "quote": "https://mastodon.social/users/404mediaco/statuses/115344945575874225", + "_misskey_quote": "https://mastodon.social/users/404mediaco/statuses/115344945575874225", + "quoteUri": "https://mastodon.social/users/404mediaco/statuses/115344945575874225", + "quoteAuthorization": "https://mastodon.social/users/404mediaco/quote_authorizations/115345489087269783", + "interactionPolicy": { + "canQuote": { + "automaticApproval": [ + "https://www.w3.org/ns/activitystreams#Public" + ] + } + }, + "attachment": [], + "tag": [], + "replies": { + "id": "https://mastodon.social/users/gwynnion/statuses/115345489087257171/replies", + "type": "Collection", + "first": { + "type": "CollectionPage", + "next": "https://mastodon.social/users/gwynnion/statuses/115345489087257171/replies?only_other_accounts=true&page=true", + "partOf": "https://mastodon.social/users/gwynnion/statuses/115345489087257171/replies", + "items": [] + } + }, + "likes": { + "id": "https://mastodon.social/users/gwynnion/statuses/115345489087257171/likes", + "type": "Collection", + "totalItems": 26 + }, + "shares": { + "id": "https://mastodon.social/users/gwynnion/statuses/115345489087257171/shares", + "type": "Collection", + "totalItems": 28 + } +} diff --git a/test/fixtures/users_mock/href_as_array.json b/test/fixtures/users_mock/href_as_array.json new file mode 100644 index 000000000..624612bce --- /dev/null +++ b/test/fixtures/users_mock/href_as_array.json @@ -0,0 +1,41 @@ +{ + "alsoKnownAs": [], + "attachment": [], + "capabilities": {}, + "discoverable": true, + "endpoints": {}, + "featured": "https://queef.in/cute_cat/collections/featured", + "followers": "https://queef.in/cute_cat/followers", + "following": "https://queef.in/cute_cat/following", + "icon": { + "type": "Image", + "url": [ + "https://queef.in/storage/profile.webp", + "https://example.com/image" + ] + }, + "id": "https://queef.in/cute_cat", + "image": { + "type": "Image", + "url": [ + "https://queef.in/storage/banner.gif", + "https://example.com/image" + ] + }, + "inbox": "https://queef.in/cute_cat/inbox", + "manuallyApprovesFollowers": false, + "name": "cute_cat", + "outbox": "https://queef.in/cute_cat/outbox", + "preferredUsername": "cute_cat", + "publicKey": { + "id": "https://queef.in/cute_cat#main-key", + "owner": "https://queef.in/cute_cat" + }, + "published": "2025-08-18T01:16:10.000Z", + "summary": "A cute cat", + "tag": [], + "type": "Person", + "url": "https://queef.in/cute_cat", + "vcard:bday": null, + "webfinger": "acct:cute_cat@queef.in" +} diff --git a/test/pleroma/http_test.exs b/test/pleroma/http_test.exs index 61347015d..7b6847cf9 100644 --- a/test/pleroma/http_test.exs +++ b/test/pleroma/http_test.exs @@ -5,8 +5,11 @@ defmodule Pleroma.HTTPTest do use ExUnit.Case, async: true use Pleroma.Tests.Helpers + import Tesla.Mock + alias Pleroma.HTTP + alias Pleroma.Utils.URIEncoding setup do mock(fn @@ -28,6 +31,36 @@ defmodule Pleroma.HTTPTest do %{method: :get, url: "https://example.com/emoji/Pack%201/koronebless.png?foo=bar+baz"} -> %Tesla.Env{status: 200, body: "emoji data"} + + %{ + method: :get, + url: "https://example.com/media/foo/bar%20!$&'()*+,;=/:%20@a%20%5Bbaz%5D.mp4" + } -> + %Tesla.Env{status: 200, body: "video data"} + + %{method: :get, url: "https://example.com/media/unicode%20%F0%9F%99%82%20.gif"} -> + %Tesla.Env{status: 200, body: "unicode data"} + + %{ + method: :get, + url: + "https://i.guim.co.uk/img/media/1069ef13c447908272c4de94174cec2b6352cb2f/0_91_2000_1201/master/2000.jpg?width=1200&height=630&quality=85&auto=format&fit=crop&precrop=40:21,offset-x50,offset-y0&overlay-align=bottom%2Cleft&overlay-width=100p&overlay-base64=L2ltZy9zdGF0aWMvb3ZlcmxheXMvdGctb3BpbmlvbnMtYWdlLTIwMTkucG5n&enable=upscale&s=cba21427a73512fdc9863c486c03fdd8" + } -> + %Tesla.Env{status: 200, body: "Guardian image quirk"} + + %{ + method: :get, + url: + "https://i.guim.co.uk/emoji/Pack%201/koronebless.png?precrop=40:21,overlay-x0,overlay-y0&foo=bar+baz" + } -> + %Tesla.Env{status: 200, body: "Space in query with Guardian quirk"} + + %{ + method: :get, + url: + "https://examplebucket.s3.amazonaws.com/test.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=accessKEY%2F20130721%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20130721T201207Z&X-Amz-Expires=86400&X-Amz-Signature=SIGNATURE&X-Amz-SignedHeaders=host" + } -> + %Tesla.Env{status: 200, body: "AWS S3 data"} end) :ok @@ -85,5 +118,100 @@ defmodule Pleroma.HTTPTest do {:ok, result} = HTTP.get(properly_encoded_url) assert result.status == 200 + + url_with_reserved_chars = "https://example.com/media/foo/bar !$&'()*+,;=/: @a [baz].mp4" + + {:ok, result} = HTTP.get(url_with_reserved_chars) + + assert result.status == 200 + + url_with_unicode = "https://example.com/media/unicode 🙂 .gif" + + {:ok, result} = HTTP.get(url_with_unicode) + + assert result.status == 200 + end + + test "decodes URL first by default" do + clear_config(:test_url_encoding, true) + + normal_url = "https://example.com/media/file%20with%20space.jpg?name=a+space.jpg" + + result = URIEncoding.encode_url(normal_url) + + assert result == "https://example.com/media/file%20with%20space.jpg?name=a+space.jpg" + end + + test "doesn't decode URL first when specified" do + clear_config(:test_url_encoding, true) + + normal_url = "https://example.com/media/file%20with%20space.jpg" + + result = URIEncoding.encode_url(normal_url, bypass_decode: true) + + assert result == "https://example.com/media/file%2520with%2520space.jpg" + end + + test "properly applies Guardian image query quirk" do + clear_config(:test_url_encoding, true) + + url = + "https://i.guim.co.uk/img/media/1069ef13c447908272c4de94174cec2b6352cb2f/0_91_2000_1201/master/2000.jpg?width=1200&height=630&quality=85&auto=format&fit=crop&precrop=40:21,offset-x50,offset-y0&overlay-align=bottom%2Cleft&overlay-width=100p&overlay-base64=L2ltZy9zdGF0aWMvb3ZlcmxheXMvdGctb3BpbmlvbnMtYWdlLTIwMTkucG5n&enable=upscale&s=cba21427a73512fdc9863c486c03fdd8" + + result = URIEncoding.encode_url(url) + + assert result == url + + {:ok, result_get} = HTTP.get(result) + + assert result_get.status == 200 + end + + test "properly encodes spaces as \"pluses\" in query when using quirks" do + clear_config(:test_url_encoding, true) + + url = + "https://i.guim.co.uk/emoji/Pack 1/koronebless.png?precrop=40:21,overlay-x0,overlay-y0&foo=bar baz" + + properly_encoded_url = + "https://i.guim.co.uk/emoji/Pack%201/koronebless.png?precrop=40:21,overlay-x0,overlay-y0&foo=bar+baz" + + result = URIEncoding.encode_url(url) + + assert result == properly_encoded_url + + {:ok, result_get} = HTTP.get(result) + + assert result_get.status == 200 + end + + test "properly encode AWS S3 queries" do + clear_config(:test_url_encoding, true) + + url = + "https://examplebucket.s3.amazonaws.com/test.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=accessKEY%2F20130721%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20130721T201207Z&X-Amz-Expires=86400&X-Amz-Signature=SIGNATURE&X-Amz-SignedHeaders=host" + + unencoded_url = + "https://examplebucket.s3.amazonaws.com/test.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=accessKEY/20130721/us-east-1/s3/aws4_request&X-Amz-Date=20130721T201207Z&X-Amz-Expires=86400&X-Amz-Signature=SIGNATURE&X-Amz-SignedHeaders=host" + + result = URIEncoding.encode_url(url) + result_unencoded = URIEncoding.encode_url(unencoded_url) + + assert result == url + assert result == result_unencoded + + {:ok, result_get} = HTTP.get(result) + + assert result_get.status == 200 + end + + test "preserves query key order" do + clear_config(:test_url_encoding, true) + + url = "https://example.com/foo?hjkl=qwertz&xyz=abc&bar=baz" + + result = URIEncoding.encode_url(url) + + assert result == url end end diff --git a/test/pleroma/language/translation/translate_locally_test.exs b/test/pleroma/language/translation/translate_locally_test.exs new file mode 100644 index 000000000..51cbd11bd --- /dev/null +++ b/test/pleroma/language/translation/translate_locally_test.exs @@ -0,0 +1,59 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2024 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Language.Translation.TranslateLocallyTest do + use Pleroma.DataCase + + alias Pleroma.Language.Translation.TranslateLocally + + @example_models %{ + "de" => %{ + "en" => "de-en-base" + }, + "en" => %{ + "de" => "en-de-base", + "pl" => "en-pl-tiny" + }, + "cs" => %{ + "en" => "cs-en-base" + }, + "pl" => %{ + "en" => "pl-en-tiny" + } + } + + test "it returns languages list" do + clear_config([Pleroma.Language.Translation.TranslateLocally, :models], @example_models) + + assert {:ok, languages} = TranslateLocally.supported_languages(:source) + assert ["cs", "de", "en", "pl"] = languages |> Enum.sort() + end + + describe "it returns languages matrix" do + test "without intermediary language" do + clear_config([Pleroma.Language.Translation.TranslateLocally, :models], @example_models) + + assert {:ok, + %{ + "cs" => ["en"], + "de" => ["en"], + "en" => ["de", "pl"], + "pl" => ["en"] + }} = TranslateLocally.languages_matrix() + end + + test "with intermediary language" do + clear_config([Pleroma.Language.Translation.TranslateLocally, :models], @example_models) + clear_config([Pleroma.Language.Translation.TranslateLocally, :intermediary_language], "en") + + assert {:ok, + %{ + "cs" => ["de", "en", "pl"], + "de" => ["en", "pl"], + "en" => ["de", "pl"], + "pl" => ["de", "en"] + }} = TranslateLocally.languages_matrix() + end + end +end diff --git a/test/pleroma/notification_test.exs b/test/pleroma/notification_test.exs index 4b20e07cf..a2be2ae49 100644 --- a/test/pleroma/notification_test.exs +++ b/test/pleroma/notification_test.exs @@ -17,6 +17,7 @@ defmodule Pleroma.NotificationTest do alias Pleroma.Web.ActivityPub.Transmogrifier alias Pleroma.Web.CommonAPI alias Pleroma.Web.MastodonAPI.NotificationView + alias Pleroma.Web.Streamer setup do Mox.stub_with(Pleroma.UnstubbedConfigMock, Pleroma.Test.StaticConfig) @@ -446,8 +447,7 @@ defmodule Pleroma.NotificationTest do describe "set_read_up_to()" do test "it sets all notifications as read up to a specified notification ID" do - user = insert(:user) - other_user = insert(:user) + [user, other_user] = insert_pair(:user) {:ok, _activity} = CommonAPI.post(user, %{ @@ -486,6 +486,37 @@ defmodule Pleroma.NotificationTest do assert m.last_read_id == to_string(n2.id) end + + @tag needs_streamer: true + test "it sends updated marker to the 'user' and the 'user:notification' stream" do + %{user: user, token: oauth_token} = oauth_access(["read"]) + other_user = insert(:user) + + {:ok, _activity} = + CommonAPI.post(other_user, %{ + status: "hi @#{user.nickname}!" + }) + + [%{id: notification_id}] = Notification.for_user(user) + + notification_id = to_string(notification_id) + + task = + Task.async(fn -> + {:ok, _topic} = + Streamer.get_topic_and_add_socket("user:notification", user, oauth_token) + + assert_receive {:text, event}, 4_000 + + assert %{"event" => "marker", "payload" => payload} = Jason.decode!(event) + + assert %{"notifications" => %{"last_read_id" => ^notification_id}} = + Jason.decode!(payload) + end) + + Notification.set_read_up_to(user, notification_id) + Task.await(task) + end end describe "for_user_since/2" do diff --git a/test/pleroma/reverse_proxy_test.exs b/test/pleroma/reverse_proxy_test.exs index 034ab28a5..8dbe9c6bf 100644 --- a/test/pleroma/reverse_proxy_test.exs +++ b/test/pleroma/reverse_proxy_test.exs @@ -396,18 +396,29 @@ defmodule Pleroma.ReverseProxyTest do end end - # Hackey is used for Reverse Proxy when Hackney or Finch is the Tesla Adapter + # Hackney is used for Reverse Proxy when Hackney or Finch is the Tesla Adapter # Gun is able to proxy through Tesla, so it does not need testing as the # test cases in the Pleroma.HTTPTest module are sufficient describe "Hackney URL encoding:" do setup do ClientMock - |> expect(:request, fn :get, - "https://example.com/emoji/Pack%201/koronebless.png?foo=bar+baz", - _headers, - _body, - _opts -> - {:ok, 200, [{"content-type", "image/png"}], "It works!"} + |> expect(:request, fn + :get, + "https://example.com/emoji/Pack%201/koronebless.png?foo=bar+baz", + _headers, + _body, + _opts -> + {:ok, 200, [{"content-type", "image/png"}], "It works!"} + + :get, + "https://example.com/media/foo/bar%20!$&'()*+,;=/:%20@a%20%5Bbaz%5D.mp4", + _headers, + _body, + _opts -> + {:ok, 200, [{"content-type", "video/mp4"}], "Allowed reserved chars."} + + :get, "https://example.com/media/unicode%20%F0%9F%99%82%20.gif", _headers, _body, _opts -> + {:ok, 200, [{"content-type", "image/gif"}], "Unicode emoji in path"} end) |> stub(:stream_body, fn _ -> :done end) |> stub(:close, fn _ -> :ok end) @@ -430,5 +441,21 @@ defmodule Pleroma.ReverseProxyTest do assert result.status == 200 end + + test "properly encodes URLs with allowed reserved characters", %{conn: conn} do + url_with_reserved_chars = "https://example.com/media/foo/bar !$&'()*+,;=/: @a [baz].mp4" + + result = ReverseProxy.call(conn, url_with_reserved_chars) + + assert result.status == 200 + end + + test "properly encodes URLs with unicode in path", %{conn: conn} do + url_with_unicode = "https://example.com/media/unicode 🙂 .gif" + + result = ReverseProxy.call(conn, url_with_unicode) + + assert result.status == 200 + end end end diff --git a/test/pleroma/upload_test.exs b/test/pleroma/upload_test.exs index 5fd62fa43..9a51c612b 100644 --- a/test/pleroma/upload_test.exs +++ b/test/pleroma/upload_test.exs @@ -227,20 +227,35 @@ defmodule Pleroma.UploadTest do assert Path.basename(attachment_url["href"]) == "an%E2%80%A6%20image.jpg" end - test "escapes reserved uri characters" do + test "escapes disallowed reserved characters in uri path" do File.cp!("test/fixtures/image.jpg", "test/fixtures/image_tmp.jpg") file = %Plug.Upload{ content_type: "image/jpeg", path: Path.absname("test/fixtures/image_tmp.jpg"), - filename: ":?#[]@!$&\\'()*+,;=.jpg" + filename: ":?#[]@!$&'()*+,;=.jpg" } {:ok, data} = Upload.store(file) [attachment_url | _] = data["url"] assert Path.basename(attachment_url["href"]) == - "%3A%3F%23%5B%5D%40%21%24%26%5C%27%28%29%2A%2B%2C%3B%3D.jpg" + ":%3F%23%5B%5D@!$&'()*+,;=.jpg" + end + + test "double %-encodes filename" do + File.cp!("test/fixtures/image.jpg", "test/fixtures/image_tmp.jpg") + + file = %Plug.Upload{ + content_type: "image/jpeg", + path: Path.absname("test/fixtures/image_tmp.jpg"), + filename: "file with %20.jpg" + } + + {:ok, data} = Upload.store(file) + [attachment_url | _] = data["url"] + + assert Path.basename(attachment_url["href"]) == "file%20with%20%2520.jpg" end end @@ -267,4 +282,23 @@ defmodule Pleroma.UploadTest do refute String.starts_with?(url, base_url <> "/media/") end end + + describe "Setting a link_name for uploaded media" do + setup do: clear_config([Pleroma.Upload, :link_name], true) + + test "encodes name parameter in query" do + File.cp!("test/fixtures/image.jpg", "test/fixtures/image_tmp.jpg") + + file = %Plug.Upload{ + content_type: "image/jpeg", + path: Path.absname("test/fixtures/image_tmp.jpg"), + filename: "test file.jpg" + } + + {:ok, data} = Upload.store(file) + [attachment_url | _] = data["url"] + + assert Path.basename(attachment_url["href"]) == "test%20file.jpg?name=test+file.jpg" + end + end end diff --git a/test/pleroma/user_search_test.exs b/test/pleroma/user_search_test.exs index 1af9a1493..75cfbd2db 100644 --- a/test/pleroma/user_search_test.exs +++ b/test/pleroma/user_search_test.exs @@ -366,5 +366,13 @@ defmodule Pleroma.UserSearchTest do assert user == result |> Map.put(:search_rank, nil) |> Map.put(:search_type, nil) end + + test "find users accepting chat messages only" do + user1 = insert(:user, nickname: "user1", accepts_chat_messages: true) + insert(:user, nickname: "user2", accepts_chat_messages: false) + + [found_user1] = User.search("user", capabilities: ["accepts_chat_messages"]) + assert found_user1.id == user1.id + end end end diff --git a/test/pleroma/user_test.exs b/test/pleroma/user_test.exs index 0b4dc9197..b2533e9f1 100644 --- a/test/pleroma/user_test.exs +++ b/test/pleroma/user_test.exs @@ -1881,6 +1881,11 @@ defmodule Pleroma.UserTest do end end + test "get_or_fetch_public_key_for_ap_id fetches a user that's not in the db" do + assert {:ok, _key} = + User.get_or_fetch_public_key_for_ap_id("http://mastodon.example.org/users/admin") + end + test "get_public_key_for_ap_id returns correctly for user that's not in the db" do assert :error = User.get_public_key_for_ap_id("http://mastodon.example.org/users/admin") end diff --git a/test/pleroma/web/activity_pub/activity_pub_test.exs b/test/pleroma/web/activity_pub/activity_pub_test.exs index 6c2a6ccf8..73f53db56 100644 --- a/test/pleroma/web/activity_pub/activity_pub_test.exs +++ b/test/pleroma/web/activity_pub/activity_pub_test.exs @@ -465,6 +465,40 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do end end + test "works with avatar/banner href as list" do + user_id = "https://queef.in/cute_cat" + + user_data = + "test/fixtures/users_mock/href_as_array.json" + |> File.read!() + |> Jason.decode!() + |> Map.delete("featured") + |> Jason.encode!() + + Tesla.Mock.mock(fn + %{ + method: :get, + url: ^user_id + } -> + %Tesla.Env{ + status: 200, + body: user_data, + headers: [{"content-type", "application/activity+json"}] + } + end) + + {:ok, user} = ActivityPub.make_user_from_ap_id(user_id) + + assert length(user.avatar["url"]) == 1 + assert length(user.banner["url"]) == 1 + + assert user.avatar["url"] |> List.first() |> Map.fetch!("href") == + "https://queef.in/storage/profile.webp" + + assert user.banner["url"] |> List.first() |> Map.fetch!("href") == + "https://queef.in/storage/banner.gif" + end + test "it fetches the appropriate tag-restricted posts" do user = insert(:user) diff --git a/test/pleroma/web/activity_pub/mrf/inline_quote_policy_test.exs b/test/pleroma/web/activity_pub/mrf/inline_quote_policy_test.exs index d5762766f..c03787abb 100644 --- a/test/pleroma/web/activity_pub/mrf/inline_quote_policy_test.exs +++ b/test/pleroma/web/activity_pub/mrf/inline_quote_policy_test.exs @@ -109,4 +109,22 @@ defmodule Pleroma.Web.ActivityPub.MRF.InlineQuotePolicyTest do {:ok, filtered} = InlineQuotePolicy.filter(activity) assert filtered == activity end + + # Mastodon uses p tags instead of span in their quote posts + # URLs in quoteUri and post content are already mismatched + test "skips objects which already have an .inline-quote p" do + object = File.read!("test/fixtures/quote_post/mastodon_quote_post.json") |> Jason.decode!() + + # Normally the ObjectValidator will fix this before it reaches MRF + object = Map.put(object, "quoteUrl", object["quoteUri"]) + + activity = %{ + "type" => "Create", + "actor" => "https://mastodon.social/users/gwynnion", + "object" => object + } + + {:ok, filtered} = InlineQuotePolicy.filter(activity) + assert filtered == activity + end end diff --git a/test/pleroma/web/feed/user_controller_test.exs b/test/pleroma/web/feed/user_controller_test.exs index 0a3aaff5c..916531cd5 100644 --- a/test/pleroma/web/feed/user_controller_test.exs +++ b/test/pleroma/web/feed/user_controller_test.exs @@ -282,6 +282,21 @@ defmodule Pleroma.Web.Feed.UserControllerTest do "#{Pleroma.Web.Endpoint.url()}/users/#{user.nickname}/feed.atom" end + test "redirects to rss feed when explicitly requested", %{conn: conn} do + note_activity = insert(:note_activity) + user = User.get_cached_by_ap_id(note_activity.data["actor"]) + + conn = + conn + |> put_req_header("accept", "application/xml") + |> get("/users/#{user.nickname}.rss") + + assert conn.status == 302 + + assert redirected_to(conn) == + "#{Pleroma.Web.Endpoint.url()}/users/#{user.nickname}/feed.rss" + end + test "with non-html / non-json format, it returns error when user is not found", %{conn: conn} do response = conn diff --git a/test/pleroma/web/mastodon_api/controllers/account_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/account_controller_test.exs index 2d91964da..02da781dd 100644 --- a/test/pleroma/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/account_controller_test.exs @@ -2104,6 +2104,50 @@ defmodule Pleroma.Web.MastodonAPI.AccountControllerTest do |> json_response_and_validate_schema(404) end + test "account lookup with restrict unauthenticated profiles for local" do + clear_config([:restrict_unauthenticated, :profiles, :local], true) + + user = insert(:user, local: true) + reading_user = insert(:user) + + conn = + build_conn() + |> get("/api/v1/accounts/lookup?acct=#{user.nickname}") + + assert json_response_and_validate_schema(conn, 401) + + conn = + build_conn() + |> assign(:user, reading_user) + |> assign(:token, insert(:oauth_token, user: reading_user, scopes: ["read:accounts"])) + |> get("/api/v1/accounts/lookup?acct=#{user.nickname}") + + assert %{"id" => id} = json_response_and_validate_schema(conn, 200) + assert id == user.id + end + + test "account lookup with restrict unauthenticated profiles for remote" do + clear_config([:restrict_unauthenticated, :profiles, :remote], true) + + user = insert(:user, nickname: "user@example.com", local: false) + reading_user = insert(:user) + + conn = + build_conn() + |> get("/api/v1/accounts/lookup?acct=#{user.nickname}") + + assert json_response_and_validate_schema(conn, 401) + + conn = + build_conn() + |> assign(:user, reading_user) + |> assign(:token, insert(:oauth_token, user: reading_user, scopes: ["read:accounts"])) + |> get("/api/v1/accounts/lookup?acct=#{user.nickname}") + + assert %{"id" => id} = json_response_and_validate_schema(conn, 200) + assert id == user.id + end + test "create a note on a user" do %{conn: conn} = oauth_access(["write:accounts", "read:follows"]) other_user = insert(:user) @@ -2134,7 +2178,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountControllerTest do assert %{"id" => ^id1, "endorsed" => true} = conn |> put_req_header("content-type", "application/json") - |> post("/api/v1/accounts/#{id1}/pin") + |> post("/api/v1/accounts/#{id1}/endorse") |> json_response_and_validate_schema(200) assert [%{"id" => ^id1}] = @@ -2153,7 +2197,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountControllerTest do assert %{"id" => ^id1, "endorsed" => false} = conn |> put_req_header("content-type", "application/json") - |> post("/api/v1/accounts/#{id1}/unpin") + |> post("/api/v1/accounts/#{id1}/unendorse") |> json_response_and_validate_schema(200) assert [] = @@ -2172,15 +2216,40 @@ defmodule Pleroma.Web.MastodonAPI.AccountControllerTest do conn |> put_req_header("content-type", "application/json") - |> post("/api/v1/accounts/#{id1}/pin") + |> post("/api/v1/accounts/#{id1}/endorse") |> json_response_and_validate_schema(200) assert %{"error" => "You have already pinned the maximum number of users"} = conn |> assign(:user, user) - |> post("/api/v1/accounts/#{id2}/pin") + |> post("/api/v1/accounts/#{id2}/endorse") |> json_response_and_validate_schema(400) end + + test "returns a list of pinned accounts", %{conn: conn} do + clear_config([:instance, :max_endorsed_users], 3) + + %{id: id1} = user1 = insert(:user) + %{id: id2} = user2 = insert(:user) + %{id: id3} = user3 = insert(:user) + + CommonAPI.follow(user2, user1) + CommonAPI.follow(user3, user1) + + User.endorse(user1, user2) + User.endorse(user1, user3) + + [%{"id" => ^id2}, %{"id" => ^id3}] = + conn + |> get("/api/v1/accounts/#{id1}/endorsements") + |> json_response_and_validate_schema(200) + end + + test "returns 404 error when specified user is not exist", %{conn: conn} do + conn = get(conn, "/api/v1/accounts/test/endorsements") + + assert json_response_and_validate_schema(conn, 404) == %{"error" => "Record not found"} + end end describe "familiar followers" 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 8a0fe5259..10c0b6ea7 100644 --- a/test/pleroma/web/mastodon_api/controllers/instance_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/instance_controller_test.exs @@ -194,4 +194,28 @@ defmodule Pleroma.Web.MastodonAPI.InstanceControllerTest do refute Map.has_key?(result["pleroma"]["metadata"]["base_urls"], "media_proxy") refute Map.has_key?(result["pleroma"]["metadata"]["base_urls"], "upload") end + + test "display timeline access restrictions", %{conn: conn} do + clear_config([:restrict_unauthenticated, :timelines, :local], true) + clear_config([:restrict_unauthenticated, :timelines, :federated], false) + + conn = get(conn, "/api/v2/instance") + + assert result = json_response_and_validate_schema(conn, 200) + + assert result["configuration"]["timelines_access"] == %{ + "live_feeds" => %{ + "local" => "authenticated", + "remote" => "public" + }, + "hashtag_feeds" => %{ + "local" => "authenticated", + "remote" => "public" + }, + "trending_link_feeds" => %{ + "local" => "disabled", + "remote" => "disabled" + } + } + end end 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 25a17d5c1..5c24df864 100644 --- a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs @@ -1867,18 +1867,29 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do %{activity: activity} end - test "returns users who have favorited the status", %{conn: conn, activity: activity} do - other_user = insert(:user) - {:ok, _} = CommonAPI.favorite(activity.id, other_user) + test "returns users who have favorited the status ordered from newest to oldest", %{ + conn: conn, + activity: activity + } do + [other_user_1, other_user_2] = insert_pair(:user) + [other_user_3, other_user_4] = insert_pair(:user) + + {:ok, _} = CommonAPI.favorite(activity.id, other_user_1) + {:ok, _} = CommonAPI.favorite(activity.id, other_user_3) + {:ok, _} = CommonAPI.favorite(activity.id, other_user_2) + {:ok, _} = CommonAPI.favorite(activity.id, other_user_4) response = conn |> get("/api/v1/statuses/#{activity.id}/favourited_by") |> json_response_and_validate_schema(:ok) - [%{"id" => id}] = response + [%{"id" => id1}, %{"id" => id2}, %{"id" => id3}, %{"id" => id4}] = response - assert id == other_user.id + assert id1 == other_user_4.id + assert id2 == other_user_2.id + assert id3 == other_user_3.id + assert id4 == other_user_1.id end test "returns empty array when status has not been favorited yet", %{ @@ -2541,4 +2552,47 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do |> json_response_and_validate_schema(404) end end + + describe "getting quotes of a specified post" do + setup do + [current_user, user] = insert_pair(:user) + %{user: current_user, conn: conn} = oauth_access(["read:statuses"], user: current_user) + [current_user: current_user, user: user, conn: conn] + end + + test "shows quotes of a post", %{conn: conn} do + user = insert(:user) + activity = insert(:note_activity) + + {:ok, quote_post} = CommonAPI.post(user, %{status: "quoat", quote_id: activity.id}) + + response = + conn + |> get("/api/v1/statuses/#{activity.id}/quotes") + |> json_response_and_validate_schema(:ok) + + [status] = response + + assert length(response) == 1 + assert status["id"] == quote_post.id + end + + test "returns 404 error when a post can't be seen", %{conn: conn} do + activity = insert(:direct_note_activity) + + response = + conn + |> get("/api/v1/statuses/#{activity.id}/quotes") + + assert json_response_and_validate_schema(response, 404) == %{"error" => "Record not found"} + end + + test "returns 404 error when a post does not exist", %{conn: conn} do + response = + conn + |> get("/api/v1/statuses/idontexist/quotes") + + assert json_response_and_validate_schema(response, 404) == %{"error" => "Record not found"} + end + 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 863e0349c..d7908886b 100644 --- a/test/pleroma/web/mastodon_api/views/status_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/status_view_test.exs @@ -344,7 +344,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do quotes_count: 0, bookmark_folder: nil, list_id: nil - } + }, + quotes_count: 0 } assert status == expected diff --git a/test/pleroma/web/media_proxy_test.exs b/test/pleroma/web/media_proxy_test.exs index 718892665..86d30c04c 100644 --- a/test/pleroma/web/media_proxy_test.exs +++ b/test/pleroma/web/media_proxy_test.exs @@ -73,7 +73,7 @@ defmodule Pleroma.Web.MediaProxyTest do end test "encodes and decodes URL and ignores query params for the path" do - url = "https://pleroma.soykaf.com/static/logo.png?93939393939&bunny=true" + url = "https://pleroma.soykaf.com/static/logo.png?93939393939=&bunny=true" encoded = MediaProxy.url(url) assert String.ends_with?(encoded, "/logo.png") assert decode_result(encoded) == url @@ -159,18 +159,6 @@ defmodule Pleroma.Web.MediaProxyTest do assert String.starts_with?(encoded, base_url) end - # Some sites expect ASCII encoded characters in the URL to be preserved even if - # unnecessary. - # Issues: https://git.pleroma.social/pleroma/pleroma/issues/580 - # https://git.pleroma.social/pleroma/pleroma/issues/1055 - test "preserve ASCII encoding" do - url = - "https://pleroma.com/%20/%21/%22/%23/%24/%25/%26/%27/%28/%29/%2A/%2B/%2C/%2D/%2E/%2F/%30/%31/%32/%33/%34/%35/%36/%37/%38/%39/%3A/%3B/%3C/%3D/%3E/%3F/%40/%41/%42/%43/%44/%45/%46/%47/%48/%49/%4A/%4B/%4C/%4D/%4E/%4F/%50/%51/%52/%53/%54/%55/%56/%57/%58/%59/%5A/%5B/%5C/%5D/%5E/%5F/%60/%61/%62/%63/%64/%65/%66/%67/%68/%69/%6A/%6B/%6C/%6D/%6E/%6F/%70/%71/%72/%73/%74/%75/%76/%77/%78/%79/%7A/%7B/%7C/%7D/%7E/%7F/%80/%81/%82/%83/%84/%85/%86/%87/%88/%89/%8A/%8B/%8C/%8D/%8E/%8F/%90/%91/%92/%93/%94/%95/%96/%97/%98/%99/%9A/%9B/%9C/%9D/%9E/%9F/%C2%A0/%A1/%A2/%A3/%A4/%A5/%A6/%A7/%A8/%A9/%AA/%AB/%AC/%C2%AD/%AE/%AF/%B0/%B1/%B2/%B3/%B4/%B5/%B6/%B7/%B8/%B9/%BA/%BB/%BC/%BD/%BE/%BF/%C0/%C1/%C2/%C3/%C4/%C5/%C6/%C7/%C8/%C9/%CA/%CB/%CC/%CD/%CE/%CF/%D0/%D1/%D2/%D3/%D4/%D5/%D6/%D7/%D8/%D9/%DA/%DB/%DC/%DD/%DE/%DF/%E0/%E1/%E2/%E3/%E4/%E5/%E6/%E7/%E8/%E9/%EA/%EB/%EC/%ED/%EE/%EF/%F0/%F1/%F2/%F3/%F4/%F5/%F6/%F7/%F8/%F9/%FA/%FB/%FC/%FD/%FE/%FF" - - encoded = MediaProxy.url(url) - assert decode_result(encoded) == url - end - # This includes unsafe/reserved characters which are not interpreted as part of the URL # and would otherwise have to be ASCII encoded. It is our role to ensure the proxied URL # is unmodified, so we are testing these characters anyway. @@ -182,11 +170,30 @@ defmodule Pleroma.Web.MediaProxyTest do assert decode_result(encoded) == url end - test "preserve unicode characters" do + # Improperly encoded URLs should not happen even when input was wrong. + test "does not preserve unicode characters" do url = "https://ko.wikipedia.org/wiki/위키백과:대문" + encoded_url = + "https://ko.wikipedia.org/wiki/%EC%9C%84%ED%82%A4%EB%B0%B1%EA%B3%BC:%EB%8C%80%EB%AC%B8" + encoded = MediaProxy.url(url) - assert decode_result(encoded) == url + assert decode_result(encoded) == encoded_url + end + + # If we preserve wrongly encoded URLs in MediaProxy, it will get fixed + # when we GET these URLs and will result in 424 when MediaProxy previews are enabled. + test "does not preserve incorrect URLs when making MediaProxy link" do + incorrect_original_url = "https://example.com/media/cofe%20%28with%20milk%29.png" + corrected_original_url = "https://example.com/media/cofe%20(with%20milk).png" + + unpreserved_encoded_original_url = + "http://localhost:4001/proxy/Sv6tt6xjA72_i4d8gXbuMAOXQSs/aHR0cHM6Ly9leGFtcGxlLmNvbS9tZWRpYS9jb2ZlJTIwKHdpdGglMjBtaWxrKS5wbmc/cofe%20(with%20milk).png" + + encoded = MediaProxy.url(incorrect_original_url) + + assert encoded == unpreserved_encoded_original_url + assert decode_result(encoded) == corrected_original_url end end diff --git a/test/pleroma/web/pleroma_api/controllers/account_controller_test.exs b/test/pleroma/web/pleroma_api/controllers/account_controller_test.exs index d152a44cd..38a928f30 100644 --- a/test/pleroma/web/pleroma_api/controllers/account_controller_test.exs +++ b/test/pleroma/web/pleroma_api/controllers/account_controller_test.exs @@ -280,35 +280,6 @@ defmodule Pleroma.Web.PleromaAPI.AccountControllerTest do end end - describe "account endorsements" do - test "returns a list of pinned accounts", %{conn: conn} do - %{id: id1} = user1 = insert(:user) - %{id: id2} = user2 = insert(:user) - %{id: id3} = user3 = insert(:user) - - CommonAPI.follow(user2, user1) - CommonAPI.follow(user3, user1) - - User.endorse(user1, user2) - User.endorse(user1, user3) - - response = - conn - |> get("/api/v1/pleroma/accounts/#{id1}/endorsements") - |> json_response_and_validate_schema(200) - - assert length(response) == 2 - assert Enum.any?(response, fn user -> user["id"] == id2 end) - assert Enum.any?(response, fn user -> user["id"] == id3 end) - end - - test "returns 404 error when specified user is not exist", %{conn: conn} do - conn = get(conn, "/api/v1/pleroma/accounts/test/endorsements") - - assert json_response_and_validate_schema(conn, 404) == %{"error" => "Record not found"} - end - end - describe "birthday reminders" do test "returns a list of friends having birthday on specified day" do %{user: user, conn: conn} = oauth_access(["read:accounts"]) diff --git a/test/pleroma/web/pleroma_api/controllers/chat_controller_test.exs b/test/pleroma/web/pleroma_api/controllers/chat_controller_test.exs index 0d3452559..0dc0e7014 100644 --- a/test/pleroma/web/pleroma_api/controllers/chat_controller_test.exs +++ b/test/pleroma/web/pleroma_api/controllers/chat_controller_test.exs @@ -337,6 +337,41 @@ defmodule Pleroma.Web.PleromaAPI.ChatControllerTest do end end + describe "POST /api/v1/pleroma/chats/:id/pin" do + setup do: oauth_access(["write:chats"]) + + test "it pins a chat", %{conn: conn, user: user} do + other_user = insert(:user) + + {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) + + result = + conn + |> post("/api/v1/pleroma/chats/#{chat.id}/pin") + |> json_response_and_validate_schema(200) + + assert %{"pinned" => true} = result + end + end + + describe "POST /api/v1/pleroma/chats/:id/unpin" do + setup do: oauth_access(["write:chats"]) + + test "it unpins a chat", %{conn: conn, user: user} do + other_user = insert(:user) + + {:ok, chat} = Chat.get_or_create(user.id, other_user.ap_id) + {:ok, chat} = Chat.pin(chat) + + result = + conn + |> post("/api/v1/pleroma/chats/#{chat.id}/unpin") + |> json_response_and_validate_schema(200) + + assert %{"pinned" => false} = result + end + end + for tested_endpoint <- ["/api/v1/pleroma/chats", "/api/v2/pleroma/chats"] do describe "GET #{tested_endpoint}" do setup do: oauth_access(["read:chats"]) @@ -407,6 +442,21 @@ defmodule Pleroma.Web.PleromaAPI.ChatControllerTest do assert length(result) == 1 end + test "it only returns pinned chats", %{conn: conn, user: user} do + recipient1 = insert(:user) + recipient2 = insert(:user) + + {:ok, %{id: id} = chat} = Chat.get_or_create(user.id, recipient1.ap_id) + {:ok, _} = Chat.get_or_create(user.id, recipient2.ap_id) + + Chat.pin(chat) + + [%{"id" => ^id, "pinned" => true}] = + conn + |> get("#{unquote(tested_endpoint)}?pinned=true") + |> json_response_and_validate_schema(200) + end + if tested_endpoint == "/api/v1/pleroma/chats" do test "it returns all chats", %{conn: conn, user: user} do Enum.each(1..30, fn _ -> diff --git a/test/pleroma/web/pleroma_api/controllers/follow_request_controller_test.exs b/test/pleroma/web/pleroma_api/controllers/follow_request_controller_test.exs new file mode 100644 index 000000000..46109e35e --- /dev/null +++ b/test/pleroma/web/pleroma_api/controllers/follow_request_controller_test.exs @@ -0,0 +1,27 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2024 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.FollowRequestControllerTest do + use Pleroma.Web.ConnCase, async: true + + alias Pleroma.Web.CommonAPI + + import Pleroma.Factory + + test "/api/v1/pleroma/outgoing_follow_requests works" do + %{conn: conn, user: user} = oauth_access(["read:follows"]) + + other_user1 = insert(:user) + other_user2 = insert(:user, is_locked: true) + _other_user3 = insert(:user) + + {:ok, _, _, _} = CommonAPI.follow(other_user1, user) + {:ok, _, _, _} = CommonAPI.follow(other_user2, user) + + conn = get(conn, "/api/v1/pleroma/outgoing_follow_requests") + + assert [relationship] = json_response_and_validate_schema(conn, 200) + assert to_string(other_user2.id) == relationship["id"] + end +end diff --git a/test/pleroma/web/pleroma_api/controllers/frontend_settings_controller_test.exs b/test/pleroma/web/pleroma_api/controllers/frontend_settings_controller_test.exs new file mode 100644 index 000000000..1f14c102f --- /dev/null +++ b/test/pleroma/web/pleroma_api/controllers/frontend_settings_controller_test.exs @@ -0,0 +1,17 @@ +defmodule Pleroma.Web.PleromaAPI.FrontendSettingsControllerTest do + use Pleroma.Web.ConnCase, async: false + + describe "PUT /api/v1/pleroma/preferred_frontend" do + test "sets a cookie with selected frontend" do + %{conn: conn} = oauth_access(["read"]) + + response = + conn + |> put_req_header("content-type", "application/json") + |> put("/api/v1/pleroma/preferred_frontend", %{"frontend_name" => "pleroma-fe/stable"}) + + json_response_and_validate_schema(response, 200) + assert %{"preferred_frontend" => %{value: "pleroma-fe/stable"}} = response.resp_cookies + end + end +end diff --git a/test/pleroma/web/pleroma_api/controllers/scrobble_controller_test.exs b/test/pleroma/web/pleroma_api/controllers/scrobble_controller_test.exs index bcc25b83e..fd405d7d7 100644 --- a/test/pleroma/web/pleroma_api/controllers/scrobble_controller_test.exs +++ b/test/pleroma/web/pleroma_api/controllers/scrobble_controller_test.exs @@ -9,7 +9,7 @@ defmodule Pleroma.Web.PleromaAPI.ScrobbleControllerTest do describe "POST /api/v1/pleroma/scrobble" do test "works correctly" do - %{conn: conn} = oauth_access(["write"]) + %{conn: conn} = oauth_access(["write:scrobbles"]) conn = conn @@ -51,7 +51,7 @@ defmodule Pleroma.Web.PleromaAPI.ScrobbleControllerTest do describe "GET /api/v1/pleroma/accounts/:id/scrobbles" do test "works correctly" do - %{user: user, conn: conn} = oauth_access(["read"]) + %{user: user, conn: conn} = oauth_access(["read:scrobbles"]) {:ok, _activity} = CommonAPI.listen(user, %{ diff --git a/test/pleroma/web/pleroma_api/controllers/status_controller_test.exs b/test/pleroma/web/pleroma_api/controllers/status_controller_test.exs index a24e98a07..4b4418384 100644 --- a/test/pleroma/web/pleroma_api/controllers/status_controller_test.exs +++ b/test/pleroma/web/pleroma_api/controllers/status_controller_test.exs @@ -9,46 +9,22 @@ defmodule Pleroma.Web.PleromaAPI.StatusControllerTest do import Pleroma.Factory - describe "getting quotes of a specified post" do - setup do - [current_user, user] = insert_pair(:user) - %{user: current_user, conn: conn} = oauth_access(["read:statuses"], user: current_user) - [current_user: current_user, user: user, conn: conn] - end + test "/quotes fallback works" do + [current_user, user] = insert_pair(:user) + %{conn: conn} = oauth_access(["read:statuses"], user: current_user) - test "shows quotes of a post", %{conn: conn} do - user = insert(:user) - activity = insert(:note_activity) + activity = insert(:note_activity) - {:ok, quote_post} = CommonAPI.post(user, %{status: "quoat", quoted_status_id: activity.id}) + {:ok, quote_post} = CommonAPI.post(user, %{status: "quoat", quoted_status_id: activity.id}) - response = - conn - |> get("/api/v1/pleroma/statuses/#{activity.id}/quotes") - |> json_response_and_validate_schema(:ok) + response = + conn + |> get("/api/v1/pleroma/statuses/#{activity.id}/quotes") + |> json_response_and_validate_schema(:ok) - [status] = response + [status] = response - assert length(response) == 1 - assert status["id"] == quote_post.id - end - - test "returns 404 error when a post can't be seen", %{conn: conn} do - activity = insert(:direct_note_activity) - - response = - conn - |> get("/api/v1/pleroma/statuses/#{activity.id}/quotes") - - assert json_response_and_validate_schema(response, 404) == %{"error" => "Record not found"} - end - - test "returns 404 error when a post does not exist", %{conn: conn} do - response = - conn - |> get("/api/v1/pleroma/statuses/idontexist/quotes") - - assert json_response_and_validate_schema(response, 404) == %{"error" => "Record not found"} - end + assert length(response) == 1 + assert status["id"] == quote_post.id end end diff --git a/test/pleroma/web/pleroma_api/views/chat_view_test.exs b/test/pleroma/web/pleroma_api/views/chat_view_test.exs index 405f1b6b7..d7efe8dfe 100644 --- a/test/pleroma/web/pleroma_api/views/chat_view_test.exs +++ b/test/pleroma/web/pleroma_api/views/chat_view_test.exs @@ -30,7 +30,8 @@ defmodule Pleroma.Web.PleromaAPI.ChatViewTest do AccountView.render("show.json", user: recipient, skip_visibility_check: true), unread: 0, last_message: nil, - updated_at: Utils.to_masto_date(chat.updated_at) + updated_at: Utils.to_masto_date(chat.updated_at), + pinned: false } {:ok, chat_message_creation} = CommonAPI.post_chat_message(user, recipient, "hello") diff --git a/test/pleroma/web/plugs/frontend_static_plug_test.exs b/test/pleroma/web/plugs/frontend_static_plug_test.exs index a7af3e74e..cbe200738 100644 --- a/test/pleroma/web/plugs/frontend_static_plug_test.exs +++ b/test/pleroma/web/plugs/frontend_static_plug_test.exs @@ -97,6 +97,7 @@ defmodule Pleroma.Web.Plugs.FrontendStaticPlugTest do "users", "tags", "mailer", + "frontend_switcher", "inbox", "relay", "internal", @@ -113,4 +114,36 @@ defmodule Pleroma.Web.Plugs.FrontendStaticPlugTest do assert expected_routes == Pleroma.Web.Router.get_api_routes() end + + describe "preferred frontend cookie handling" do + test "returns preferred frontend file", %{conn: conn} do + name = "test-fe" + ref = "develop" + + clear_config([:frontends, :pickable], ["#{name}/#{ref}"]) + path = "#{@dir}/frontends/#{name}/#{ref}" + + Pleroma.Backports.mkdir_p!(path) + File.write!("#{path}/index.html", "from frontend plug") + + index = + conn + |> put_req_cookie("preferred_frontend", "#{name}/#{ref}") + |> get("/") + + assert html_response(index, 200) == "from frontend plug" + end + + test "only returns content from pickable frontends", %{conn: conn} do + clear_config([:instance, :static_dir], "instance/static") + clear_config([:frontends, :pickable], ["pleroma-fe/develop", "pl-fe/develop"]) + + config_file = + conn + |> put_req_cookie("preferred_frontend", "../../../config") + |> get("/config.exs") + + refute response(config_file, 200) =~ "import Config" + end + end end diff --git a/test/pleroma/web/streamer_test.exs b/test/pleroma/web/streamer_test.exs index 85978e824..096ca2d2a 100644 --- a/test/pleroma/web/streamer_test.exs +++ b/test/pleroma/web/streamer_test.exs @@ -883,7 +883,7 @@ defmodule Pleroma.Web.StreamerTest do assert Streamer.filtered_by_user?(user1, notif) end - test "it send non-reblog notification for reblog-muted actors", %{ + test "it sends non-reblog notification for reblog-muted actors", %{ user: user1, token: user1_token } do diff --git a/tools/check-changelog b/tools/check-changelog index d053ed577..5952aefcc 100644 --- a/tools/check-changelog +++ b/tools/check-changelog @@ -1,5 +1,8 @@ #!/bin/sh +echo "adding ownership exception" +git config --global --add safe.directory $(pwd) + echo "looking for change log" git remote add upstream https://git.pleroma.social/pleroma/pleroma.git