+++ /dev/null
-<!--
-### Precheck
-
-* For support use https://git.pleroma.social/pleroma/pleroma-support or [community channels](https://git.pleroma.social/pleroma/pleroma#community-channels).
-* Please do a quick search to ensure no similar bug has been reported before. If the bug has not been addressed after 2 weeks, it's fine to bump it.
-* Try to ensure that the bug is actually related to the Pleroma backend. For example, if a bug happens in Pleroma-FE but not in Mastodon-FE or mobile clients, it's likely that the bug should be filed in [Pleroma-FE](https://git.pleroma.social/pleroma/pleroma-fe/issues/new) repository.
--->
-
-### Environment
-
-* Installation type (OTP or From Source):
-* Pleroma version (could be found in the "Version" tab of settings in Pleroma-FE):
-* Elixir version (`elixir -v` for from source installations, N/A for OTP):
-* Operating system:
-* PostgreSQL version (`psql -V`):
-
-
-### Bug description
+++ /dev/null
-### Release checklist
-* [ ] Bump version in `mix.exs`
-* [ ] Compile a changelog
-* [ ] Create an MR with an announcement to pleroma.social
-* [ ] Tag the release
-* [ ] Merge `stable` into `develop` (in case the fixes are already in develop, use `git merge -s ours --no-commit` and manually merge the changelogs)
- /bin/sh /entrypoint.sh
debian-bullseye:
- image: elixir:1.13.4
+ image: akkoma/debian
<<: *on-release
environment:
MIX_ENV: prod
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
-## [Unreleased]
+## 2022.09
+
+### Added
+- support for fedibird-fe, and non-breaking API parity for it to function
+- support for setting instance languages in metadata
+- support for reusing oauth tokens, and not requiring new authorizations
+- the ability to obfuscate domains in your MRF descriptions
+- automatic translation of statuses via DeepL or LibreTranslate
+- ability to edit posts
+- ability to react with remote emoji
+
+### Changed
+- MFM parsing is now done on the backend by a modified version of ilja's parser -> https://akkoma.dev/AkkomaGang/mfm-parser
+- InlineQuotePolicy is now on by default
+
+### Fixed
+- Compatibility with latest meilisearch
+- Resolution of nested mix tasks (i.e search.meilisearch) in OTP releases
+- Elasticsearch returning likes and repeats, displaying as posts
+- Ensure key generation happens at registration-time to prevent potential race-conditions
+- Ensured websockets get closed on logout
+- Allowed GoToSocial-style `?query_string` signatures
+
+### Removed
+- Non-finch HTTP adapters. `:tesla, :adapter` is now highly recommended to be set to the default.
+
+## 2022.08
### Removed
- Non-finch HTTP adapters. `:tesla, :adapter` is now highly recommended to be set to the default.
- amd64 is built for debian stable. Compatible with ubuntu 20.
- ubuntu-jammy is built for... well, ubuntu 22 (LTS)
- amd64-musl is built for alpine 3.16
+- Enable remote users to interact with posts
### Fixed
- Updated mastoFE path, for the newer version
*a smallish microblogging platform, aka the cooler pleroma*
+![English OK](https://img.shields.io/badge/English-OK-blueviolet) ![日本語OK](https://img.shields.io/badge/%E6%97%A5%E6%9C%AC%E8%AA%9E-OK-blueviolet)
+
## About
This is a fork of Pleroma, which is a microblogging server software that can federate (= exchange messages with) other servers that support ActivityPub. What that means is that you can host a server for yourself or your friends and stay in control of your online identity, but still exchange messages with people on larger servers. Akkoma will federate with all servers that implement ActivityPub, like Friendica, GNU Social, Hubzilla, Mastodon, Misskey, Peertube, and Pixelfed.
--- /dev/null
+untrusted comment: Akkoma Signing Key public key
+RWQRlw8Ex/uTbvo1wB1yK75tQ5nXKilB/vrKdkL41bgZHL9aKP+7fSS5
avatar_upload_limit: 2_000_000,
background_upload_limit: 4_000_000,
banner_upload_limit: 4_000_000,
+ languages: ["en"],
poll_limits: %{
max_options: 20,
max_option_chars: 200,
"build_dir" => "distribution",
"ref" => "akkoma"
},
+ "fedibird-fe" => %{
+ "name" => "fedibird-fe",
+ "git" => "https://akkoma.dev/AkkomaGang/fedibird-fe",
+ "build_url" =>
+ "https://akkoma-updates.s3-website.fr-par.scw.cloud/frontend/${ref}/fedibird-fe.zip",
+ "build_dir" => "distribution",
+ "ref" => "akkoma"
+ },
"admin-fe" => %{
"name" => "admin-fe",
"git" => "https://akkoma.dev/AkkomaGang/admin-fe",
config :pleroma, :mrf,
policies: [Pleroma.Web.ActivityPub.MRF.ObjectAgePolicy, Pleroma.Web.ActivityPub.MRF.TagPolicy],
transparency: true,
- transparency_exclusions: []
+ transparency_exclusions: [],
+ transparency_obfuscate_domains: []
config :ex_aws, http_client: Pleroma.HTTP.ExAws
}
}
+config :pleroma, :translator,
+ enabled: false,
+ module: Pleroma.Akkoma.Translators.DeepL
+
+config :pleroma, :deepl,
+ # either :free or :pro
+ tier: :free,
+ api_key: ""
+
+config :pleroma, :libre_translate,
+ url: "http://127.0.0.1:5000",
+ api_key: nil
+
# Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above.
import_config "#{Mix.env()}.exs"
"Pleroma"
]
},
+ %{
+ key: :languages,
+ type: {:list, :string},
+ description: "Languages the instance uses",
+ suggestions: [
+ "en",
+ "ja",
+ "fr"
+ ]
+ },
%{
key: :email,
label: "Admin Email Address",
hideFilteredStatuses: false,
hideMutedPosts: false,
hidePostStats: false,
- hideSitename: false,
hideUserStats: false,
loginMethod: "password",
logo: "/static/logo.svg",
type: :boolean,
description: "Hide notices statistics (repeats, favorites, ...)"
},
- %{
- key: :hideSitename,
- label: "Hide Sitename",
- type: :boolean,
- description: "Hides instance name from PleromaFE banner"
- },
%{
key: :hideUserStats,
label: "Hide user stats",
type: :string,
description: "Which theme to use. Available themes are defined in styles.json",
suggestions: ["pleroma-dark"]
+ },
+ %{
+ key: :showPanelNavShortcuts,
+ label: "Show timeline panel nav shortcuts",
+ type: :boolean,
+ description: "Whether to put timeline nav tabs on the top of the panel"
+ },
+ %{
+ key: :showNavShortcuts,
+ label: "Show navbar shortcuts",
+ type: :boolean,
+ description: "Whether to put extra navigation options on the navbar"
+ },
+ %{
+ key: :showWiderShortcuts,
+ label: "Increase navbar shortcut spacing",
+ type: :boolean,
+ description: "Whether to add extra space between navbar icons"
+ },
+ %{
+ key: :hideSiteFavicon,
+ label: "Hide site favicon",
+ type: :boolean,
+ description: "Whether to hide the instance favicon from the navbar"
+ },
+ %{
+ key: :hideSiteName,
+ label: "Hide site name",
+ type: :boolean,
+ description: "Whether to hide the site name from the navbar"
+ },
+ %{
+ key: :renderMisskeyMarkdown,
+ label: "Render misskey markdown",
+ type: :boolean,
+ description: "Whether to render Misskey-flavoured markdown"
}
]
},
group: :pleroma,
key: Pleroma.Search,
type: :group,
+ label: "Search",
description: "General search settings.",
children: [
%{
key: :module,
- type: :keyword,
+ type: :module,
description: "Selected search module.",
- suggestion: [Pleroma.Search.DatabaseSearch, Pleroma.Search.Meilisearch]
+ suggestions: {:list_behaviour_implementations, Pleroma.Search.SearchBackend}
}
]
},
},
%{
key: :initial_indexing_chunk_size,
- type: :int,
+ type: :integer,
description:
"Amount of posts in a batch when running the initial indexing operation. Should probably not be more than 100000" <>
" since there's a limit on maximum insert size",
%{
group: :pleroma,
key: Pleroma.Search.Elasticsearch.Cluster,
+ label: "Elasticsearch",
type: :group,
description: "Elasticsearch settings.",
children: [
},
%{
key: :bulk_page_size,
- type: :int,
+ type: :integer,
description: "Size for bulk put requests, mostly used on building the index",
suggestion: [5000]
},
%{
key: :bulk_wait_interval,
- type: :int,
+ type: :integer,
description: "Time to wait between bulk put requests (in ms)",
suggestion: [15_000]
}
]
}
]
+ },
+ %{
+ group: :pleroma,
+ key: :translator,
+ type: :group,
+ description: "Translation Settings",
+ children: [
+ %{
+ key: :enabled,
+ type: :boolean,
+ description: "Is translation enabled?",
+ suggestion: [true, false]
+ },
+ %{
+ key: :module,
+ type: :module,
+ description: "Translation module.",
+ suggestions: {:list_behaviour_implementations, Pleroma.Akkoma.Translator}
+ }
+ ]
+ },
+ %{
+ group: :pleroma,
+ key: :deepl,
+ label: "DeepL",
+ type: :group,
+ description: "DeepL Settings.",
+ children: [
+ %{
+ key: :tier,
+ type: {:dropdown, :atom},
+ description: "API Tier",
+ suggestions: [:free, :pro]
+ },
+ %{
+ key: :api_key,
+ type: :string,
+ description: "API key for DeepL",
+ suggestions: [nil]
+ }
+ ]
+ },
+ %{
+ group: :pleroma,
+ key: :libre_translate,
+ type: :group,
+ description: "LibreTranslate Settings.",
+ children: [
+ %{
+ key: :url,
+ type: :string,
+ description: "URL for libretranslate",
+ suggestion: [nil]
+ },
+ %{
+ key: :api_key,
+ type: :string,
+ description: "API key for libretranslate",
+ suggestion: [nil]
+ }
+ ]
}
]
* `Pleroma.Web.ActivityPub.MRF.KeywordPolicy`: Rejects or removes from the federated timeline or replaces keywords. (See [`:mrf_keyword`](#mrf_keyword)).
* `transparency`: Make the content of your Message Rewrite Facility settings public (via nodeinfo).
* `transparency_exclusions`: Exclude specific instance names from MRF transparency. The use of the exclusions feature will be disclosed in nodeinfo as a boolean value.
+* `transparency_obfuscate_domains`: Show domains with `*` in the middle, to censor them if needed. For example, `ridingho.me` will show as `rid*****.me`
## Federation
### MRF policies
* `:max_running` - max concurrently runnings jobs
* `:max_waiting` - max waiting jobs
+
+### Translation Settings
+
+Settings to automatically translate statuses for end users. Currently supported
+translation services are DeepL and LibreTranslate.
+
+Translations are available at `/api/v1/statuses/:id/translations/:language`, where
+`language` is the target language code (e.g `en`)
+
+### `:translator`
+
+- `:enabled` - enables translation
+- `:module` - Sets module to be used
+ - Either `Pleroma.Akkoma.Translators.DeepL` or `Pleroma.Akkoma.Translators.LibreTranslate`
+
+### `:deepl`
+
+- `:api_key` - API key for DeepL
+- `:tier` - API tier
+ - either `:free` or `:pro`
+
+### `:libre_translate`
+
+- `:url` - URL of LibreTranslate instance
+- `:api_key` - API key for LibreTranslate
admin: %{
"name" => "admin-fe",
"ref" => "stable"
+ },
+ mastodon: %{
+ "name" => "mastodon-fe",
+ "ref" => "akkoma"
}
```
Refer to [the frontend CLI task](../../administration/CLI_tasks/frontend) for how to install the frontend's files
-If you wish masto-fe to also be enabled, you will also need to run the install task for `mastodon-fe`. Not doing this will lead to the frontend not working.
-
If you choose not to install a frontend for whatever reason, it is recommended that you enable [`:static_fe`](#static_fe) to allow remote users to click "view remote source". Don't bother with this if you've got no unauthenticated access though.
You can also replace the default "no frontend" page by placing an `index.html` file under your `instance/static/` directory.
+## Mastodon-FE
+
+Akkoma supports both [glitchsoc](https://github.com/glitch-soc/mastodon)'s more "vanilla" mastodon frontend,
+as well as [fedibird](https://github.com/fedibird/mastodon)'s extended frontend which has near-feature-parity with akkoma (with quoting and reactions).
+
+To enable either one, you must run the `frontend.install` task for either `mastodon-fe` or `fedibird-fe` (both `--ref akkoma`), then make sure
+`:pleroma, :frontends, :mastodon` references the one you want.
+
## Swagger (openAPI) documentation viewer
If you're a developer and you'd like a human-readable rendering of the
- `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.
+The `GET /api/v1/statuses/:id/source` endpoint additionally has the following attributes:
+
+- `content_type`: The content type of the status source.
+
## Scheduled statuses
Has these additional fields in `params`:
doas -u akkoma env MIX_ENV=prod mix pleroma.user new <username> <your@emailaddress> --admin
```
+{! installation/frontends.include !}
+
#### Further reading
{! installation/further_reading.include !}
sudo -Hu akkoma MIX_ENV=prod mix pleroma.user new <username> <your@emailaddress> --admin
```
+{! installation/frontends.include !}
+
#### Further reading
{! installation/further_reading.include !}
sudo -Hu akkoma MIX_ENV=prod mix pleroma.user new <username> <your@emailaddress> --admin
```
+{! installation/frontends.include !}
+
#### Further reading
{! installation/further_reading.include !}
sudo -Hu akkoma MIX_ENV=prod mix pleroma.user new <username> <your@emailaddress> --admin
```
+{! installation/frontends.include !}
+
#### Further reading
{! installation/further_reading.include !}
```shell
sudo -Hu akkoma MIX_ENV=prod mix pleroma.user new <username> <your@emailaddress> --admin
```
+
+{! installation/frontends.include !}
+
## Conclusion
Restart nginx with `# service nginx restart` and you should be up and running.
--- /dev/null
+#### Installing Frontends
+
+Once your backend server is functional, you'll also want to
+probably install frontends.
+
+These are no longer bundled with the distribution and need an extra
+command to install.
+
+For most installations, the following will suffice:
+
+=== "OTP"
+ ```sh
+ ./bin/pleroma_ctl frontend install pleroma-fe --ref stable
+ # and also, if desired
+ ./bin/pleroma_ctl frontend install admin-fe --ref stable
+ ```
+
+=== "From Source"
+ ```sh
+ mix pleroma.frontend install pleroma-fe --ref stable
+ mix pleroma.frontend install admin-fe --ref stable
+ ```
+
+For more customised installations, refer to [Frontend Management](../../configuration/frontend_management)
+
If you opted to allow sudo for the `akkoma` user but would like to remove the ability for greater security, now might be a good time to edit `/etc/sudoers` and/or change the groups the `akkoma` user belongs to. Be sure to restart the akkoma service afterwards to ensure it picks up on the changes.
+{! installation/frontends.include !}
+
#### Further reading
{! installation/further_reading.include !}
# Migrating to Akkoma
-**Akkoma does not currently have a stable release, until 3.0, all builds should be considered "develop"**
-
## Why should you migrate?
aside from actually responsive maintainer(s)? let's lookie here, we've got:
- elasticsearch support (because pleroma search is GARBAGE)
- latest develop pleroma-fe additions
- local-only posting
+- automatic post translation
+- the mastodon frontend back in all its glory
- probably more, this is like 3.5 years of IHBA additions finally compiled
## Actually migrating
```bash
export FLAVOUR=[the flavour you found above]
-./bin/pleroma_ctl update --zip-url https://akkoma-updates.s3-website.fr-par.scw.cloud/develop/akkoma-$FLAVOUR.zip
+./bin/pleroma_ctl update --zip-url https://akkoma-updates.s3-website.fr-par.scw.cloud/stable/akkoma-$FLAVOUR.zip
./bin/pleroma_ctl migrate
```
Then restart. When updating in the future, you canjust use
```bash
-./bin/pleroma_ctl update --branch develop
+./bin/pleroma_ctl update --branch stable
```
## Frontend changes
You'll need to run a couple of commands,
-```bash
-# From source
-mix pleroma.frontend install pleroma-fe
-# you'll probably want this too
-mix pleroma.frontend install admin-fe
-
-# OTP
-./bin/pleroma_ctl frontend install pleroma-fe
-# you'll probably want this too
-./bin/pleroma_ctl frontend install admin-fe
-```
+=== "OTP"
+ ```sh
+ ./bin/pleroma_ctl frontend install pleroma-fe --ref stable
+ # and also, if desired
+ ./bin/pleroma_ctl frontend install admin-fe --ref stable
+ ```
+
+=== "From Source"
+ ```sh
+ mix pleroma.frontend install pleroma-fe --ref stable
+ mix pleroma.frontend install admin-fe --ref stable
+ ```
### I've run the mix task to install a frontend
* <https://catgirl.science>
+{! installation/frontends.include !}
+
#### Further reading
{! installation/further_reading.include !}
LC_ALL=en_US.UTF-8 MIX_ENV=prod mix pleroma.user new <username> <your@emailaddress> --admin
```
+{! installation/frontends.include !}
+
#### Further reading
{! installation/further_reading.include !}
```
This will create an account withe the username of 'joeuser' with the email address of joeuser@sld.tld, and set that user's account as an admin. This will result in a link that you can paste into the browser, which logs you in and enables you to set the password.
+{! installation/frontends.include !}
+
## Further reading
{! installation/further_reading.include !}
As it currently stands, your OTP build will only be compatible for the specific RedHat distribution you've built it on. Fedora builds only work on Fedora, Centos builds only on Centos, RedHat builds only on RedHat. Secondly, for Fedora, they will also be bound to the specific Fedora release. This is because different releases of Fedora may have significant changes made in some of the required packages and libraries.
+{! installation/frontends.include !}
{! installation/further_reading.include !}
--- /dev/null
+# Verifying OTP release integrity
+
+All stable OTP releases are cryptographically signed, to allow
+you to verify the integrity if you choose to.
+
+Releases are signed with [Signify](https://man.openbsd.org/signify.1),
+with [the public key in the main repository](https://akkoma.dev/AkkomaGang/akkoma/src/branch/develop/SIGNING_KEY.pub)
+
+Release URLs will always be of the form
+
+```
+https://akkoma-updates.s3-website.fr-par.scw.cloud/{branch}/akkoma-{flavour}.zip
+```
+
+Where branch is usually `stable` or `develop`, and `flavour` is
+the one [that you detect on install](../otp_en/#detecting-flavour).
+
+So, for an AMD64 stable install, your update URL will be
+
+```
+https://akkoma-updates.s3-website.fr-par.scw.cloud/stable/akkoma-amd64.zip
+```
+
+To verify the integrity of this file, we have two helper files
+
+```
+# Checksums
+https://akkoma-updates.s3-website.fr-par.scw.cloud/{branch}/akkoma-{flavour}.zip.sha256
+
+# Signify signature of the hashes
+https://akkoma-updates.s3-website.fr-par.scw.cloud/{branch}/akkoma-{flavour}.zip.sha256.sig
+```
+
+Thus, to upgrade manually, with integrity checking, consider the following script:
+
+```bash
+#!/bin/bash
+set -eo pipefail
+
+export FLAVOUR=amd64
+export BRANCH=stable
+
+# Fetch signing key
+curl --silent https://akkoma.dev/AkkomaGang/akkoma/raw/branch/$BRANCH/SIGNING_KEY.pub -o AKKOMA_SIGNING_KEY.pub
+
+# Download zip file and sig files
+wget -q https://akkoma-updates.s3-website.fr-par.scw.cloud/$BRANCH/akkoma-$FLAVOUR{.zip,.zip.sha256,.zip.sha256.sig}
+
+# Verify zip file's sha256 integrity
+sha256sum --check akkoma-$FLAVOUR.zip.sha256
+
+# Verify hash file's integrity
+# Signify might be under the `signify` command, depending on your distribution
+signify-openbsd -V -p AKKOMA_SIGNING_KEY.pub -m akkoma-$FLAVOUR.zip.sha256
+
+# We're good, use that URL
+echo "Update URL contents verified"
+echo "use"
+echo "./bin/pleroma_ctl update --zip-url https://akkoma-updates.s3-website.fr-par.scw.cloud/$BRANCH/akkoma-$FLAVOUR"
+echo "to update your instance"
+
+# Clean up
+rm akkoma-$FLAVOUR.zip
+rm akkoma-$FLAVOUR.zip.sha256
+rm akkoma-$FLAVOUR.zip.sha256.sig
+```
import Ecto.Query
import Pleroma.Search.Meilisearch,
- only: [meili_post: 2, meili_put: 2, meili_get: 1, meili_delete!: 1]
+ only: [meili_put: 2, meili_get: 1, meili_delete!: 1]
def run(["index"]) do
start_pleroma()
end
{:ok, _} =
- meili_post(
+ meili_put(
"/indexes/objects/settings/ranking-rules",
[
"published:desc",
)
{:ok, _} =
- meili_post(
+ meili_put(
"/indexes/objects/settings/searchable-attributes",
[
"content"
)
with {:ok, res} <- result do
- if not Map.has_key?(res, "uid") do
+ if not Map.has_key?(res, "indexUid") do
IO.puts("\nFailed to index: #{inspect(result)}")
end
else
@cachex Pleroma.Config.get([:cachex, :provider], Cachex)
+ # We store a list of cache keys related to an activity in a
+ # separate cache, scrubber_management_cache. It has the same
+ # size as scrubber_cache (see application.ex). Every time we add
+ # a cache to scrubber_cache, we update scrubber_management_cache.
+ #
+ # The most recent write of a certain key in the management cache
+ # is the same as the most recent write of any record related to that
+ # key in the main cache.
+ # Assuming LRW ( https://hexdocs.pm/cachex/Cachex.Policy.LRW.html ),
+ # this means when the management cache is evicted by cachex, all
+ # related records in the main cache will also have been evicted.
+
+ defp get_cache_keys_for(activity_id) do
+ with {:ok, list} when is_list(list) <- @cachex.get(:scrubber_management_cache, activity_id) do
+ list
+ else
+ _ -> []
+ end
+ end
+
+ defp add_cache_key_for(activity_id, additional_key) do
+ current = get_cache_keys_for(activity_id)
+
+ unless additional_key in current do
+ @cachex.put(:scrubber_management_cache, activity_id, [additional_key | current])
+ end
+ end
+
+ def invalidate_cache_for(activity_id) do
+ keys = get_cache_keys_for(activity_id)
+ Enum.map(keys, &@cachex.del(:scrubber_cache, &1))
+ @cachex.del(:scrubber_management_cache, activity_id)
+ end
+
def get_cached_scrubbed_html_for_activity(
content,
scrubbers,
@cachex.fetch!(:scrubber_cache, key, fn _key ->
object = Object.normalize(activity, fetch: false)
+
+ add_cache_key_for(activity.id, key)
HTML.ensure_scrubbed_html(content, scrubbers, object.data["fake"] || false, callback)
end)
end
--- /dev/null
+defmodule Pleroma.Akkoma.Translators.DeepL do
+ @behaviour Pleroma.Akkoma.Translator
+
+ alias Pleroma.HTTP
+ alias Pleroma.Config
+ require Logger
+
+ defp base_url(:free) do
+ "https://api-free.deepl.com/v2/"
+ end
+
+ defp base_url(:pro) do
+ "https://api.deepl.com/v2/"
+ end
+
+ defp api_key do
+ Config.get([:deepl, :api_key])
+ end
+
+ defp tier do
+ Config.get([:deepl, :tier])
+ end
+
+ @impl Pleroma.Akkoma.Translator
+ def languages do
+ with {:ok, %{status: 200} = source_response} <- do_languages("source"),
+ {:ok, %{status: 200} = dest_response} <- do_languages("target"),
+ {:ok, source_body} <- Jason.decode(source_response.body),
+ {:ok, dest_body} <- Jason.decode(dest_response.body) do
+ source_resp =
+ Enum.map(source_body, fn %{"language" => code, "name" => name} ->
+ %{code: code, name: name}
+ end)
+
+ dest_resp =
+ Enum.map(dest_body, fn %{"language" => code, "name" => name} ->
+ %{code: code, name: name}
+ end)
+
+ {:ok, source_resp, dest_resp}
+ else
+ {:ok, %{status: status} = response} ->
+ Logger.warning("DeepL: Request rejected: #{inspect(response)}")
+ {:error, "DeepL request failed (code #{status})"}
+
+ {:error, reason} ->
+ {:error, reason}
+ end
+ end
+
+ @impl Pleroma.Akkoma.Translator
+ def translate(string, from_language, to_language) do
+ with {:ok, %{status: 200} = response} <-
+ do_request(api_key(), tier(), string, from_language, to_language),
+ {:ok, body} <- Jason.decode(response.body) do
+ %{"translations" => [%{"text" => translated, "detected_source_language" => detected}]} =
+ body
+
+ {:ok, detected, translated}
+ else
+ {:ok, %{status: status} = response} ->
+ Logger.warning("DeepL: Request rejected: #{inspect(response)}")
+ {:error, "DeepL request failed (code #{status})"}
+
+ {:error, reason} ->
+ {:error, reason}
+ end
+ end
+
+ defp do_request(api_key, tier, string, from_language, to_language) do
+ HTTP.post(
+ base_url(tier) <> "translate",
+ URI.encode_query(
+ %{
+ text: string,
+ target_lang: to_language,
+ tag_handling: "html"
+ }
+ |> maybe_add_source(from_language),
+ :rfc3986
+ ),
+ [
+ {"authorization", "DeepL-Auth-Key #{api_key}"},
+ {"content-type", "application/x-www-form-urlencoded"}
+ ]
+ )
+ end
+
+ defp maybe_add_source(opts, nil), do: opts
+ defp maybe_add_source(opts, lang), do: Map.put(opts, :source_lang, lang)
+
+ defp do_languages(type) do
+ HTTP.get(
+ base_url(tier()) <> "languages?type=#{type}",
+ [
+ {"authorization", "DeepL-Auth-Key #{api_key()}"}
+ ]
+ )
+ end
+end
--- /dev/null
+defmodule Pleroma.Akkoma.Translators.LibreTranslate do
+ @behaviour Pleroma.Akkoma.Translator
+
+ alias Pleroma.Config
+ alias Pleroma.HTTP
+ require Logger
+
+ defp api_key do
+ Config.get([:libre_translate, :api_key])
+ end
+
+ defp url do
+ Config.get([:libre_translate, :url])
+ end
+
+ @impl Pleroma.Akkoma.Translator
+ def languages do
+ with {:ok, %{status: 200} = response} <- do_languages(),
+ {:ok, body} <- Jason.decode(response.body) do
+ resp = Enum.map(body, fn %{"code" => code, "name" => name} -> %{code: code, name: name} end)
+ # No separate source/dest
+ {:ok, resp, resp}
+ else
+ {:ok, %{status: status} = response} ->
+ Logger.warning("LibreTranslate: Request rejected: #{inspect(response)}")
+ {:error, "LibreTranslate request failed (code #{status})"}
+
+ {:error, reason} ->
+ {:error, reason}
+ end
+ end
+
+ @impl Pleroma.Akkoma.Translator
+ def translate(string, from_language, to_language) do
+ with {:ok, %{status: 200} = response} <- do_request(string, from_language, to_language),
+ {:ok, body} <- Jason.decode(response.body) do
+ %{"translatedText" => translated} = body
+
+ detected =
+ if Map.has_key?(body, "detectedLanguage") do
+ get_in(body, ["detectedLanguage", "language"])
+ else
+ from_language
+ end
+
+ {:ok, detected, translated}
+ else
+ {:ok, %{status: status} = response} ->
+ Logger.warning("libre_translate: request failed, #{inspect(response)}")
+ {:error, "libre_translate: request failed (code #{status})"}
+
+ {:error, reason} ->
+ {:error, reason}
+ end
+ end
+
+ defp do_request(string, from_language, to_language) do
+ url = URI.parse(url())
+ url = %{url | path: "/translate"}
+
+ HTTP.post(
+ to_string(url),
+ Jason.encode!(%{
+ q: string,
+ source: if(is_nil(from_language), do: "auto", else: from_language),
+ target: to_language,
+ format: "html",
+ api_key: api_key()
+ }),
+ [
+ {"content-type", "application/json"}
+ ]
+ )
+ end
+
+ defp do_languages() do
+ url = URI.parse(url())
+ url = %{url | path: "/languages"}
+
+ HTTP.get(to_string(url))
+ end
+end
--- /dev/null
+defmodule Pleroma.Akkoma.Translator do
+ @callback translate(String.t(), String.t() | nil, String.t()) ::
+ {:ok, String.t(), String.t()} | {:error, any()}
+ @callback languages() ::
+ {:ok, [%{name: String.t(), code: String.t()}],
+ [%{name: String.t(), code: String.t()}]}
+ | {:error, any()}
+end
Pleroma.Repo,
Config.TransferTask,
Pleroma.Emoji,
- Pleroma.Web.Plugs.RateLimiter.Supervisor
+ Pleroma.Web.Plugs.RateLimiter.Supervisor,
+ {Task.Supervisor, name: Pleroma.TaskSupervisor}
] ++
cachex_children() ++
http_children() ++
build_cachex("object", default_ttl: 25_000, ttl_interval: 1000, limit: 2500),
build_cachex("rich_media", default_ttl: :timer.minutes(120), limit: 5000),
build_cachex("scrubber", limit: 2500),
+ build_cachex("scrubber_management", limit: 2500),
build_cachex("idempotency", expiration: idempotency_expiration(), limit: 2500),
build_cachex("web_resp", limit: 2500),
build_cachex("emoji_packs", expiration: emoji_packs_expiration(), limit: 10),
build_cachex("failed_proxy_url", limit: 2500),
- build_cachex("banned_urls", default_ttl: :timer.hours(24 * 30), limit: 5_000)
+ build_cachex("banned_urls", default_ttl: :timer.hours(24 * 30), limit: 5_000),
+ build_cachex("translations", default_ttl: :timer.hours(24 * 30), limit: 2500)
]
end
alias Pleroma.Config
require Logger
- def fetch_collection_by_ap_id(ap_id) when is_binary(ap_id) do
- fetch_collection(ap_id)
- end
-
+ @spec fetch_collection(String.t() | map()) :: {:ok, [Pleroma.Object.t()]} | {:error, any()}
def fetch_collection(ap_id) when is_binary(ap_id) do
with {:ok, page} <- Fetcher.fetch_and_contain_remote_object_from_id(ap_id) do
{:ok, objects_from_collection(page)}
end
def fetch_collection(%{"type" => type} = page)
- when type in ["Collection", "OrderedCollection"] do
+ when type in ["Collection", "OrderedCollection", "CollectionPage", "OrderedCollectionPage"] do
{:ok, objects_from_collection(page)}
end
when is_list(items) and type in ["Collection", "CollectionPage"],
do: items
- defp objects_from_collection(%{"type" => "OrderedCollection", "orderedItems" => items})
- when is_list(items),
- do: items
+ defp objects_from_collection(%{"type" => type, "orderedItems" => items} = page)
+ when is_list(items) and type in ["OrderedCollection", "OrderedCollectionPage"],
+ do: maybe_next_page(page, items)
- defp objects_from_collection(%{"type" => "Collection", "items" => items}) when is_list(items),
- do: items
+ defp objects_from_collection(%{"type" => type, "items" => items} = page)
+ when is_list(items) and type in ["Collection", "CollectionPage"],
+ do: maybe_next_page(page, items)
defp objects_from_collection(%{"type" => type, "first" => first})
when is_binary(first) and type in ["Collection", "OrderedCollection"] do
fetch_page_items(id)
end
+ defp objects_from_collection(_page), do: []
+
defp fetch_page_items(id, items \\ []) do
if Enum.count(items) >= Config.get([:activitypub, :max_collection_objects]) do
items
else
- {:ok, page} = Fetcher.fetch_and_contain_remote_object_from_id(id)
- objects = items_in_page(page)
+ with {:ok, page} <- Fetcher.fetch_and_contain_remote_object_from_id(id) do
+ objects = items_in_page(page)
- if Enum.count(objects) > 0 do
- maybe_next_page(page, items ++ objects)
+ if Enum.count(objects) > 0 do
+ maybe_next_page(page, items ++ objects)
+ else
+ items
+ end
else
- items
+ {:error, "Object has been deleted"} ->
+ items
+
+ {:error, error} ->
+ Logger.error("Could not fetch page #{id} - #{inspect(error)}")
+ {:error, error}
end
end
end
def load_and_update_env(deleted_settings \\ [], restart_pleroma? \\ true) do
with {_, true} <- {:configurable, Config.get(:configurable_from_database)} do
# We need to restart applications for loaded settings take effect
-
{logger, other} =
(Repo.all(ConfigDB) ++ deleted_settings)
|> Enum.map(&merge_with_default/1)
end
defp merge_with_default(%{group: group, key: key, value: value} = setting) do
- default = Config.Holder.default_config(group, key)
+ default =
+ if group == :pleroma do
+ Config.get([key], Config.Holder.default_config(group, key))
+ else
+ Config.Holder.default_config(group, key)
+ end
merged =
cond do
do:
~w(index.html robots.txt static static-fe finmoji emoji packs sounds images instance sw.js sw-pleroma.js favicon.png schemas doc embed.js embed.css)
)
+
+ const(status_updatable_fields,
+ do: [
+ "source",
+ "tag",
+ "updated",
+ "emoji",
+ "content",
+ "summary",
+ "sensitive",
+ "attachment",
+ "generator"
+ ]
+ )
+
+ const(updatable_object_types,
+ do: [
+ "Note",
+ "Question",
+ "Audio",
+ "Video",
+ "Event",
+ "Article",
+ "Page"
+ ]
+ )
+
+ const(actor_types,
+ do: [
+ "Application",
+ "Group",
+ "Organization",
+ "Person",
+ "Service"
+ ]
+ )
end
def emoji_url(_), do: nil
+ def emoji_name_with_instance(name, url) do
+ url = url |> URI.parse() |> Map.get(:host)
+ "#{name}@#{url}"
+ end
+
emoji_qualification_map =
emojis
|> Enum.filter(&String.contains?(&1, "\uFE0F"))
import Plug.Conn
@oauth_token_session_key :oauth_token
+ @oauth_user_session_key :oauth_user
@doc """
Skips OAuth permissions (scopes) checks, assigns nil `:token`.
def delete_session_token(%Conn{} = conn) do
delete_session(conn, @oauth_token_session_key)
end
+
+ def put_session_user(%Conn{} = conn, user) do
+ put_session(conn, @oauth_user_session_key, user)
+ end
+
+ def delete_session_user(%Conn{} = conn) do
+ delete_session(conn, @oauth_user_session_key)
+ end
+
+ def get_session_user(%Conn{} = conn) do
+ get_session(conn, @oauth_user_session_key)
+ end
end
end
def create_notifications(%Activity{data: %{"type" => type}} = activity, options)
- when type in ["Follow", "Like", "Announce", "Move", "EmojiReact", "Flag"] do
+ when type in ["Follow", "Like", "Announce", "Move", "EmojiReact", "Flag", "Update"] do
do_create_notifications(activity, options)
end
activity
|> type_from_activity_object()
+ "Update" ->
+ "update"
+
t ->
raise "No notification type for activity type #{t}"
end
def get_notified_from_activity(activity, local_only \\ true)
def get_notified_from_activity(%Activity{data: %{"type" => type}} = activity, local_only)
- when type in ["Create", "Like", "Announce", "Follow", "Move", "EmojiReact", "Flag"] do
+ when type in [
+ "Create",
+ "Like",
+ "Announce",
+ "Follow",
+ "Move",
+ "EmojiReact",
+ "Flag",
+ "Update"
+ ] do
potential_receiver_ap_ids = get_potential_receiver_ap_ids(activity)
potential_receivers =
(User.all_superusers() |> Enum.map(fn user -> user.ap_id end)) -- [actor]
end
+ # Update activity: notify all who repeated this
+ def get_potential_receiver_ap_ids(%{data: %{"type" => "Update", "actor" => actor}} = activity) do
+ with %Object{data: %{"id" => object_id}} <- Object.normalize(activity, fetch: false) do
+ repeaters =
+ Activity.Queries.by_type("Announce")
+ |> Activity.Queries.by_object_id(object_id)
+ |> Activity.with_joined_user_actor()
+ |> where([a, u], u.local)
+ |> select([a, u], u.ap_id)
+ |> Repo.all()
+
+ repeaters -- [actor]
+ end
+ end
+
def get_potential_receiver_ap_ids(activity) do
[]
|> Utils.maybe_notify_to_recipients(activity)
end
defp maybe_reinject_internal_fields(%{data: %{} = old_data}, new_data) do
+ has_history? = fn
+ %{"formerRepresentations" => %{"orderedItems" => list}} when is_list(list) -> true
+ _ -> false
+ end
+
internal_fields = Map.take(old_data, Pleroma.Constants.object_internal_fields())
+ remote_history_exists? = has_history?.(new_data)
+
+ # If the remote history exists, we treat that as the only source of truth.
+ new_data =
+ if has_history?.(old_data) and not remote_history_exists? do
+ Map.put(new_data, "formerRepresentations", old_data["formerRepresentations"])
+ else
+ new_data
+ end
+
+ # If the remote does not have history information, we need to manage it ourselves
+ new_data =
+ if not remote_history_exists? do
+ changed? =
+ Pleroma.Constants.status_updatable_fields()
+ |> Enum.any?(fn field -> Map.get(old_data, field) != Map.get(new_data, field) end)
+
+ %{updated_object: updated_object} =
+ new_data
+ |> Object.Updater.maybe_update_history(old_data,
+ updated: changed?,
+ use_history_in_new_object?: false
+ )
+
+ updated_object
+ else
+ new_data
+ end
+
Map.merge(new_data, internal_fields)
end
--- /dev/null
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Object.Updater do
+ require Pleroma.Constants
+
+ def update_content_fields(orig_object_data, updated_object) do
+ Pleroma.Constants.status_updatable_fields()
+ |> Enum.reduce(
+ %{data: orig_object_data, updated: false},
+ fn field, %{data: data, updated: updated} ->
+ updated =
+ updated or
+ (field != "updated" and
+ Map.get(updated_object, field) != Map.get(orig_object_data, field))
+
+ data =
+ if Map.has_key?(updated_object, field) do
+ Map.put(data, field, updated_object[field])
+ else
+ Map.drop(data, [field])
+ end
+
+ %{data: data, updated: updated}
+ end
+ )
+ end
+
+ def maybe_history(object) do
+ with history <- Map.get(object, "formerRepresentations"),
+ true <- is_map(history),
+ "OrderedCollection" <- Map.get(history, "type"),
+ true <- is_list(Map.get(history, "orderedItems")),
+ true <- is_integer(Map.get(history, "totalItems")) do
+ history
+ else
+ _ -> nil
+ end
+ end
+
+ def history_for(object) do
+ with history when not is_nil(history) <- maybe_history(object) do
+ history
+ else
+ _ -> history_skeleton()
+ end
+ end
+
+ defp history_skeleton do
+ %{
+ "type" => "OrderedCollection",
+ "totalItems" => 0,
+ "orderedItems" => []
+ }
+ end
+
+ def maybe_update_history(
+ updated_object,
+ orig_object_data,
+ opts
+ ) do
+ updated = opts[:updated]
+ use_history_in_new_object? = opts[:use_history_in_new_object?]
+
+ if not updated do
+ %{updated_object: updated_object, used_history_in_new_object?: false}
+ else
+ # Put edit history
+ # Note that we may have got the edit history by first fetching the object
+ {new_history, used_history_in_new_object?} =
+ with true <- use_history_in_new_object?,
+ updated_history when not is_nil(updated_history) <- maybe_history(opts[:new_data]) do
+ {updated_history, true}
+ else
+ _ ->
+ history = history_for(orig_object_data)
+
+ latest_history_item =
+ orig_object_data
+ |> Map.drop(["id", "formerRepresentations"])
+
+ updated_history =
+ history
+ |> Map.put("orderedItems", [latest_history_item | history["orderedItems"]])
+ |> Map.put("totalItems", history["totalItems"] + 1)
+
+ {updated_history, false}
+ end
+
+ updated_object =
+ updated_object
+ |> Map.put("formerRepresentations", new_history)
+
+ %{updated_object: updated_object, used_history_in_new_object?: used_history_in_new_object?}
+ end
+ end
+
+ defp maybe_update_poll(to_be_updated, updated_object) do
+ choice_key = fn data ->
+ if Map.has_key?(data, "anyOf"), do: "anyOf", else: "oneOf"
+ end
+
+ with true <- to_be_updated["type"] == "Question",
+ key <- choice_key.(updated_object),
+ true <- key == choice_key.(to_be_updated),
+ orig_choices <- to_be_updated[key] |> Enum.map(&Map.drop(&1, ["replies"])),
+ new_choices <- updated_object[key] |> Enum.map(&Map.drop(&1, ["replies"])),
+ true <- orig_choices == new_choices do
+ # Choices are the same, but counts are different
+ to_be_updated
+ |> Map.put(key, updated_object[key])
+ else
+ # Choices (or vote type) have changed, do not allow this
+ _ -> to_be_updated
+ end
+ end
+
+ # This calculates the data to be sent as the object of an Update.
+ # new_data's formerRepresentations is not considered.
+ # formerRepresentations is added to the returned data.
+ def make_update_object_data(original_data, new_data, date) do
+ %{data: updated_data, updated: updated} =
+ original_data
+ |> update_content_fields(new_data)
+
+ if not updated do
+ updated_data
+ else
+ %{updated_object: updated_data} =
+ updated_data
+ |> maybe_update_history(original_data, updated: updated, use_history_in_new_object?: false)
+
+ updated_data
+ |> Map.put("updated", date)
+ end
+ end
+
+ # This calculates the data of the new Object from an Update.
+ # new_data's formerRepresentations is considered.
+ def make_new_object_data_from_update_object(original_data, new_data) do
+ update_is_reasonable =
+ with {_, updated} when not is_nil(updated) <- {:cur_updated, new_data["updated"]},
+ {_, {:ok, updated_time, _}} <- {:cur_updated, DateTime.from_iso8601(updated)},
+ {_, last_updated} when not is_nil(last_updated) <-
+ {:last_updated, original_data["updated"] || original_data["published"]},
+ {_, {:ok, last_updated_time, _}} <-
+ {:last_updated, DateTime.from_iso8601(last_updated)},
+ :gt <- DateTime.compare(updated_time, last_updated_time) do
+ :update_everything
+ else
+ # only allow poll updates
+ {:cur_updated, _} -> :no_content_update
+ :eq -> :no_content_update
+ # allow all updates
+ {:last_updated, _} -> :update_everything
+ # allow no updates
+ _ -> false
+ end
+
+ %{
+ updated_object: updated_data,
+ used_history_in_new_object?: used_history_in_new_object?,
+ updated: updated
+ } =
+ if update_is_reasonable == :update_everything do
+ %{data: updated_data, updated: updated} =
+ original_data
+ |> update_content_fields(new_data)
+
+ updated_data
+ |> maybe_update_history(original_data,
+ updated: updated,
+ use_history_in_new_object?: true,
+ new_data: new_data
+ )
+ |> Map.put(:updated, updated)
+ else
+ %{
+ updated_object: original_data,
+ used_history_in_new_object?: false,
+ updated: false
+ }
+ end
+
+ updated_data =
+ if update_is_reasonable != false do
+ updated_data
+ |> maybe_update_poll(new_data)
+ else
+ updated_data
+ end
+
+ %{
+ updated_data: updated_data,
+ updated: updated,
+ used_history_in_new_object?: used_history_in_new_object?
+ }
+ end
+
+ def for_each_history_item(%{"orderedItems" => items} = history, _object, fun) do
+ new_items =
+ Enum.map(items, fun)
+ |> Enum.reduce_while(
+ {:ok, []},
+ fn
+ {:ok, item}, {:ok, acc} -> {:cont, {:ok, acc ++ [item]}}
+ e, _acc -> {:halt, e}
+ end
+ )
+
+ case new_items do
+ {:ok, items} -> {:ok, Map.put(history, "orderedItems", items)}
+ e -> e
+ end
+ end
+
+ def for_each_history_item(history, _, _) do
+ {:ok, history}
+ end
+
+ def do_with_history(object, fun) do
+ with history <- object["formerRepresentations"],
+ object <- Map.drop(object, ["formerRepresentations"]),
+ {_, {:ok, object}} <- {:main_body, fun.(object)},
+ {_, {:ok, history}} <- {:history_items, for_each_history_item(history, object, fun)} do
+ object =
+ if history do
+ Map.put(object, "formerRepresentations", history)
+ else
+ object
+ end
+
+ {:ok, object}
+ else
+ {:main_body, e} -> e
+ {:history_items, e} -> e
+ end
+ end
+end
module = Module.split(module)
match?(["Mix", "Tasks", "Pleroma" | _], module) and
- String.downcase(List.last(module)) == task
+ task_match?(module, task)
end)
if module do
end
end
+ defp task_match?(["Mix", "Tasks", "Pleroma" | module_path], task) do
+ module_path
+ |> Enum.join(".")
+ |> String.downcase()
+ |> String.equivalent?(String.downcase(task))
+ end
+
def migrate(args) do
Mix.Tasks.Pleroma.Ecto.Migrate.run(args)
end
timeout: "5s",
sort: [
"_score",
- %{_timestamp: %{order: "desc", format: "basic_date_time"}}
+ %{"_timestamp" => %{order: "desc", format: "basic_date_time"}}
],
query: %{
bool: %{
Task.async(fn ->
q = es_query(:activity, parsed_query, offset, limit)
- Pleroma.Search.Elasticsearch.Store.search(:activities, q)
- |> Enum.filter(fn x -> Visibility.visible_for_user?(x, user) end)
+ :activities
+ |> Pleroma.Search.Elasticsearch.Store.search(q)
+ |> Enum.filter(fn x ->
+ x.data["type"] == "Create" && x.object.data["type"] == "Note" &&
+ Visibility.visible_for_user?(x, user)
+ end)
end)
activity_results = Task.await(activity_task)
results
|> Enum.map(fn result -> result["_id"] end)
|> Pleroma.Activity.all_by_ids_with_object()
- |> Enum.sort(&(&1.inserted_at >= &2.inserted_at))
else
e ->
Logger.error(e)
alias Ecto.UUID
alias Pleroma.Config
alias Pleroma.Maps
+ alias Pleroma.Web.ActivityPub.Utils
require Logger
@type source ::
{:ok, url_spec} <- Pleroma.Uploaders.Uploader.put_file(opts.uploader, upload) do
{:ok,
%{
+ "id" => Utils.generate_object_id(),
"type" => opts.activity_type,
"mediaType" => upload.content_type,
"url" => [
def notify_and_stream(activity) do
Notification.create_notifications(activity)
- conversation = create_or_bump_conversation(activity, activity.actor)
+ original_activity =
+ case activity do
+ %{data: %{"type" => "Update"}, object: %{data: %{"id" => id}}} ->
+ Activity.get_create_by_object_ap_id_with_object(id)
+
+ _ ->
+ activity
+ end
+
+ conversation = create_or_bump_conversation(original_activity, original_activity.actor)
participations = get_participations(conversation)
stream_out(activity)
stream_out_participations(participations)
@impl true
def stream_out(%Activity{data: %{"type" => data_type}} = activity)
- when data_type in ["Create", "Announce", "Delete"] do
+ when data_type in ["Create", "Announce", "Delete", "Update"] do
activity
|> Topics.get_activity_topics()
|> Streamer.stream(activity)
defp do_unfollow(follower, followed, activity_id, local) when local == true do
with %Activity{} = follow_activity <- fetch_latest_follow(follower, followed),
- {:ok, follow_activity} <- update_follow_state(follow_activity, "cancelled"),
unfollow_data <- make_unfollow_data(follower, followed, follow_activity, activity_id),
{:ok, activity} <- insert(unfollow_data, local),
+ {:ok, _activity} <- Repo.delete(follow_activity),
_ <- notify_and_stream(activity),
:ok <- maybe_federate(activity) do
{:ok, activity}
with %Activity{} = follow_activity <- fetch_latest_follow(follower, followed),
{:ok, _activity} <- Repo.delete(follow_activity),
unfollow_data <- make_unfollow_data(follower, followed, follow_activity, activity_id),
- unfollow_activity <- remote_unfollow_data(unfollow_data),
+ unfollow_activity <- make_unfollow_activity(unfollow_data, false),
_ <- notify_and_stream(unfollow_activity) do
{:ok, unfollow_activity}
else
end
end
- defp remote_unfollow_data(data) do
+ defp make_unfollow_activity(data, local) do
{recipients, _, _} = get_recipients(data)
%Activity{
data: data,
- local: false,
+ local: local,
actor: data["actor"],
recipients: recipients
}
{:ok, data, []}
end
+ defp unicode_emoji_react(_object, data, emoji) do
+ data
+ |> Map.put("content", emoji)
+ |> Map.put("type", "EmojiReact")
+ end
+
+ defp add_emoji_content(data, emoji, url) do
+ data
+ |> Map.put("content", Emoji.maybe_quote(emoji))
+ |> Map.put("type", "EmojiReact")
+ |> Map.put("tag", [
+ %{}
+ |> Map.put("id", url)
+ |> Map.put("type", "Emoji")
+ |> Map.put("name", Emoji.maybe_quote(emoji))
+ |> Map.put(
+ "icon",
+ %{}
+ |> Map.put("type", "Image")
+ |> Map.put("url", url)
+ )
+ ])
+ end
+
+ defp remote_custom_emoji_react(
+ %{data: %{"reactions" => existing_reactions}},
+ data,
+ emoji
+ ) do
+ [emoji_code, instance] = String.split(Emoji.stripped_name(emoji), "@")
+
+ matching_reaction =
+ Enum.find(
+ existing_reactions,
+ fn [name, _, url] ->
+ url = URI.parse(url)
+ url.host == instance && name == emoji_code
+ end
+ )
+
+ if matching_reaction do
+ [name, _, url] = matching_reaction
+ add_emoji_content(data, name, url)
+ else
+ {:error, "Could not react"}
+ end
+ end
+
+ defp remote_custom_emoji_react(_object, _data, _emoji) do
+ {:error, "Could not react"}
+ end
+
+ defp local_custom_emoji_react(data, emoji) do
+ with %{} = emojo <- Emoji.get(emoji) do
+ path = emojo |> Map.get(:file)
+ url = "#{Endpoint.url()}#{path}"
+ add_emoji_content(data, emojo.code, url)
+ else
+ _ -> {:error, "Emoji does not exist"}
+ end
+ end
+
+ defp custom_emoji_react(object, data, emoji) do
+ if String.contains?(emoji, "@") do
+ remote_custom_emoji_react(object, data, emoji)
+ else
+ local_custom_emoji_react(data, emoji)
+ end
+ end
+
@spec emoji_react(User.t(), Object.t(), String.t()) :: {:ok, map(), keyword()}
def emoji_react(actor, object, emoji) do
with {:ok, data, meta} <- object_action(actor, object) do
data =
if Emoji.is_unicode_emoji?(emoji) do
- data
- |> Map.put("content", emoji)
- |> Map.put("type", "EmojiReact")
+ unicode_emoji_react(object, data, emoji)
else
- with %{} = emojo <- Emoji.get(emoji) do
- path = emojo |> Map.get(:file)
- url = "#{Endpoint.url()}#{path}"
-
- data
- |> Map.put("content", emoji)
- |> Map.put("type", "EmojiReact")
- |> Map.put("tag", [
- %{}
- |> Map.put("id", url)
- |> Map.put("type", "Emoji")
- |> Map.put("name", emojo.code)
- |> Map.put(
- "icon",
- %{}
- |> Map.put("type", "Image")
- |> Map.put("url", url)
- )
- ])
- else
- _ -> {:error, "Emoji does not exist"}
- end
+ custom_emoji_react(object, data, emoji)
end
{:ok, data, meta}
end
end
- # Retricted to user updates for now, always public
@spec update(User.t(), Object.t()) :: {:ok, map(), keyword()}
def update(actor, object) do
- to = [Pleroma.Constants.as_public(), actor.follower_address]
+ {to, cc} =
+ if object["type"] in Pleroma.Constants.actor_types() do
+ # User updates, always public
+ {[Pleroma.Constants.as_public(), actor.follower_address], []}
+ else
+ # Status updates, follow the recipients in the object
+ {object["to"] || [], object["cc"] || []}
+ end
{:ok,
%{
"type" => "Update",
"actor" => actor.ap_id,
"object" => object,
- "to" => to
+ "to" => to,
+ "cc" => cc
}, []}
end
suggestions: [
"exclusion.com"
]
+ },
+ %{
+ key: :transparency_obfuscate_domains,
+ label: "MRF domain obfuscation",
+ type: {:list, :string},
+ description:
+ "Obfuscate domains in MRF transparency. This is useful if the domain you're blocking contains words you don't want displayed, but still want to disclose the MRF settings.",
+ suggestions: [
+ "badword.com"
+ ]
}
]
}
@required_description_keys [:key, :related_policy]
+ def filter_one(policy, message) do
+ should_plug_history? =
+ if function_exported?(policy, :history_awareness, 0) do
+ policy.history_awareness()
+ else
+ :manual
+ end
+ |> Kernel.==(:auto)
+
+ if not should_plug_history? do
+ policy.filter(message)
+ else
+ main_result = policy.filter(message)
+
+ with {_, {:ok, main_message}} <- {:main, main_result},
+ {_,
+ %{
+ "formerRepresentations" => %{
+ "orderedItems" => [_ | _]
+ }
+ }} = {_, object} <- {:object, message["object"]},
+ {_, {:ok, new_history}} <-
+ {:history,
+ Pleroma.Object.Updater.for_each_history_item(
+ object["formerRepresentations"],
+ object,
+ fn item ->
+ with {:ok, filtered} <- policy.filter(Map.put(message, "object", item)) do
+ {:ok, filtered["object"]}
+ else
+ e -> e
+ end
+ end
+ )} do
+ {:ok, put_in(main_message, ["object", "formerRepresentations"], new_history)}
+ else
+ {:main, _} -> main_result
+ {:object, _} -> main_result
+ {:history, e} -> e
+ end
+ end
+ end
+
def filter(policies, %{} = message) do
policies
|> Enum.reduce({:ok, message}, fn
- policy, {:ok, message} -> policy.filter(message)
+ policy, {:ok, message} -> filter_one(policy, message)
_, error -> error
end)
end
def get_policies do
Pleroma.Config.get([:mrf, :policies], [])
|> get_policies()
- |> Enum.concat([Pleroma.Web.ActivityPub.MRF.HashtagPolicy])
+ |> Enum.concat([
+ Pleroma.Web.ActivityPub.MRF.HashtagPolicy,
+ Pleroma.Web.ActivityPub.MRF.InlineQuotePolicy
+ ])
+ |> Enum.uniq()
end
defp get_policies(policy) when is_atom(policy), do: [policy]
require Logger
+ @impl true
+ def history_awareness, do: :auto
+
# has the user successfully posted before?
defp old_user?(%User{} = u) do
u.note_count > 0 || u.follower_count > 0
@reply_prefix Regex.compile!("^re:[[:space:]]*", [:caseless])
+ def history_awareness, do: :auto
+
def filter_by_summary(
%{data: %{"summary" => parent_summary}} = _in_reply_to,
%{"summary" => child_summary} = child
def filter_by_summary(_in_reply_to, child), do: child
- def filter(%{"type" => "Create", "object" => child_object} = object)
- when is_map(child_object) do
+ def filter(%{"type" => type, "object" => child_object} = object)
+ when type in ["Create", "Update"] and is_map(child_object) do
child =
child_object["inReplyTo"]
|> Object.normalize(fetch: false)
@behaviour Pleroma.Web.ActivityPub.MRF.Policy
+ @impl true
+ def history_awareness, do: :manual
+
defp check_reject(message, hashtags) do
if Enum.any?(Config.get([:mrf_hashtag, :reject]), fn match -> match in hashtags end) do
{:reject, "[HashtagPolicy] Matches with rejected keyword"}
defp check_ftl_removal(message, _hashtags), do: {:ok, message}
- defp check_sensitive(message, hashtags) do
- if Enum.any?(Config.get([:mrf_hashtag, :sensitive]), fn match -> match in hashtags end) do
- {:ok, Kernel.put_in(message, ["object", "sensitive"], true)}
- else
- {:ok, message}
- end
+ defp check_sensitive(message) do
+ {:ok, new_object} =
+ Object.Updater.do_with_history(message["object"], fn object ->
+ hashtags = Object.hashtags(%Object{data: object})
+
+ if Enum.any?(Config.get([:mrf_hashtag, :sensitive]), fn match -> match in hashtags end) do
+ {:ok, Map.put(object, "sensitive", true)}
+ else
+ {:ok, object}
+ end
+ end)
+
+ {:ok, Map.put(message, "object", new_object)}
end
@impl true
- def filter(%{"type" => "Create", "object" => object} = message) do
- hashtags = Object.hashtags(%Object{data: object})
+ def filter(%{"type" => type, "object" => object} = message) when type in ["Create", "Update"] do
+ history_items =
+ with %{"formerRepresentations" => %{"orderedItems" => items}} <- object do
+ items
+ else
+ _ -> []
+ end
+
+ historical_hashtags =
+ Enum.reduce(history_items, [], fn item, acc ->
+ acc ++ Object.hashtags(%Object{data: item})
+ end)
+
+ hashtags = Object.hashtags(%Object{data: object}) ++ historical_hashtags
if hashtags != [] do
with {:ok, message} <- check_reject(message, hashtags),
- {:ok, message} <- check_ftl_removal(message, hashtags),
- {:ok, message} <- check_sensitive(message, hashtags) do
+ {:ok, message} <-
+ (if "type" == "Create" do
+ check_ftl_removal(message, hashtags)
+ else
+ {:ok, message}
+ end),
+ {:ok, message} <- check_sensitive(message) do
{:ok, message}
end
else
end
defp check_reject(%{"object" => %{} = object} = message) do
- payload = object_payload(object)
-
- if Enum.any?(Pleroma.Config.get([:mrf_keyword, :reject]), fn pattern ->
- string_matches?(payload, pattern)
- end) do
- {:reject, "[KeywordPolicy] Matches with rejected keyword"}
- else
+ with {:ok, _new_object} <-
+ Pleroma.Object.Updater.do_with_history(object, fn object ->
+ payload = object_payload(object)
+
+ if Enum.any?(Pleroma.Config.get([:mrf_keyword, :reject]), fn pattern ->
+ string_matches?(payload, pattern)
+ end) do
+ {:reject, "[KeywordPolicy] Matches with rejected keyword"}
+ else
+ {:ok, message}
+ end
+ end) do
{:ok, message}
+ else
+ e -> e
end
end
- defp check_ftl_removal(%{"to" => to, "object" => %{} = object} = message) do
- payload = object_payload(object)
+ defp check_ftl_removal(%{"type" => "Create", "to" => to, "object" => %{} = object} = message) do
+ check_keyword = fn object ->
+ payload = object_payload(object)
- if Pleroma.Constants.as_public() in to and
- Enum.any?(Pleroma.Config.get([:mrf_keyword, :federated_timeline_removal]), fn pattern ->
+ if Enum.any?(Pleroma.Config.get([:mrf_keyword, :federated_timeline_removal]), fn pattern ->
string_matches?(payload, pattern)
end) do
+ {:should_delist, nil}
+ else
+ {:ok, %{}}
+ end
+ end
+
+ should_delist? = fn object ->
+ with {:ok, _} <- Pleroma.Object.Updater.do_with_history(object, check_keyword) do
+ false
+ else
+ _ -> true
+ end
+ end
+
+ if Pleroma.Constants.as_public() in to and should_delist?.(object) do
to = List.delete(to, Pleroma.Constants.as_public())
cc = [Pleroma.Constants.as_public() | message["cc"] || []]
end
end
+ defp check_ftl_removal(message) do
+ {:ok, message}
+ end
+
defp check_replace(%{"object" => %{} = object} = message) do
- object =
+ replace_kw = fn object ->
["content", "name", "summary"]
|> Enum.filter(fn field -> Map.has_key?(object, field) && object[field] end)
|> Enum.reduce(object, fn field, object ->
Map.put(object, field, data)
end)
+ |> (fn object -> {:ok, object} end).()
+ end
+
+ {:ok, object} = Pleroma.Object.Updater.do_with_history(object, replace_kw)
message = Map.put(message, "object", object)
end
@impl true
- def filter(%{"type" => "Create", "object" => %{"content" => _content}} = message) do
+ def filter(%{"type" => type, "object" => %{"content" => _content}} = message)
+ when type in ["Create", "Update"] do
with {:ok, message} <- check_reject(message),
{:ok, message} <- check_ftl_removal(message),
{:ok, message} <- check_replace(message) do
recv_timeout: 10_000
]
+ @impl true
+ def history_awareness, do: :auto
+
defp prefetch(url) do
# Fetching only proxiable resources
if MediaProxy.enabled?() and MediaProxy.url_proxiable?(url) do
end
@impl true
- def filter(
- %{"type" => "Create", "object" => %{"attachment" => attachments} = _object} = message
- )
- when is_list(attachments) and length(attachments) > 0 do
+ def filter(%{"type" => type, "object" => %{"attachment" => attachments} = _object} = message)
+ when type in ["Create", "Update"] and is_list(attachments) and length(attachments) > 0 do
preload(message)
{:ok, message}
@impl true
def filter(%{"actor" => actor} = object) do
with true <- is_local?(actor),
+ true <- is_eligible_type?(object),
true <- is_note?(object),
false <- has_attachment?(object),
true <- only_mentions?(object) do
end
defp has_attachment?(%{
- "type" => "Create",
"object" => %{"type" => "Note", "attachment" => attachments}
})
when length(attachments) > 0,
defp has_attachment?(_), do: false
- defp only_mentions?(%{"type" => "Create", "object" => %{"type" => "Note", "source" => source}})
- when is_binary(source) do
- non_mentions =
- source |> String.split() |> Enum.filter(&(not String.starts_with?(&1, "@"))) |> length
+ defp only_mentions?(%{"object" => %{"type" => "Note", "source" => source}}) do
+ source =
+ case source do
+ %{"content" => text} -> text
+ _ -> source
+ end
- if non_mentions > 0 do
- false
- else
- true
- end
- end
-
- defp only_mentions?(%{
- "type" => "Create",
- "object" => %{"type" => "Note", "source" => %{"content" => source}}
- })
- when is_binary(source) do
non_mentions =
source |> String.split() |> Enum.filter(&(not String.starts_with?(&1, "@"))) |> length
defp only_mentions?(_), do: false
- defp is_note?(%{"type" => "Create", "object" => %{"type" => "Note"}}), do: true
+ defp is_note?(%{"object" => %{"type" => "Note"}}), do: true
defp is_note?(_), do: false
+ defp is_eligible_type?(%{"type" => type}) when type in ["Create", "Update"], do: true
+ defp is_eligible_type?(_), do: false
+
@impl true
def describe, do: {:ok, %{}}
end
@moduledoc "Ensure no content placeholder is present (such as the dot from mastodon)"
@behaviour Pleroma.Web.ActivityPub.MRF.Policy
+ @impl true
+ def history_awareness, do: :auto
+
@impl true
def filter(
%{
- "type" => "Create",
+ "type" => type,
"object" => %{"content" => content, "attachment" => _} = _child_object
} = object
)
- when content in [".", "<p>.</p>"] do
+ when type in ["Create", "Update"] and content in [".", "<p>.</p>"] do
{:ok, put_in(object, ["object", "content"], "")}
end
@behaviour Pleroma.Web.ActivityPub.MRF.Policy
@impl true
- def filter(%{"type" => "Create", "object" => child_object} = object) do
+ def history_awareness, do: :auto
+
+ @impl true
+ def filter(%{"type" => type, "object" => child_object} = object)
+ when type in ["Create", "Update"] do
scrub_policy = Pleroma.Config.get([:mrf_normalize_markup, :scrub_policy])
content =
label: String.t(),
description: String.t()
}
- @optional_callbacks config_description: 0
+ @callback history_awareness() :: :auto | :manual
+ @optional_callbacks config_description: 0, history_awareness: 0
end
def filter(object), do: {:ok, object}
+ defp obfuscate(string) when is_binary(string) do
+ string
+ |> to_charlist()
+ |> Enum.with_index()
+ |> Enum.map(fn
+ {?., _index} ->
+ ?.
+
+ {char, index} ->
+ if 3 <= index && index < String.length(string) - 3, do: ?*, else: char
+ end)
+ |> to_string()
+ end
+
+ defp maybe_obfuscate(host, obfuscations) do
+ if MRF.subdomain_match?(obfuscations, host) do
+ obfuscate(host)
+ else
+ host
+ end
+ end
+
@impl true
def describe do
exclusions = Config.get([:mrf, :transparency_exclusions]) |> MRF.instance_list_from_tuples()
+ obfuscations =
+ Config.get([:mrf, :transparency_obfuscate_domains], []) |> MRF.subdomains_regex()
+
mrf_simple_excluded =
Config.get(:mrf_simple)
|> Enum.map(fn {rule, instances} ->
mrf_simple =
mrf_simple_excluded
|> Enum.map(fn {rule, instances} ->
- {rule, Enum.map(instances, fn {host, _} -> host end)}
+ {rule, Enum.map(instances, fn {host, _} -> maybe_obfuscate(host, obfuscations) end)}
end)
|> Map.new()
|> Enum.map(fn {rule, instances} ->
instances =
instances
- |> Enum.map(fn {host, reason} -> {host, %{"reason" => reason}} end)
+ |> Enum.map(fn {host, reason} ->
+ {maybe_obfuscate(host, obfuscations), %{"reason" => reason}}
+ end)
|> Map.new()
{rule, instances}
meta
)
when objtype in ~w[Question Answer Audio Video Event Article Note Page] do
- with {:ok, object_data} <- cast_and_apply(object),
- meta = Keyword.put(meta, :object_data, object_data |> stringify_keys),
+ with {:ok, object_data} <- cast_and_apply_and_stringify_with_history(object),
+ meta = Keyword.put(meta, :object_data, object_data),
{:ok, create_activity} <-
create_activity
|> CreateGenericValidator.cast_and_validate(meta)
end
with {:ok, object} <-
- object
- |> validator.cast_and_validate()
- |> Ecto.Changeset.apply_action(:insert) do
- object = stringify_keys(object)
+ do_separate_with_history(object, fn object ->
+ with {:ok, object} <-
+ object
+ |> validator.cast_and_validate()
+ |> Ecto.Changeset.apply_action(:insert) do
+ object = stringify_keys(object)
+
+ # Insert copy of hashtags as strings for the non-hashtag table indexing
+ tag = (object["tag"] || []) ++ Object.hashtags(%Object{data: object})
+ object = Map.put(object, "tag", tag)
+
+ {:ok, object}
+ end
+ end) do
+ {:ok, object, meta}
+ end
+ end
- # Insert copy of hashtags as strings for the non-hashtag table indexing
- tag = (object["tag"] || []) ++ Object.hashtags(%Object{data: object})
- object = Map.put(object, "tag", tag)
+ def validate(
+ %{"type" => "Update", "object" => %{"type" => objtype} = object} = update_activity,
+ meta
+ )
+ when objtype in ~w[Question Answer Audio Video Event Article Note Page] do
+ with {_, false} <- {:local, Access.get(meta, :local, false)},
+ {_, {:ok, object_data, _}} <- {:object_validation, validate(object, meta)},
+ meta = Keyword.put(meta, :object_data, object_data),
+ {:ok, update_activity} <-
+ update_activity
+ |> UpdateValidator.cast_and_validate()
+ |> Ecto.Changeset.apply_action(:insert) do
+ update_activity = stringify_keys(update_activity)
+ {:ok, update_activity, meta}
+ else
+ {:local, _} ->
+ with {:ok, object} <-
+ update_activity
+ |> UpdateValidator.cast_and_validate()
+ |> Ecto.Changeset.apply_action(:insert) do
+ object = stringify_keys(object)
+ {:ok, object, meta}
+ end
- {:ok, object, meta}
+ {:object_validation, e} ->
+ e
end
end
def validate(o, m), do: {:error, {:validator_not_set, {o, m}}}
+ def cast_and_apply_and_stringify_with_history(object) do
+ do_separate_with_history(object, fn object ->
+ with {:ok, object_data} <- cast_and_apply(object),
+ object_data <- object_data |> stringify_keys() do
+ {:ok, object_data}
+ end
+ end)
+ end
+
def cast_and_apply(%{"type" => "Question"} = object) do
QuestionValidator.cast_and_apply(object)
end
Object.normalize(object["object"], fetch: true)
:ok
end
+
+ defp for_each_history_item(
+ %{"type" => "OrderedCollection", "orderedItems" => items} = history,
+ object,
+ fun
+ ) do
+ processed_items =
+ Enum.map(items, fn item ->
+ with item <- Map.put(item, "id", object["id"]),
+ {:ok, item} <- fun.(item) do
+ item
+ else
+ _ -> nil
+ end
+ end)
+
+ if Enum.all?(processed_items, &(not is_nil(&1))) do
+ {:ok, Map.put(history, "orderedItems", processed_items)}
+ else
+ {:error, :invalid_history}
+ end
+ end
+
+ defp for_each_history_item(nil, _object, _fun) do
+ {:ok, nil}
+ end
+
+ defp for_each_history_item(_, _object, _fun) do
+ {:error, :invalid_history}
+ end
+
+ # fun is (object -> {:ok, validated_object_with_string_keys})
+ defp do_separate_with_history(object, fun) do
+ with history <- object["formerRepresentations"],
+ object <- Map.drop(object, ["formerRepresentations"]),
+ {_, {:ok, object}} <- {:main_body, fun.(object)},
+ {_, {:ok, history}} <- {:history_items, for_each_history_item(history, object, fun)} do
+ object =
+ if history do
+ Map.put(object, "formerRepresentations", history)
+ else
+ object
+ end
+
+ {:ok, object}
+ else
+ {:main_body, e} -> e
+ {:history_items, e} -> e
+ end
+ end
end
use Ecto.Schema
alias Pleroma.User
alias Pleroma.EctoType.ActivityPub.ObjectValidators
- alias Pleroma.Object.Fetcher
alias Pleroma.Web.CommonAPI.Utils
alias Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes
alias Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations
defp fix_url(%{"url" => url} = data) when is_map(url), do: Map.put(data, "url", url["href"])
defp fix_url(data), do: data
- defp fix_tag(%{"tag" => tag} = data) when is_list(tag), do: data
+ defp fix_tag(%{"tag" => tag} = data) when is_list(tag) do
+ Map.put(data, "tag", Enum.filter(tag, &is_map/1))
+ end
+
defp fix_tag(%{"tag" => tag} = data) when is_map(tag), do: Map.put(data, "tag", [tag])
defp fix_tag(data), do: Map.drop(data, ["tag"])
- defp fix_replies(%{"replies" => %{"first" => %{"items" => replies}}} = data)
- when is_list(replies),
- do: Map.put(data, "replies", replies)
-
- defp fix_replies(%{"replies" => %{"items" => replies}} = data) when is_list(replies),
- do: Map.put(data, "replies", replies)
-
- defp fix_replies(%{"replies" => replies} = data) when is_bitstring(replies),
- do: Map.drop(data, ["replies"])
+ defp fix_replies(%{"replies" => replies} = data) when is_list(replies), do: data
defp fix_replies(%{"replies" => %{"first" => first}} = data) do
- with {:ok, %{"orderedItems" => replies}} <-
- Fetcher.fetch_and_contain_remote_object_from_id(first) do
+ with {:ok, replies} <- Akkoma.Collections.Fetcher.fetch_collection(first) do
Map.put(data, "replies", replies)
else
{:error, _} ->
end
end
- defp fix_replies(data), do: data
+ defp fix_replies(%{"replies" => %{"items" => replies}} = data) when is_list(replies),
+ do: Map.put(data, "replies", replies)
+
+ defp fix_replies(data), do: Map.delete(data, "replies")
defp remote_mention_resolver(
%{"id" => ap_id, "tag" => tags},
end
# https://github.com/misskey-dev/misskey/pull/8787
+ # Misskey has an awful tendency to drop all custom formatting when it sends remotely
+ # So this basically reprocesses their MFM source
defp fix_misskey_content(
%{"source" => %{"mediaType" => "text/x.misskeymarkdown", "content" => content}} = object
)
@primary_key false
embedded_schema do
+ field(:id, :string)
field(:type, :string)
field(:mediaType, :string, default: "application/octet-stream")
field(:name, :string)
|> fix_url()
struct
- |> cast(data, [:type, :mediaType, :name, :blurhash])
+ |> cast(data, [:id, :type, :mediaType, :name, :blurhash])
|> cast_embed(:url, with: &url_changeset/2, required: true)
|> validate_inclusion(:type, ~w[Link Document Audio Image Video])
|> validate_required([:type, :mediaType])
field(:content, :string)
field(:published, ObjectValidators.DateTime)
+ field(:updated, ObjectValidators.DateTime)
field(:emoji, ObjectValidators.Emoji, default: %{})
embeds_many(:attachment, AttachmentValidator)
end
alias Pleroma.Object
alias Pleroma.Object.Containment
alias Pleroma.User
- alias Pleroma.Web.ActivityPub.Transmogrifier
alias Pleroma.Web.ActivityPub.Utils
+ require Pleroma.Constants
def cast_and_filter_recipients(message, field, follower_collection, field_fallback \\ []) do
{:ok, data} = ObjectValidators.Recipients.cast(message[field] || field_fallback)
|> cast_and_filter_recipients("cc", follower_collection)
|> cast_and_filter_recipients("bto", follower_collection)
|> cast_and_filter_recipients("bcc", follower_collection)
- |> Transmogrifier.fix_implicit_addressing(follower_collection)
+ |> fix_implicit_addressing(follower_collection)
end
def fix_activity_addressing(activity) do
|> cast_and_filter_recipients("cc", follower_collection)
|> cast_and_filter_recipients("bto", follower_collection)
|> cast_and_filter_recipients("bcc", follower_collection)
- |> Transmogrifier.fix_implicit_addressing(follower_collection)
+ |> fix_implicit_addressing(follower_collection)
end
def fix_actor(data) do
Map.put(data, "to", to)
end
+
+ # if as:Public is addressed, then make sure the followers collection is also addressed
+ # so that the activities will be delivered to local users.
+ def fix_implicit_addressing(%{"to" => to, "cc" => cc} = object, followers_collection) do
+ recipients = to ++ cc
+
+ if followers_collection not in recipients do
+ cond do
+ Pleroma.Constants.as_public() in cc ->
+ to = to ++ [followers_collection]
+ Map.put(object, "to", to)
+
+ Pleroma.Constants.as_public() in to ->
+ cc = cc ++ [followers_collection]
+ Map.put(object, "cc", cc)
+
+ true ->
+ object
+ end
+ else
+ object
+ end
+ end
end
alias Pleroma.User
alias Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes
alias Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations
- alias Pleroma.Web.ActivityPub.Transmogrifier
import Ecto.Changeset
|> CommonFixes.cast_and_filter_recipients("cc", follower_collection, object["cc"])
|> CommonFixes.cast_and_filter_recipients("bto", follower_collection, object["bto"])
|> CommonFixes.cast_and_filter_recipients("bcc", follower_collection, object["bcc"])
- |> Transmogrifier.fix_implicit_addressing(follower_collection)
+ |> CommonFixes.fix_implicit_addressing(follower_collection)
end
def fix(data, meta) do
import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations
@primary_key false
- @emoji_regex ~r/:[A-Za-z0-9_-]+:/
+ @emoji_regex ~r/:[A-Za-z0-9_-]+(@.+)?:/
embedded_schema do
quote do
with actor = get_field(cng, :actor),
object = get_field(cng, :object),
{:ok, object_id} <- ObjectValidators.ObjectID.cast(object),
- true <- actor == object_id do
+ actor_uri <- URI.parse(actor),
+ object_uri <- URI.parse(object_id),
+ true <- actor_uri.host == object_uri.host do
cng
else
_e ->
alias Pleroma.Web.Streamer
alias Pleroma.Workers.PollWorker
+ require Pleroma.Constants
require Logger
@logger Pleroma.Config.get([:side_effects, :logger], Logger)
# Tasks this handles:
# - Update the user
+ # - Update a non-user object (Note, Question, etc.)
#
# For a local user, we also get a changeset with the full information, so we
# can update non-federating, non-activitypub settings as well.
@impl true
def handle(%{data: %{"type" => "Update", "object" => updated_object}} = object, meta) do
- if changeset = Keyword.get(meta, :user_update_changeset) do
- changeset
- |> User.update_and_set_cache()
+ updated_object_id = updated_object["id"]
+
+ with {_, true} <- {:has_id, is_binary(updated_object_id)},
+ %{"type" => type} <- updated_object,
+ {_, is_user} <- {:is_user, type in Pleroma.Constants.actor_types()} do
+ if is_user do
+ handle_update_user(object, meta)
+ else
+ handle_update_object(object, meta)
+ end
else
- {:ok, new_user_data} = ActivityPub.user_data_from_user_object(updated_object)
-
- User.get_by_ap_id(updated_object["id"])
- |> User.remote_user_changeset(new_user_data)
- |> User.update_and_set_cache()
+ _ ->
+ {:ok, object, meta}
end
-
- {:ok, object, meta}
end
# Tasks this handles:
{:ok, object, meta}
end
+ defp handle_update_user(
+ %{data: %{"type" => "Update", "object" => updated_object}} = object,
+ meta
+ ) do
+ if changeset = Keyword.get(meta, :user_update_changeset) do
+ changeset
+ |> User.update_and_set_cache()
+ else
+ {:ok, new_user_data} = ActivityPub.user_data_from_user_object(updated_object)
+
+ User.get_by_ap_id(updated_object["id"])
+ |> User.remote_user_changeset(new_user_data)
+ |> User.update_and_set_cache()
+ end
+
+ {:ok, object, meta}
+ end
+
+ defp handle_update_object(
+ %{data: %{"type" => "Update", "object" => updated_object}} = object,
+ meta
+ ) do
+ orig_object_ap_id = updated_object["id"]
+ orig_object = Object.get_by_ap_id(orig_object_ap_id)
+ orig_object_data = orig_object.data
+
+ updated_object =
+ if meta[:local] do
+ # If this is a local Update, we don't process it by transmogrifier,
+ # so we use the embedded object as-is.
+ updated_object
+ else
+ meta[:object_data]
+ end
+
+ if orig_object_data["type"] in Pleroma.Constants.updatable_object_types() do
+ %{
+ updated_data: updated_object_data,
+ updated: updated,
+ used_history_in_new_object?: used_history_in_new_object?
+ } = Object.Updater.make_new_object_data_from_update_object(orig_object_data, updated_object)
+
+ changeset =
+ orig_object
+ |> Repo.preload(:hashtags)
+ |> Object.change(%{data: updated_object_data})
+
+ with {:ok, new_object} <- Repo.update(changeset),
+ {:ok, _} <- Object.invalid_object_cache(new_object),
+ {:ok, _} <- Object.set_cache(new_object),
+ # The metadata/utils.ex uses the object id for the cache.
+ {:ok, _} <- Pleroma.Activity.HTML.invalidate_cache_for(new_object.id) do
+ if used_history_in_new_object? do
+ with create_activity when not is_nil(create_activity) <-
+ Pleroma.Activity.get_create_by_object_ap_id(orig_object_ap_id),
+ {:ok, _} <- Pleroma.Activity.HTML.invalidate_cache_for(create_activity.id) do
+ nil
+ else
+ _ -> nil
+ end
+ end
+
+ if updated do
+ object
+ |> Activity.normalize()
+ |> ActivityPub.notify_and_stream()
+ end
+ end
+ end
+
+ {:ok, object, meta}
+ end
+
def handle_object_creation(%{"type" => "Question"} = object, activity, meta) do
with {:ok, object, meta} <- Pipeline.common_pipeline(object, meta) do
PollWorker.schedule_poll_end(activity)
alias Pleroma.Web.ActivityPub.Pipeline
alias Pleroma.Web.ActivityPub.Utils
alias Pleroma.Web.ActivityPub.Visibility
+ alias Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes
alias Pleroma.Web.Federator
alias Pleroma.Workers.TransmogrifierWorker
|> Map.put("cc", final_cc)
end
- # if as:Public is addressed, then make sure the followers collection is also addressed
- # so that the activities will be delivered to local users.
- def fix_implicit_addressing(%{"to" => to, "cc" => cc} = object, followers_collection) do
- recipients = to ++ cc
-
- if followers_collection not in recipients do
- cond do
- Pleroma.Constants.as_public() in cc ->
- to = to ++ [followers_collection]
- Map.put(object, "to", to)
-
- Pleroma.Constants.as_public() in to ->
- cc = cc ++ [followers_collection]
- Map.put(object, "cc", cc)
-
- true ->
- object
- end
- else
- object
- end
- end
-
def fix_addressing(object) do
{:ok, %User{follower_address: follower_collection}} =
object
|> fix_addressing_list("bto")
|> fix_addressing_list("bcc")
|> fix_explicit_addressing(follower_collection)
- |> fix_implicit_addressing(follower_collection)
+ |> CommonFixes.fix_implicit_addressing(follower_collection)
end
def fix_actor(%{"attributedTo" => actor} = object) do
|> strip_internal_fields
|> strip_internal_tags
|> set_type
+ |> maybe_process_history
+ end
+
+ defp maybe_process_history(%{"formerRepresentations" => %{"orderedItems" => history}} = object) do
+ processed_history =
+ Enum.map(
+ history,
+ fn
+ item when is_map(item) -> prepare_object(item)
+ item -> item
+ end
+ )
+
+ put_in(object, ["formerRepresentations", "orderedItems"], processed_history)
+ end
+
+ defp maybe_process_history(object) do
+ object
end
# @doc
{:ok, data}
end
+ def prepare_outgoing(%{"type" => "Update", "object" => %{"type" => objtype} = object} = data)
+ when objtype in Pleroma.Constants.updatable_object_types() do
+ object =
+ object
+ |> prepare_object
+
+ data =
+ data
+ |> Map.put("object", object)
+ |> Map.merge(Utils.make_json_ld_header())
+ |> Map.delete("bcc")
+
+ {:ok, data}
+ end
+
def prepare_outgoing(%{"type" => "Announce", "actor" => ap_id, "object" => object_id} = data) do
object =
object_id
object
) do
reactions = get_cached_emoji_reactions(object)
- emoji = stripped_emoji_name(emoji)
+ emoji = Pleroma.Emoji.stripped_name(emoji)
url = emoji_url(emoji, activity)
new_reactions =
update_element_in_object("reaction", new_reactions, object, count)
end
- defp stripped_emoji_name(name) do
- name
- |> String.replace_leading(":", "")
- |> String.replace_trailing(":", "")
- end
-
defp emoji_url(
name,
%Activity{
%Activity{data: %{"content" => emoji, "actor" => actor}} = activity,
object
) do
- emoji = stripped_emoji_name(emoji)
+ emoji = Pleroma.Emoji.stripped_name(emoji)
reactions = get_cached_emoji_reactions(object)
url = emoji_url(emoji, activity)
{:ok, activity}
end
- def update_follow_state(
- %Activity{} = activity,
- state
- ) do
- new_data = Map.put(activity.data, "state", state)
- changeset = Changeset.change(activity, data: new_data)
-
- with {:ok, activity} <- Repo.update(changeset) do
- {:ok, activity}
- end
- end
-
@doc """
Makes a follow activity data for the given follower and followed
"""
def get_latest_reaction(internal_activity_id, %{ap_id: ap_id}, emoji) do
%{data: %{"object" => object_ap_id}} = Activity.get_by_id(internal_activity_id)
-
emoji = Pleroma.Emoji.maybe_quote(emoji)
"EmojiReact"
|> Activity.Queries.by_type()
|> where(actor: ^ap_id)
- |> where([activity], fragment("?->>'content' = ?", activity.data, ^emoji))
+ |> custom_emoji_discriminator(emoji)
|> Activity.Queries.by_object_id(object_ap_id)
|> order_by([activity], fragment("? desc nulls last", activity.id))
|> limit(1)
|> Repo.one()
end
+ defp custom_emoji_discriminator(query, emoji) do
+ if String.contains?(emoji, "@") do
+ stripped = Pleroma.Emoji.stripped_name(emoji)
+ [name, domain] = String.split(stripped, "@")
+ domain_pattern = "%" <> domain <> "%"
+ emoji_pattern = Pleroma.Emoji.maybe_quote(name)
+
+ query
+ |> where([activity], fragment("?->>'content' = ?
+ AND EXISTS (
+ SELECT FROM jsonb_array_elements(?->'tag') elem
+ WHERE elem->>'id' ILIKE ?
+ )", activity.data, ^emoji_pattern, activity.data, ^domain_pattern))
+ else
+ query
+ |> where([activity], fragment("?->>'content' = ?", activity.data, ^emoji))
+ end
+ end
+
#### Announce-related helpers
@doc """
--- /dev/null
+defmodule Pleroma.Web.AkkomaAPI.TranslationController do
+ use Pleroma.Web, :controller
+
+ alias Pleroma.Web.Plugs.OAuthScopesPlug
+
+ @cachex Pleroma.Config.get([:cachex, :provider], Cachex)
+
+ @unauthenticated_access %{fallback: :proceed_unauthenticated, scopes: []}
+ plug(
+ OAuthScopesPlug,
+ %{@unauthenticated_access | scopes: ["read:statuses"]}
+ when action in [
+ :languages
+ ]
+ )
+
+ plug(Pleroma.Web.ApiSpec.CastAndValidate)
+ defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.TranslationOperation
+
+ action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
+
+ @doc "GET /api/v1/akkoma/translation/languages"
+ def languages(conn, _params) do
+ with {:ok, source_languages, dest_languages} <- get_languages() do
+ conn
+ |> json(%{source: source_languages, target: dest_languages})
+ else
+ e -> IO.inspect(e)
+ end
+ end
+
+ defp get_languages do
+ module = Pleroma.Config.get([:translator, :module])
+
+ @cachex.fetch!(:translations_cache, "languages:#{module}}", fn _ ->
+ with {:ok, source_languages, dest_languages} <- module.languages() do
+ {:ok, source_languages, dest_languages}
+ else
+ {:error, err} -> {:ignore, {:error, err}}
+ end
+ end)
+ end
+end
alias OpenApiSpex.Operation
alias OpenApiSpex.Schema
alias Pleroma.Web.ApiSpec.AccountOperation
+ alias Pleroma.Web.ApiSpec.Schemas.Account
alias Pleroma.Web.ApiSpec.Schemas.ApiError
+ alias Pleroma.Web.ApiSpec.Schemas.Attachment
alias Pleroma.Web.ApiSpec.Schemas.BooleanLike
+ alias Pleroma.Web.ApiSpec.Schemas.Emoji
alias Pleroma.Web.ApiSpec.Schemas.FlakeID
+ alias Pleroma.Web.ApiSpec.Schemas.Poll
alias Pleroma.Web.ApiSpec.Schemas.ScheduledStatus
alias Pleroma.Web.ApiSpec.Schemas.Status
alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope
}
end
+ def translate_operation do
+ %Operation{
+ tags: ["Retrieve status translation"],
+ summary: "Translate status",
+ description: "View the translation of a given status",
+ operationId: "StatusController.translation",
+ security: [%{"oAuth" => ["read:statuses"]}],
+ parameters: [id_param(), language_param(), source_language_param()],
+ responses: %{
+ 200 => Operation.response("Translation", "application/json", translation()),
+ 400 => Operation.response("Error", "application/json", ApiError),
+ 404 => Operation.response("Not Found", "application/json", ApiError)
+ }
+ }
+ end
+
+ def show_history_operation do
+ %Operation{
+ tags: ["Retrieve status history"],
+ summary: "Status history",
+ description: "View history of a status",
+ operationId: "StatusController.show_history",
+ security: [%{"oAuth" => ["read:statuses"]}],
+ parameters: [
+ id_param()
+ ],
+ responses: %{
+ 200 => status_history_response(),
+ 404 => Operation.response("Not Found", "application/json", ApiError)
+ }
+ }
+ end
+
+ def show_source_operation do
+ %Operation{
+ tags: ["Retrieve status source"],
+ summary: "Status source",
+ description: "View source of a status",
+ operationId: "StatusController.show_source",
+ security: [%{"oAuth" => ["read:statuses"]}],
+ parameters: [
+ id_param()
+ ],
+ responses: %{
+ 200 => status_source_response(),
+ 404 => Operation.response("Not Found", "application/json", ApiError)
+ }
+ }
+ end
+
+ def update_operation do
+ %Operation{
+ tags: ["Update status"],
+ summary: "Update status",
+ description: "Change the content of a status",
+ operationId: "StatusController.update",
+ security: [%{"oAuth" => ["write:statuses"]}],
+ parameters: [
+ id_param()
+ ],
+ requestBody: request_body("Parameters", update_request(), required: true),
+ responses: %{
+ 200 => status_response(),
+ 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
}
end
+ defp update_request do
+ %Schema{
+ title: "StatusUpdateRequest",
+ type: :object,
+ properties: %{
+ status: %Schema{
+ type: :string,
+ nullable: true,
+ description:
+ "Text content of the status. If `media_ids` is provided, this becomes optional. Attaching a `poll` is optional while `status` is provided."
+ },
+ media_ids: %Schema{
+ nullable: true,
+ type: :array,
+ items: %Schema{type: :string},
+ description: "Array of Attachment ids to be attached as media."
+ },
+ poll: poll_params(),
+ sensitive: %Schema{
+ allOf: [BooleanLike],
+ nullable: true,
+ description: "Mark status and attached media as sensitive?"
+ },
+ spoiler_text: %Schema{
+ type: :string,
+ nullable: true,
+ description:
+ "Text to be shown as a warning or subject before the actual content. Statuses are generally collapsed behind this field."
+ },
+ content_type: %Schema{
+ type: :string,
+ nullable: true,
+ description:
+ "The MIME type of the status, it is transformed into HTML by the backend. You can get the list of the supported MIME types with the nodeinfo endpoint."
+ },
+ to: %Schema{
+ type: :array,
+ nullable: true,
+ items: %Schema{type: :string},
+ description:
+ "A list of nicknames (like `lain@soykaf.club` or `lain` on the local server) that will be used to determine who is going to be addressed by this post. Using this will disable the implicit addressing by mentioned names in the `status` body, only the people in the `to` list will be addressed. The normal rules for for post visibility are not affected by this and will still apply"
+ }
+ },
+ example: %{
+ "status" => "What time is it?",
+ "sensitive" => "false",
+ "poll" => %{
+ "options" => ["Cofe", "Adventure"],
+ "expires_in" => 420
+ }
+ }
+ }
+ end
+
def poll_params do
%Schema{
nullable: true,
)
end
+ defp language_param do
+ Operation.parameter(:language, :path, :string, "ISO 639 language code", example: "en")
+ end
+
+ defp source_language_param do
+ Operation.parameter(:from, :query, :string, "ISO 639 language code", example: "en")
+ end
+
defp status_response do
Operation.response("Status", "application/json", Status)
end
+ defp status_history_response do
+ Operation.response(
+ "Status History",
+ "application/json",
+ %Schema{
+ title: "Status history",
+ description: "Response schema for history of a status",
+ type: :array,
+ items: %Schema{
+ type: :object,
+ properties: %{
+ account: %Schema{
+ allOf: [Account],
+ description: "The account that authored this status"
+ },
+ content: %Schema{
+ type: :string,
+ format: :html,
+ description: "HTML-encoded status content"
+ },
+ sensitive: %Schema{
+ type: :boolean,
+ description: "Is this status marked as sensitive content?"
+ },
+ spoiler_text: %Schema{
+ type: :string,
+ description:
+ "Subject or summary line, below which status content is collapsed until expanded"
+ },
+ created_at: %Schema{
+ type: :string,
+ format: "date-time",
+ description: "The date when this status was created"
+ },
+ media_attachments: %Schema{
+ type: :array,
+ items: Attachment,
+ description: "Media that is attached to this status"
+ },
+ emojis: %Schema{
+ type: :array,
+ items: Emoji,
+ description: "Custom emoji to be used when rendering status content"
+ },
+ poll: %Schema{
+ allOf: [Poll],
+ nullable: true,
+ description: "The poll attached to the status"
+ }
+ }
+ }
+ }
+ )
+ end
+
+ defp status_source_response do
+ Operation.response(
+ "Status Source",
+ "application/json",
+ %Schema{
+ type: :object,
+ properties: %{
+ id: FlakeID,
+ text: %Schema{
+ type: :string,
+ description: "Raw source of status content"
+ },
+ spoiler_text: %Schema{
+ type: :string,
+ description:
+ "Subject or summary line, below which status content is collapsed until expanded"
+ },
+ content_type: %Schema{
+ type: :string,
+ description: "The content type of the source"
+ }
+ }
+ }
+ )
+ end
+
defp context do
%Schema{
title: "StatusContext",
}
}
end
+
+ defp translation do
+ %Schema{
+ title: "StatusTranslation",
+ description: "The translation of a status.",
+ type: :object,
+ required: [:detected_language, :text],
+ properties: %{
+ detected_language: %Schema{
+ type: :string,
+ description: "The detected language of the text"
+ },
+ text: %Schema{type: :string, description: "The translated text"}
+ }
+ }
+ end
end
--- /dev/null
+defmodule Pleroma.Web.ApiSpec.TranslationOperation do
+ alias OpenApiSpex.Operation
+ alias OpenApiSpex.Schema
+
+ @spec open_api_operation(atom) :: Operation.t()
+ def open_api_operation(action) do
+ operation = String.to_existing_atom("#{action}_operation")
+ apply(__MODULE__, operation, [])
+ end
+
+ @spec languages_operation() :: Operation.t()
+ def languages_operation() do
+ %Operation{
+ tags: ["Retrieve status translation"],
+ summary: "Translate status",
+ description: "View the translation of a given status",
+ operationId: "AkkomaAPI.TranslationController.languages",
+ security: [%{"oAuth" => ["read:statuses"]}],
+ responses: %{
+ 200 =>
+ Operation.response("Translation", "application/json", source_dest_languages_schema())
+ }
+ }
+ end
+
+ defp source_dest_languages_schema do
+ %Schema{
+ type: :object,
+ required: [:source, :target],
+ properties: %{
+ source: languages_schema(),
+ target: languages_schema()
+ }
+ }
+ end
+
+ defp languages_schema do
+ %Schema{
+ type: :array,
+ items: %Schema{
+ type: :object,
+ properties: %{
+ code: %Schema{
+ type: :string
+ },
+ name: %Schema{
+ type: :string
+ }
+ }
+ }
+ }
+ end
+end
}
end
+ def show_subscribe_form_operation do
+ %Operation{
+ tags: ["Accounts"],
+ summary: "Show remote subscribe form",
+ operationId: "UtilController.show_subscribe_form",
+ parameters: [],
+ responses: %{200 => Operation.response("Web Page", "test/html", %Schema{type: :string})}
+ }
+ end
+
defp delete_account_request do
%Schema{
title: "AccountDeleteRequest",
format: "date-time",
description: "The date when this status was created"
},
+ edited_at: %Schema{
+ type: :string,
+ format: "date-time",
+ nullable: true,
+ description: "The date when this status was last edited"
+ },
emojis: %Schema{
type: :array,
items: Emoji,
{:ok, activity, _} <- Pipeline.common_pipeline(emoji_react, local: true) do
{:ok, activity}
else
- _ -> {:error, dgettext("errors", "Could not add reaction emoji")}
+ _ ->
+ {:error, dgettext("errors", "Could not add reaction emoji")}
end
end
end
end
+ def update(user, orig_activity, changes) do
+ with orig_object <- Object.normalize(orig_activity),
+ {:ok, new_object} <- make_update_data(user, orig_object, changes),
+ {:ok, update_data, _} <- Builder.update(user, new_object),
+ {:ok, update, _} <- Pipeline.common_pipeline(update_data, local: true) do
+ {:ok, update}
+ else
+ _ -> {:error, nil}
+ end
+ end
+
+ defp make_update_data(user, orig_object, changes) do
+ kept_params = %{
+ visibility: Visibility.get_visibility(orig_object),
+ in_reply_to_id:
+ with replied_id when is_binary(replied_id) <- orig_object.data["inReplyTo"],
+ %Activity{id: activity_id} <- Activity.get_create_by_object_ap_id(replied_id) do
+ activity_id
+ else
+ _ -> nil
+ end
+ }
+
+ params = Map.merge(changes, kept_params)
+
+ with {:ok, draft} <- ActivityDraft.create(user, params) do
+ change =
+ Object.Updater.make_update_object_data(orig_object.data, draft.object, Utils.make_date())
+
+ {:ok, change}
+ else
+ _ -> {:error, nil}
+ end
+ end
+
@spec pin(String.t(), User.t()) :: {:ok, Activity.t()} | {:error, term()}
def pin(id, %User{} = user) do
with %Activity{} = activity <- create_activity_by_id(id),
|> Map.put("emoji", emoji)
|> Map.put("source", %{
"content" => draft.status,
- "mediaType" => draft.params[:content_type]
+ "mediaType" => Utils.get_content_type(draft.params[:content_type])
})
|> Map.put("generator", draft.params[:generator])
def attachments_from_ids_no_descs(ids) do
Enum.map(ids, fn media_id ->
- case Repo.get(Object, media_id) do
+ case get_attachment(media_id) do
%Object{data: data} -> data
_ -> nil
end
{_, descs} = Jason.decode(descs_str)
Enum.map(ids, fn media_id ->
- with %Object{data: data} <- Repo.get(Object, media_id) do
+ with %Object{data: data} <- get_attachment(media_id) do
Map.put(data, "name", descs[media_id])
end
end)
|> Enum.reject(&is_nil/1)
end
+ defp get_attachment(media_id) do
+ Repo.get(Object, media_id)
+ end
+
@spec get_to_and_cc(ActivityDraft.t()) :: {list(String.t()), list(String.t())}
def get_to_and_cc(%{in_reply_to_conversation: %Participation{} = participation}) do
|> maybe_add_attachments(draft.attachments, attachment_links)
end
- defp get_content_type(content_type) do
+ def get_content_type(content_type) do
if Enum.member?(Config.get([:instance, :allowed_post_formats]), content_type) do
content_type
else
def format_input(text, "text/x.misskeymarkdown", options) do
text
+ |> Formatter.markdown_to_html()
+ |> MfmParser.Parser.parse()
+ |> MfmParser.Encoder.to_html()
|> Formatter.linkify(options)
- |> Formatter.html_escape("text/x.misskeymarkdown")
- |> (fn {text, mentions, tags} ->
- {String.replace(text, ~r/\r?\n/, "<br>"), mentions, tags}
- end).()
+ |> Formatter.html_escape("text/html")
end
def format_input(text, "text/markdown", options) do
def index(conn, _params) do
with %{assigns: %{user: %User{} = user, token: %Token{app_id: token_app_id} = token}} <- conn,
{:ok, %{id: ^token_app_id}} <- AuthController.local_mastofe_app() do
+ flavour =
+ [:frontends, :mastodon]
+ |> Pleroma.Config.get()
+ |> Map.get("name", "mastodon-fe")
+
+ index =
+ if flavour == "fedibird-fe" do
+ "fedibird.index.html"
+ else
+ "glitchsoc.index.html"
+ end
+
conn
|> put_layout(false)
- |> render("index.html",
+ |> render(index,
token: token.token,
user: user,
custom_emojis: Pleroma.Emoji.get_all()
def login(conn, %{"code" => auth_token} = params) do
with {:ok, app} <- local_mastofe_app(),
{:ok, auth} <- Authorization.get_by_token(app, auth_token),
- {:ok, oauth_token} <- Token.exchange_token(app, auth) do
+ %User{} = user <- User.get_cached_by_id(auth.user_id),
+ {:ok, oauth_token} <- Token.get_or_exchange_token(auth, app, user) do
redirect_to =
conn
|> local_mastodon_post_login_path()
move
pleroma:emoji_reaction
poll
+ update
}
def index(%{assigns: %{user: user}} = conn, params) do
params =
alias Pleroma.Bookmark
alias Pleroma.Object
alias Pleroma.Repo
+ alias Pleroma.Config
alias Pleroma.ScheduledActivity
alias Pleroma.User
alias Pleroma.Web.ActivityPub.ActivityPub
plug(:skip_public_check when action in [:index, :show])
@unauthenticated_access %{fallback: :proceed_unauthenticated, scopes: []}
+ @cachex Pleroma.Config.get([:cachex, :provider], Cachex)
plug(
OAuthScopesPlug,
when action in [
:index,
:show,
- :context
+ :context,
+ :translate,
+ :show_history,
+ :show_source
]
)
:create,
:delete,
:reblog,
- :unreblog
+ :unreblog,
+ :update
]
)
create(%Plug.Conn{conn | body_params: params}, %{})
end
+ @doc "GET /api/v1/statuses/:id/history"
+ def show_history(%{assigns: assigns} = conn, %{id: id} = params) do
+ with user = assigns[:user],
+ %Activity{} = activity <- Activity.get_by_id_with_object(id),
+ true <- Visibility.visible_for_user?(activity, user) do
+ try_render(conn, "history.json",
+ activity: activity,
+ for: user,
+ with_direct_conversation_id: true,
+ with_muted: Map.get(params, :with_muted, false)
+ )
+ else
+ _ -> {:error, :not_found}
+ end
+ end
+
+ @doc "GET /api/v1/statuses/:id/source"
+ def show_source(%{assigns: assigns} = conn, %{id: id} = _params) do
+ with user = assigns[:user],
+ %Activity{} = activity <- Activity.get_by_id_with_object(id),
+ true <- Visibility.visible_for_user?(activity, user) do
+ try_render(conn, "source.json",
+ activity: activity,
+ for: user
+ )
+ else
+ _ -> {:error, :not_found}
+ end
+ end
+
+ @doc "PUT /api/v1/statuses/:id"
+ def update(%{assigns: %{user: user}, body_params: body_params} = conn, %{id: id} = params) do
+ with {_, %Activity{}} = {_, activity} <- {:activity, Activity.get_by_id_with_object(id)},
+ {_, true} <- {:visible, Visibility.visible_for_user?(activity, user)},
+ {_, true} <- {:is_create, activity.data["type"] == "Create"},
+ actor <- Activity.user_actor(activity),
+ {_, true} <- {:own_status, actor.id == user.id},
+ changes <- body_params |> put_application(conn),
+ {_, {:ok, _update_activity}} <- {:pipeline, CommonAPI.update(user, activity, changes)},
+ {_, %Activity{}} = {_, activity} <- {:refetched, Activity.get_by_id_with_object(id)} do
+ try_render(conn, "show.json",
+ activity: activity,
+ for: user,
+ with_direct_conversation_id: true,
+ with_muted: Map.get(params, :with_muted, false)
+ )
+ else
+ {:own_status, _} -> {:error, :forbidden}
+ {:pipeline, _} -> {:error, :internal_server_error}
+ _ -> {:error, :not_found}
+ end
+ end
+
@doc "GET /api/v1/statuses/:id"
def show(%{assigns: %{user: user}} = conn, %{id: id} = params) do
with %Activity{} = activity <- Activity.get_by_id_with_object(id),
)
end
+ @doc "GET /api/v1/statuses/:id/translations/:language"
+ def translate(%{assigns: %{user: user}} = conn, %{id: id, language: language} = params) do
+ with {:enabled, true} <- {:enabled, Config.get([:translator, :enabled])},
+ %Activity{} = activity <- Activity.get_by_id_with_object(id),
+ {:visible, true} <- {:visible, Visibility.visible_for_user?(activity, user)},
+ translation_module <- Config.get([:translator, :module]),
+ {:ok, detected, translation} <-
+ fetch_or_translate(
+ activity.id,
+ activity.object.data["content"],
+ Map.get(params, :from, nil),
+ language,
+ translation_module
+ ) do
+ json(conn, %{detected_language: detected, text: translation})
+ else
+ {:enabled, false} ->
+ conn
+ |> put_status(:bad_request)
+ |> json(%{"error" => "Translation is not enabled"})
+
+ {:visible, false} ->
+ {:error, :not_found}
+
+ e ->
+ e
+ end
+ end
+
+ defp fetch_or_translate(status_id, text, source_language, target_language, translation_module) do
+ @cachex.fetch!(
+ :translations_cache,
+ "translations:#{status_id}:#{source_language}:#{target_language}",
+ fn _ ->
+ value = translation_module.translate(text, source_language, target_language)
+
+ with {:ok, _, _} <- value do
+ value
+ else
+ _ -> {:ignore, value}
+ end
+ 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
thumbnail:
URI.merge(Pleroma.Web.Endpoint.url(), Keyword.get(instance, :instance_thumbnail))
|> to_string,
- languages: ["en"],
+ languages: Keyword.get(instance, :languages, ["en"]),
registrations: Keyword.get(instance, :registrations_open),
approval_required: Keyword.get(instance, :account_approval_required),
# Extra (not present in Mastodon):
"shareable_emoji_packs",
"multifetch",
"pleroma:api/v1/notifications:include_types_filter",
+ "editing",
if Config.get([:media_proxy, :enabled]) do
"media_proxy"
end,
if Config.get([:instance, :profile_directory]) do
"profile_directory"
end,
- "custom_emoji_reactions"
+ if Config.get([:translator, :enabled], false) do
+ "akkoma:machine_translation"
+ end,
+ "custom_emoji_reactions",
+ "pleroma:get:main/ostatus"
]
|> Enum.filter(& &1)
end
alias Pleroma.Web.MastodonAPI.NotificationView
alias Pleroma.Web.MastodonAPI.StatusView
- @parent_types ~w{Like Announce EmojiReact}
+ defp object_id_for(%{data: %{"object" => %{"id" => id}}}) when is_binary(id), do: id
+
+ defp object_id_for(%{data: %{"object" => id}}) when is_binary(id), do: id
+
+ @parent_types ~w{Like Announce EmojiReact Update}
def render("index.json", %{notifications: notifications, for: reading_user} = opts) do
activities = Enum.map(notifications, & &1.activity)
%{data: %{"type" => type}} ->
type in @parent_types
end)
- |> Enum.map(& &1.data["object"])
+ |> Enum.map(&object_id_for/1)
|> Activity.create_by_object_ap_id()
|> Activity.with_preloaded_object(:left)
|> Pleroma.Repo.all()
parent_activity_fn = fn ->
if opts[:parent_activities] do
- Activity.Queries.find_by_object_ap_id(opts[:parent_activities], activity.data["object"])
+ Activity.Queries.find_by_object_ap_id(opts[:parent_activities], object_id_for(activity))
else
- Activity.get_create_by_object_ap_id(activity.data["object"])
+ Activity.get_create_by_object_ap_id(object_id_for(activity))
end
end
"reblog" ->
put_status(response, parent_activity_fn.(), reading_user, status_render_opts)
+ "update" ->
+ put_status(response, parent_activity_fn.(), reading_user, status_render_opts)
+
"move" ->
put_target(response, activity, reading_user, %{})
created_at = Utils.to_masto_date(object.data["published"])
+ edited_at =
+ with %{"updated" => updated} <- object.data,
+ date <- Utils.to_masto_date(updated),
+ true <- date != "" do
+ date
+ else
+ _ ->
+ nil
+ end
+
reply_to = get_reply_to(activity, opts)
reply_to_user = reply_to && CommonAPI.get_user(reply_to.data["actor"])
+ history_len =
+ 1 +
+ (Object.Updater.history_for(object.data)
+ |> Map.get("orderedItems")
+ |> length())
+
+ # See render("history.json", ...) for more details
+ # Here the implicit index of the current content is 0
+ chrono_order = history_len - 1
+
content =
object
|> render_content()
|> Activity.HTML.get_cached_scrubbed_html_for_activity(
User.html_filter_policy(opts[:for]),
activity,
- "mastoapi:content"
+ "mastoapi:content:#{chrono_order}"
)
content_plaintext =
content
|> Activity.HTML.get_cached_stripped_html_for_activity(
activity,
- "mastoapi:content"
+ "mastoapi:content:#{chrono_order}"
)
summary = object.data["summary"] || ""
reblog: nil,
card: card,
content: content_html,
- text: opts[:with_source] && object.data["source"],
+ text: opts[:with_source] && get_source_text(object.data["source"]),
created_at: created_at,
+ edited_at: edited_at,
reblogs_count: announcement_count,
replies_count: object.data["repliesCount"] || 0,
favourites_count: like_count,
emojis: build_emojis(object.data["emoji"]),
quote_id: if(quote, do: quote.id, else: nil),
quote: maybe_render_quote(quote, opts),
+ emoji_reactions: emoji_reactions,
pleroma: %{
local: activity.local,
conversation_id: get_context_id(activity),
nil
end
+ def render("history.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do
+ object = Object.normalize(activity, fetch: false)
+
+ hashtags = Object.hashtags(object)
+
+ user = CommonAPI.get_user(activity.data["actor"])
+
+ past_history =
+ Object.Updater.history_for(object.data)
+ |> Map.get("orderedItems")
+ |> Enum.map(&Map.put(&1, "id", object.data["id"]))
+ |> Enum.map(&%Object{data: &1, id: object.id})
+
+ history =
+ [object | past_history]
+ # Mastodon expects the original to be at the first
+ |> Enum.reverse()
+ |> Enum.with_index()
+ |> Enum.map(fn {object, chrono_order} ->
+ %{
+ # The history is prepended every time there is a new edit.
+ # In chrono_order, the oldest item is always at 0, and so on.
+ # The chrono_order is an invariant kept between edits.
+ chrono_order: chrono_order,
+ object: object
+ }
+ end)
+
+ individual_opts =
+ opts
+ |> Map.put(:as, :item)
+ |> Map.put(:user, user)
+ |> Map.put(:hashtags, hashtags)
+
+ render_many(history, StatusView, "history_item.json", individual_opts)
+ end
+
+ def render(
+ "history_item.json",
+ %{
+ activity: activity,
+ user: user,
+ item: %{object: object, chrono_order: chrono_order},
+ hashtags: hashtags
+ } = opts
+ ) do
+ sensitive = object.data["sensitive"] || Enum.member?(hashtags, "nsfw")
+
+ attachment_data = object.data["attachment"] || []
+ attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment)
+
+ created_at = Utils.to_masto_date(object.data["updated"] || object.data["published"])
+
+ content =
+ object
+ |> render_content()
+
+ content_html =
+ content
+ |> Activity.HTML.get_cached_scrubbed_html_for_activity(
+ User.html_filter_policy(opts[:for]),
+ activity,
+ "mastoapi:content:#{chrono_order}"
+ )
+
+ summary = object.data["summary"] || ""
+
+ %{
+ account:
+ AccountView.render("show.json", %{
+ user: user,
+ for: opts[:for]
+ }),
+ content: content_html,
+ sensitive: sensitive,
+ spoiler_text: summary,
+ created_at: created_at,
+ media_attachments: attachments,
+ emojis: build_emojis(object.data["emoji"]),
+ poll: render(PollView, "show.json", object: object, for: opts[:for])
+ }
+ end
+
+ def render("source.json", %{activity: %{data: %{"object" => _object}} = activity} = _opts) do
+ object = Object.normalize(activity, fetch: false)
+
+ %{
+ id: activity.id,
+ text: get_source_text(Map.get(object.data, "source", "")),
+ spoiler_text: Map.get(object.data, "summary", ""),
+ content_type: get_source_content_type(object.data["source"])
+ }
+ end
+
def render("card.json", %{rich_media: rich_media, page_url: page_url}) do
page_url_data = URI.parse(page_url)
true -> "unknown"
end
- <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
+ attachment_id =
+ with {_, ap_id} when is_binary(ap_id) <- {:ap_id, attachment["id"]},
+ {_, %Object{data: _object_data, id: object_id}} <-
+ {:object, Object.get_by_ap_id(ap_id)} do
+ to_string(object_id)
+ else
+ _ ->
+ <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
+ to_string(attachment["id"] || hash_id)
+ end
%{
- id: to_string(attachment["id"] || hash_id),
+ id: attachment_id,
url: href,
remote_url: href,
preview_url: href_preview,
defp build_emoji_map(emoji, users, url, current_user) do
%{
- name: emoji,
+ name: Pleroma.Web.PleromaAPI.EmojiReactionView.emoji_name(emoji, url),
count: length(users),
url: MediaProxy.url(url),
- me: !!(current_user && current_user.ap_id in users)
+ me: !!(current_user && current_user.ap_id in users),
+ account_ids: Enum.map(users, fn user -> User.get_cached_by_ap_id(user).id end)
}
end
defp maybe_render_quote(nil, _), do: nil
defp maybe_render_quote(quote, opts) do
- if opts[:do_not_recurse] || !visible_for_user?(quote, opts[:for]) do
- nil
- else
+ with %User{} = quoted_user <- User.get_cached_by_ap_id(quote.actor),
+ false <- Map.get(opts, :do_not_recurse, false),
+ true <- visible_for_user?(quote, opts[:for]),
+ false <- User.blocks?(opts[:for], quoted_user),
+ false <- User.mutes?(opts[:for], quoted_user) do
opts =
opts
|> Map.put(:activity, quote)
|> Map.put(:do_not_recurse, true)
render("show.json", opts)
+ else
+ _ -> nil
end
end
+
+ defp get_source_text(%{"content" => content} = _source) do
+ content
+ end
+
+ defp get_source_text(source) when is_binary(source) do
+ source
+ end
+
+ defp get_source_text(_) do
+ ""
+ end
+
+ defp get_source_content_type(%{"mediaType" => type} = _source) do
+ type
+ end
+
+ defp get_source_content_type(_source) do
+ Utils.get_content_type(nil)
+ end
end
req
end
- {:cowboy_websocket, req, %{user: user, topic: topic, count: 0, timer: nil},
- %{idle_timeout: @timeout}}
+ {:cowboy_websocket, req,
+ %{
+ user: user,
+ topic: topic,
+ count: 0,
+ timer: nil,
+ subscriptions: [],
+ oauth_token: oauth_token
+ }, %{idle_timeout: @timeout}}
else
{:error, :bad_topic} ->
Logger.debug("#{__MODULE__} bad topic #{inspect(req)}")
"#{__MODULE__} accepted websocket connection for user #{(state.user || %{id: "anonymous"}).id}, topic #{state.topic}"
)
- Streamer.add_socket(state.topic, state.user)
+ Streamer.add_socket(state.topic, state.oauth_token)
{:ok, %{state | timer: timer()}}
end
# We only receive pings for now
def websocket_handle(:ping, state), do: {:ok, state}
- def websocket_handle({:text, "ping"}, state) do
+ def websocket_handle({:text, ping}, state) when ping in ~w[ping PING] do
if state.timer, do: Process.cancel_timer(state.timer)
{:reply, {:text, "pong"}, %{state | timer: timer()}}
end
+ def websocket_handle({:text, text}, state) do
+ with {:ok, json} <- Jason.decode(text) do
+ websocket_handle({:json, json}, state)
+ else
+ _ ->
+ Logger.error("#{__MODULE__} received text frame: #{text}")
+ {:ok, state}
+ end
+ end
+
+ def websocket_handle(
+ {:json, %{"type" => "subscribe", "stream" => stream_name}},
+ %{user: user, oauth_token: token} = state
+ ) do
+ with {:ok, topic} <- Streamer.get_topic(stream_name, user, token, %{}) do
+ new_subscriptions =
+ [topic | Map.get(state, :subscriptions, [])]
+ |> Enum.uniq()
+
+ {:ok, _topic} = Streamer.add_socket(topic, user)
+
+ {:ok, Map.put(state, :subscriptions, new_subscriptions)}
+ else
+ _ ->
+ Logger.error("#{__MODULE__} received invalid topic: #{stream_name}")
+ {:ok, state}
+ end
+ end
+
def websocket_handle(frame, state) do
Logger.error("#{__MODULE__} received frame: #{inspect(frame)}")
{:ok, state}
end
- def websocket_info({:render_with_user, view, template, item}, state) do
+ def websocket_info({:render_with_user, view, template, item, topic}, state) do
user = %User{} = User.get_cached_by_ap_id(state.user.ap_id)
unless Streamer.filtered_by_user?(user, item) do
- websocket_info({:text, view.render(template, item, user)}, %{state | user: user})
+ websocket_info({:text, view.render(template, item, user, topic)}, %{state | user: user})
else
{:ok, state}
end
{:reply, :ping, %{state | timer: nil, count: 0}, :hibernate}
end
+ def websocket_info(:close, state) do
+ {:stop, state}
+ end
+
# State can be `[]` only in case we terminate before switching to websocket,
# we already log errors for these cases in `init/1`, so just do nothing here
def terminate(_reason, _req, []), do: :ok
from(t in __MODULE__, where: t.app_id == ^app_id and t.token == ^token)
|> Repo.find_resource()
end
+
+ def get_preeexisting_by_app_and_user(%App{id: app_id} = _app, %User{id: user_id} = _user) do
+ from(t in __MODULE__, where: t.app_id == ^app_id and t.user_id == ^user_id, limit: 1)
+ |> Repo.find_resource()
+ end
end
# after user already authorized to MastodonFE.
# So we have to check client and token.
def authorize(
- %Plug.Conn{assigns: %{token: %Token{} = token}} = conn,
+ %Plug.Conn{assigns: %{token: %Token{} = token, user: %User{} = user}} = conn,
%{"client_id" => client_id} = params
) do
with %Token{} = t <- Repo.get_by(Token, token: token.token) |> Repo.preload(:app),
^client_id <- t.app.client_id do
handle_existing_authorization(conn, params)
+ else
+ _ ->
+ maybe_reuse_token(conn, params, user.id)
+ end
+ end
+
+ def authorize(%Plug.Conn{} = conn, params) do
+ # if we have a user in the session, attempt to authenticate as them
+ # otherwise show the login form
+ maybe_reuse_token(conn, params, AuthHelper.get_session_user(conn))
+ end
+
+ defp maybe_reuse_token(conn, params, user_id) when is_binary(user_id) do
+ with %User{} = user <- User.get_cached_by_id(user_id),
+ %App{} = app <- Repo.get_by(App, client_id: params["client_id"]),
+ {:ok, %Token{} = token} <- Token.get_preeexisting_by_app_and_user(app, user),
+ {:ok, %Authorization{} = auth} <-
+ Authorization.get_preeexisting_by_app_and_user(app, user) do
+ conn
+ |> assign(:token, token)
+ |> after_create_authorization(auth, %{"authorization" => params})
else
_ -> do_authorize(conn, params)
end
end
- def authorize(%Plug.Conn{} = conn, params), do: do_authorize(conn, params)
+ defp maybe_reuse_token(conn, params, _user), do: do_authorize(conn, params)
defp do_authorize(%Plug.Conn{} = conn, params) do
app = Repo.get_by(App, client_id: params["client_id"])
def create_authorization(%Plug.Conn{} = conn, %{"authorization" => _} = params, opts) do
with {:ok, auth, user} <- do_create_authorization(conn, params, opts[:user]),
{:mfa_required, _, _, false} <- {:mfa_required, user, auth, MFA.require?(user)} do
- after_create_authorization(conn, auth, params)
+ conn
+ |> AuthHelper.put_session_user(user.id)
+ |> after_create_authorization(auth, params)
else
error ->
handle_create_authorization_error(conn, error, params)
fixed_token = Token.Utils.fix_padding(params["code"]),
{:ok, auth} <- Authorization.get_by_token(app, fixed_token),
%User{} = user <- User.get_cached_by_id(auth.user_id),
- {:ok, token} <- Token.exchange_token(app, auth) do
+ {:ok, token} <- Token.get_or_exchange_token(auth, app, user) do
after_token_exchange(conn, %{user: user, token: token})
else
error ->
def after_token_exchange(%Plug.Conn{} = conn, %{token: token} = view_params) do
conn
|> AuthHelper.put_session_token(token.token)
+ |> AuthHelper.put_session_user(token.user_id)
|> json(OAuthView.render("token.json", view_params))
end
end
end
+ def get_preeexisting_by_app_and_user(app, user) do
+ Query.get_by_app(app.id)
+ |> Query.get_by_user(user.id)
+ |> Query.get_unexpired()
+ |> Query.preload([:user])
+ |> Query.sort_by_inserted_at()
+ |> Query.limit(1)
+ |> Repo.find_resource()
+ end
+
defp put_token(changeset) do
changeset
|> change(%{token: Token.Utils.generate_token()})
|> unique_constraint(:refresh_token)
end
+ def get_or_exchange_token(%Authorization{} = auth, %App{} = app, %User{} = user) do
+ if auth.used do
+ get_preeexisting_by_app_and_user(app, user)
+ else
+ exchange_token(app, auth)
+ end
+ end
+
defp put_valid_until(changeset, attrs) do
valid_until =
Map.get(attrs, :valid_until, NaiveDateTime.add(NaiveDateTime.utc_now(), lifespan()))
from(q in query, where: q.user_id == ^user_id)
end
+ def get_unexpired(query) do
+ now = NaiveDateTime.utc_now()
+ from(q in query, where: q.valid_until > ^now)
+ end
+
+ def limit(query, limit) do
+ from(q in query, limit: ^limit)
+ end
+
+ def sort_by_inserted_at(query) do
+ from(q in query, order_by: [desc: :updated_at])
+ end
+
@spec preload(query, any) :: query
def preload(query \\ Token, assoc_preload \\ [])
@doc "Revokes access token"
@spec revoke(Token.t()) :: {:ok, Token.t()} | {:error, Ecto.Changeset.t()}
def revoke(%Token{} = token) do
- Repo.delete(token)
+ with {:ok, token} <- Repo.delete(token) do
+ Task.Supervisor.start_child(
+ Pleroma.TaskSupervisor,
+ Pleroma.Web.Streamer,
+ :close_streams_by_oauth_token,
+ [token],
+ restart: :transient
+ )
+
+ {:ok, token}
+ else
+ result -> result
+ end
end
end
defp filter(reactions, _), do: reactions
def create(%{assigns: %{user: user}} = conn, %{id: activity_id, emoji: emoji}) do
+ emoji =
+ emoji
+ |> Pleroma.Emoji.fully_qualify_emoji()
+ |> Pleroma.Emoji.maybe_quote()
+
with {:ok, _activity} <- CommonAPI.react_with_emoji(activity_id, user, emoji) do
activity = Activity.get_by_id(activity_id)
end
def delete(%{assigns: %{user: user}} = conn, %{id: activity_id, emoji: emoji}) do
+ emoji =
+ emoji
+ |> Pleroma.Emoji.fully_qualify_emoji()
+ |> Pleroma.Emoji.maybe_quote()
+
with {:ok, _activity} <- CommonAPI.unreact_with_emoji(activity_id, user, emoji) do
activity = Activity.get_by_id(activity_id)
alias Pleroma.Web.MastodonAPI.AccountView
alias Pleroma.Web.MediaProxy
+ def emoji_name(emoji, nil), do: emoji
+
+ def emoji_name(emoji, url) do
+ url = URI.parse(url)
+
+ if url.host == Pleroma.Web.Endpoint.host() do
+ emoji
+ else
+ "#{emoji}@#{url.host}"
+ end
+ end
+
def render("index.json", %{emoji_reactions: emoji_reactions} = opts) do
render_many(emoji_reactions, __MODULE__, "show.json", opts)
end
users = fetch_users(user_ap_ids)
%{
- name: emoji,
+ name: emoji_name(emoji, url),
count: length(users),
accounts: render(AccountView, "index.json", users: users, for: user),
url: MediaProxy.url(url),
end
end
- def route_aliases(%{path_info: ["objects", id]}) do
+ def route_aliases(%{path_info: ["objects", id], query_string: query_string}) do
ap_id = Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :object, id)
with %Activity{} = activity <- Activity.get_by_object_ap_id_with_object(ap_id) do
- ["/notice/#{activity.id}"]
+ ["/notice/#{activity.id}", "/notice/#{activity.id}?#{query_string}"]
else
_ -> []
end
if has_signature_header?(conn) do
# set (request-target) header to the appropriate value
# we also replace the digest header with the one we computed
- possible_paths = route_aliases(conn) ++ [conn.request_path]
+ possible_paths =
+ route_aliases(conn) ++ [conn.request_path, conn.request_path <> "?#{conn.query_string}"]
+
assign_valid_signature_on_route_aliases(conn, possible_paths)
else
Logger.debug("No signature header!")
pipe_through(:pleroma_html)
post("/main/ostatus", UtilController, :remote_subscribe)
+ get("/main/ostatus", UtilController, :show_subscribe_form)
get("/ostatus_subscribe", RemoteFollowController, :follow)
post("/ostatus_subscribe", RemoteFollowController, :do_follow)
end
get("/federation_status", InstancesController, :show)
end
+ scope "/api/v1", Pleroma.Web.PleromaAPI do
+ pipe_through(:authenticated_api)
+ put("/statuses/:id/emoji_reactions/:emoji", EmojiReactionController, :create)
+ end
+
+ scope "/api/v1/akkoma", Pleroma.Web.AkkomaAPI do
+ pipe_through(:authenticated_api)
+ get("/translation/languages", TranslationController, :languages)
+ end
+
scope "/api/v1", Pleroma.Web.MastodonAPI do
pipe_through(:authenticated_api)
get("/bookmarks", StatusController, :bookmarks)
post("/statuses", StatusController, :create)
+ put("/statuses/:id", StatusController, :update)
delete("/statuses/:id", StatusController, :delete)
post("/statuses/:id/reblog", StatusController, :reblog)
post("/statuses/:id/unreblog", StatusController, :unreblog)
post("/statuses/:id/unbookmark", StatusController, :unbookmark)
post("/statuses/:id/mute", StatusController, :mute_conversation)
post("/statuses/:id/unmute", StatusController, :unmute_conversation)
+ get("/statuses/:id/translations/:language", StatusController, :translate)
post("/push/subscription", SubscriptionController, :create)
get("/push/subscription", SubscriptionController, :show)
get("/statuses/:id/context", StatusController, :context)
get("/statuses/:id/favourited_by", StatusController, :favourited_by)
get("/statuses/:id/reblogged_by", StatusController, :reblogged_by)
+ get("/statuses/:id/history", StatusController, :show_history)
+ get("/statuses/:id/source", StatusController, :show_source)
get("/custom_emojis", CustomEmojiController, :index)
{:ok, topic :: String.t()} | {:error, :bad_topic} | {:error, :unauthorized}
def get_topic_and_add_socket(stream, user, oauth_token, params \\ %{}) do
with {:ok, topic} <- get_topic(stream, user, oauth_token, params) do
- add_socket(topic, user)
+ add_socket(topic, oauth_token)
end
end
{:error, :unauthorized}
end
+ # mastodon multi-topic WS
+ def get_topic(nil, _user, _oauth_token, _params) do
+ {:ok, :multi}
+ end
+
def get_topic(_stream, _user, _oauth_token, _params) do
{:error, :bad_topic}
end
@doc "Registers the process for streaming. Use `get_topic/3` to get the full authorized topic."
- def add_socket(topic, user) do
+ def add_socket(topic, oauth_token) do
if should_env_send?() do
- auth? = if user, do: true
- Registry.register(@registry, topic, auth?)
+ oauth_token_id = if oauth_token, do: oauth_token.id, else: false
+ Registry.register(@registry, topic, oauth_token_id)
end
{:ok, topic}
end
defp do_stream("follow_relationship", item) do
- text = StreamerView.render("follow_relationships_update.json", item)
user_topic = "user:#{item.follower.id}"
+ text = StreamerView.render("follow_relationships_update.json", item, user_topic)
Logger.debug("Trying to push follow relationship update to #{user_topic}\n\n")
when topic in ["user", "user:notification"] do
Registry.dispatch(@registry, "#{topic}:#{item.user_id}", fn list ->
Enum.each(list, fn {pid, _auth} ->
- send(pid, {:render_with_user, StreamerView, "notification.json", item})
+ send(pid, {:render_with_user, StreamerView, "notification.json", item, topic})
end)
end)
end
end
defp push_to_socket(topic, %Participation{} = participation) do
- rendered = StreamerView.render("conversation.json", participation)
+ rendered = StreamerView.render("conversation.json", participation, topic)
Registry.dispatch(@registry, topic, fn list ->
Enum.each(list, fn {pid, _} ->
defp push_to_socket(_topic, %Activity{data: %{"type" => "Delete"}}), do: :noop
+ defp push_to_socket(topic, %Activity{data: %{"type" => "Update"}} = item) do
+ create_activity =
+ Pleroma.Activity.get_create_by_object_ap_id(item.object.data["id"])
+ |> Map.put(:object, item.object)
+
+ anon_render = StreamerView.render("status_update.json", create_activity, topic)
+
+ Registry.dispatch(@registry, topic, fn list ->
+ Enum.each(list, fn {pid, auth?} ->
+ if auth? do
+ send(
+ pid,
+ {:render_with_user, StreamerView, "status_update.json", create_activity, topic}
+ )
+ else
+ send(pid, {:text, anon_render})
+ end
+ end)
+ end)
+ end
+
defp push_to_socket(topic, item) do
- anon_render = StreamerView.render("update.json", item)
+ anon_render = StreamerView.render("update.json", item, topic)
Registry.dispatch(@registry, topic, fn list ->
Enum.each(list, fn {pid, auth?} ->
if auth? do
- send(pid, {:render_with_user, StreamerView, "update.json", item})
+ send(pid, {:render_with_user, StreamerView, "update.json", item, topic})
else
send(pid, {:text, anon_render})
end
end
end
+ def close_streams_by_oauth_token(oauth_token) do
+ if should_env_send?() do
+ Registry.select(
+ @registry,
+ [
+ {
+ {:"$1", :"$2", :"$3"},
+ [{:==, :"$3", oauth_token.id}],
+ [:"$2"]
+ }
+ ]
+ )
+ |> Enum.each(fn pid -> send(pid, :close) end)
+ end
+ end
+
# In test environement, only return true if the registry is started.
# In benchmark environment, returns false.
# In any other environment, always returns true.
--- /dev/null
+<!DOCTYPE html>
+<html lang='en'>
+<head>
+<meta charset='utf-8'>
+<meta content='width=device-width, initial-scale=1' name='viewport'>
+<title>
+<%= Config.get([:instance, :name]) %>
+</title>
+<link rel="icon" type="image/png" href="/favicon.png"/>
+<link rel="manifest" type="applicaton/manifest+json" href="<%= Routes.masto_fe_path(Pleroma.Web.Endpoint, :manifest) %>" />
+
+<meta name="theme-color" content="<%= Config.get([:manifest, :theme_color]) %>" />
+
+<script id='initial-state' type='application/json'><%= initial_state(@token, @user, @custom_emojis) %></script>
+
+<script crossorigin='anonymous' src="/packs/js/common.js"></script>
+<script crossorigin='anonymous' src="/packs/js/locale_en.js"></script>
+
+<link rel='preload' as='script' crossorigin='anonymous' href='/packs/js/features/getting_started.js'>
+<link rel='preload' as='script' crossorigin='anonymous' href='/packs/js/features/compose.js'>
+<link rel='preload' as='script' crossorigin='anonymous' href='/packs/js/features/home_timeline.js'>
+<link rel='preload' as='script' crossorigin='anonymous' href='/packs/js/features/notifications.js'>
+<script crossorigin='anonymous' src="/packs/js/application.js"></script>
+
+
+<link rel="stylesheet" media="all" href="/packs/css/common.css" />
+<link rel="stylesheet" media="all" href="/packs/css/default.css" />
+
+</head>
+<body class='app-body no-reduce-motion system-font'>
+ <div class='app-holder' data-props='{"locale":"en"}' id='mastodon'>
+ </div>
+</body>
+</html>
--- /dev/null
+<%= if @error do %>
+ <h2><%= Gettext.dpgettext("static_pages", "status interact error", "Error: %{error}", error: @error) %></h2>
+<% else %>
+ <h2><%= raw Gettext.dpgettext("static_pages", "status interact header", "Interacting with %{nickname}'s %{status_link}", nickname: safe_to_string(html_escape(@nickname)), status_link: safe_to_string(link(Gettext.dpgettext("static_pages", "status interact header - status link text", "status"), to: @status_link))) %></h2>
+ <%= form_for @conn, Routes.util_path(@conn, :remote_subscribe), [as: "status"], fn f -> %>
+ <%= hidden_input f, :status_id, value: @status_id %>
+ <%= text_input f, :profile, placeholder: Gettext.dpgettext("static_pages", "placeholder text for account id", "Your account ID, e.g. lain@quitter.se") %>
+ <%= submit Gettext.dpgettext("static_pages", "status interact authorization button", "Interact") %>
+ <% end %>
+<% end %>
require Logger
+ alias Pleroma.Activity
alias Pleroma.Config
alias Pleroma.Emoji
alias Pleroma.Healthcheck
alias Pleroma.Web.Plugs.OAuthScopesPlug
alias Pleroma.Web.WebFinger
- plug(Pleroma.Web.ApiSpec.CastAndValidate when action != :remote_subscribe)
- plug(Pleroma.Web.Plugs.FederatingPlug when action == :remote_subscribe)
+ plug(
+ Pleroma.Web.ApiSpec.CastAndValidate
+ when action != :remote_subscribe and action != :show_subscribe_form
+ )
+
+ plug(
+ Pleroma.Web.Plugs.FederatingPlug
+ when action == :remote_subscribe
+ when action == :show_subscribe_form
+ )
plug(
OAuthScopesPlug,
defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.TwitterUtilOperation
- def remote_subscribe(conn, %{"nickname" => nick, "profile" => _}) do
+ def show_subscribe_form(conn, %{"nickname" => nick}) do
with %User{} = user <- User.get_cached_by_nickname(nick),
avatar = User.avatar_url(user) do
conn
render(conn, "subscribe.html", %{
nickname: nick,
avatar: nil,
- error: "Could not find user"
+ error:
+ Pleroma.Web.Gettext.dpgettext(
+ "static_pages",
+ "remote follow error message - user not found",
+ "Could not find user"
+ )
})
end
end
+ def show_subscribe_form(conn, %{"status_id" => id}) do
+ with %Activity{} = activity <- Activity.get_by_id(id),
+ {:ok, ap_id} <- get_ap_id(activity),
+ %User{} = user <- User.get_cached_by_ap_id(activity.actor),
+ avatar = User.avatar_url(user) do
+ conn
+ |> render("status_interact.html", %{
+ status_link: ap_id,
+ status_id: id,
+ nickname: user.nickname,
+ avatar: avatar,
+ error: false
+ })
+ else
+ _e ->
+ render(conn, "status_interact.html", %{
+ status_id: id,
+ avatar: nil,
+ error:
+ Pleroma.Web.Gettext.dpgettext(
+ "static_pages",
+ "status interact error message - status not found",
+ "Could not find status"
+ )
+ })
+ end
+ end
+
+ def remote_subscribe(conn, %{"nickname" => nick, "profile" => _}) do
+ show_subscribe_form(conn, %{"nickname" => nick})
+ end
+
+ def remote_subscribe(conn, %{"status_id" => id, "profile" => _}) do
+ show_subscribe_form(conn, %{"status_id" => id})
+ end
+
def remote_subscribe(conn, %{"user" => %{"nickname" => nick, "profile" => profile}}) do
with {:ok, %{"subscribe_address" => template}} <- WebFinger.finger(profile),
%User{ap_id: ap_id} <- User.get_cached_by_nickname(nick) do
render(conn, "subscribe.html", %{
nickname: nick,
avatar: nil,
- error: "Something went wrong."
+ error:
+ Pleroma.Web.Gettext.dpgettext(
+ "static_pages",
+ "remote follow error message - unknown error",
+ "Something went wrong."
+ )
+ })
+ end
+ end
+
+ def remote_subscribe(conn, %{"status" => %{"status_id" => id, "profile" => profile}}) do
+ with {:ok, %{"subscribe_address" => template}} <- WebFinger.finger(profile),
+ %Activity{} = activity <- Activity.get_by_id(id),
+ {:ok, ap_id} <- get_ap_id(activity) do
+ conn
+ |> Phoenix.Controller.redirect(external: String.replace(template, "{uri}", ap_id))
+ else
+ _e ->
+ render(conn, "status_interact.html", %{
+ status_id: id,
+ avatar: nil,
+ error:
+ Pleroma.Web.Gettext.dpgettext(
+ "static_pages",
+ "status interact error message - unknown error",
+ "Something went wrong."
+ )
})
end
end
end
end
+ defp get_ap_id(activity) do
+ object = Pleroma.Object.normalize(activity, fetch: false)
+
+ case object do
+ %{data: %{"id" => ap_id}} -> {:ok, ap_id}
+ _ -> {:no_ap_id, nil}
+ end
+ end
+
def frontend_configurations(conn, _params) do
render(conn, "frontend_configurations.json")
end
defmodule Pleroma.Web.TwitterAPI.UtilView do
use Pleroma.Web, :view
+ import Phoenix.HTML
import Phoenix.HTML.Form
+ import Phoenix.HTML.Link
alias Pleroma.Config
alias Pleroma.Web.Endpoint
alias Pleroma.Web.Gettext
%{
meta: %{
+ title: Config.get([:instance, :name]),
streaming_api_base_url: Pleroma.Web.Endpoint.websocket_url(),
access_token: token,
locale: "en",
display_sensitive_media: false,
reduce_motion: false,
max_toot_chars: limit,
- mascot: User.get_mascot(user)["url"]
+ mascot: User.get_mascot(user)["url"],
+ show_quote_button: true,
+ enable_reaction: true,
+ compact_reaction: false,
+ advanced_layout: true
},
poll_limits: Config.get([:instance, :poll_limits]),
rights: %{
"video\/mp4"
]
},
+ lists: [],
settings: user.mastofe_settings || %{},
push_subscription: nil,
accounts: %{user.id => render(AccountView, "show.json", user: user, for: user)},
alias Pleroma.User
alias Pleroma.Web.MastodonAPI.NotificationView
- def render("update.json", %Activity{} = activity, %User{} = user) do
+ def render("update.json", %Activity{} = activity, %User{} = user, topic) do
%{
+ stream: [topic],
event: "update",
payload:
Pleroma.Web.MastodonAPI.StatusView.render(
|> Jason.encode!()
end
- def render("notification.json", %Notification{} = notify, %User{} = user) do
+ def render("status_update.json", %Activity{} = activity, %User{} = user, topic) do
+ activity = Activity.get_create_by_object_ap_id_with_object(activity.object.data["id"])
+
+ %{
+ stream: [topic],
+ event: "status.update",
+ payload:
+ Pleroma.Web.MastodonAPI.StatusView.render(
+ "show.json",
+ activity: activity,
+ for: user
+ )
+ |> Jason.encode!()
+ }
+ |> Jason.encode!()
+ end
+
+ def render("notification.json", %Notification{} = notify, %User{} = user, topic) do
%{
+ stream: [topic],
event: "notification",
payload:
NotificationView.render(
|> Jason.encode!()
end
- def render("update.json", %Activity{} = activity) do
+ def render("update.json", %Activity{} = activity, topic) do
%{
+ stream: [topic],
event: "update",
payload:
Pleroma.Web.MastodonAPI.StatusView.render(
|> Jason.encode!()
end
- def render("follow_relationships_update.json", item) do
+ def render("status_update.json", %Activity{} = activity, topic) do
+ activity = Activity.get_create_by_object_ap_id_with_object(activity.object.data["id"])
+
+ %{
+ stream: [topic],
+ event: "status.update",
+ payload:
+ Pleroma.Web.MastodonAPI.StatusView.render(
+ "show.json",
+ activity: activity
+ )
+ |> Jason.encode!()
+ }
+ |> Jason.encode!()
+ end
+
+ def render("follow_relationships_update.json", item, topic) do
%{
+ stream: [topic],
event: "pleroma:follow_relationships_update",
payload:
%{
|> Jason.encode!()
end
- def render("conversation.json", %Participation{} = participation) do
+ def render("conversation.json", %Participation{} = participation, topic) do
%{
+ stream: [topic],
event: "conversation",
payload:
Pleroma.Web.MastodonAPI.ConversationView.render("participation.json", %{
def project do
[
app: :pleroma,
- version: version("3.1.0"),
- elixir: "~> 1.9",
+ version: version("3.2.0"),
+ elixir: "~> 1.12",
elixirc_paths: elixirc_paths(Mix.env()),
compilers: [:phoenix, :gettext] ++ Mix.compilers(),
elixirc_options: [warnings_as_errors: warnings_as_errors()],
override: true},
{:bcrypt_elixir, "~> 2.2"},
{:trailing_format_plug, "~> 0.0.7"},
- {:fast_sanitize, "~> 0.2.0"},
+ {:fast_sanitize, "~> 0.2.3"},
{:html_entities, "~> 0.5", override: true},
{:phoenix_html, "~> 3.1", override: true},
{:calendar, "~> 1.0"},
{:ecto_psql_extras, "~> 0.6"},
{:elasticsearch,
git: "https://akkoma.dev/AkkomaGang/elasticsearch-elixir.git", ref: "main"},
+ {:mfm_parser,
+ git: "https://akkoma.dev/AkkomaGang/mfm-parser.git",
+ ref: "912fba81152d4d572e457fd5427f9875b2bc3dbe"},
# indirect dependency version override
{:plug, "~> 1.10.4", override: true},
# temporary downgrade for excoveralls, hackney until hackney max_connections bug will be fixed
{:excoveralls, "0.12.3", only: :test},
{:mox, "~> 1.0", only: :test},
- {:websocket_client, git: "https://github.com/jeremyong/websocket_client.git", only: :test}
+ {:websockex, "~> 0.4.3", only: :test}
] ++ oauth_deps()
end
"makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"},
"meck": {:hex, :meck, "0.9.2", "85ccbab053f1db86c7ca240e9fc718170ee5bda03810a6292b5306bf31bae5f5", [:rebar3], [], "hexpm", "81344f561357dc40a8344afa53767c32669153355b626ea9fcbc8da6b3045826"},
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
+ "mfm_parser": {:git, "https://akkoma.dev/AkkomaGang/mfm-parser.git", "912fba81152d4d572e457fd5427f9875b2bc3dbe", [ref: "912fba81152d4d572e457fd5427f9875b2bc3dbe"]},
"mime": {:hex, :mime, "1.6.0", "dabde576a497cef4bbdd60aceee8160e02a6c89250d6c0b29e56c0dfb00db3d2", [:mix], [], "hexpm", "31a1a8613f8321143dde1dafc36006a17d28d02bdfecb9e95a880fa7aabd19a7"},
"mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"},
"mint": {:hex, :mint, "1.4.2", "50330223429a6e1260b2ca5415f69b0ab086141bc76dc2fbf34d7c389a6675b2", [:mix], [{:castore, "~> 0.1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "ce75a5bbcc59b4d7d8d70f8b2fc284b1751ffb35c7b6a6302b5192f8ab4ddd80"},
"table_rex": {:hex, :table_rex, "3.1.1", "0c67164d1714b5e806d5067c1e96ff098ba7ae79413cc075973e17c38a587caa", [:mix], [], "hexpm", "678a23aba4d670419c23c17790f9dcd635a4a89022040df7d5d772cb21012490"},
"telemetry": {:hex, :telemetry, "0.4.3", "a06428a514bdbc63293cd9a6263aad00ddeb66f608163bdec7c8995784080818", [:rebar3], [], "hexpm", "eb72b8365ffda5bed68a620d1da88525e326cb82a75ee61354fc24b844768041"},
"telemetry_metrics": {:hex, :telemetry_metrics, "0.6.1", "315d9163a1d4660aedc3fee73f33f1d355dcc76c5c3ab3d59e76e3edf80eef1f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7be9e0871c41732c233be71e4be11b96e56177bf15dde64a8ac9ce72ac9834c6"},
+ "temple": {:git, "https://akkoma.dev/AkkomaGang/temple.git", "066a699ade472d8fa42a9d730b29a61af9bc8b59", [ref: "066a699ade472d8fa42a9d730b29a61af9bc8b59"]},
"tesla": {:hex, :tesla, "1.4.4", "bb89aa0c9745190930366f6a2ac612cdf2d0e4d7fff449861baa7875afd797b2", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.3", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, "~> 1.3", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.0", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "d5503a49f9dec1b287567ea8712d085947e247cb11b06bc54adb05bfde466457"},
"timex": {:hex, :timex, "3.7.8", "0e6e8bf7c0aba95f1e13204889b2446e7a5297b1c8e408f15ab58b2c8dc85f81", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "8f3b8edc5faab5205d69e5255a1d64a83b190bab7f16baa78aefcb897cf81435"},
"trailing_format_plug": {:hex, :trailing_format_plug, "0.0.7", "64b877f912cf7273bed03379936df39894149e35137ac9509117e59866e10e45", [:mix], [{:plug, "> 0.12.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "bd4fde4c15f3e993a999e019d64347489b91b7a9096af68b2bdadd192afa693f"},
"unsafe": {:hex, :unsafe, "1.0.1", "a27e1874f72ee49312e0a9ec2e0b27924214a05e3ddac90e91727bc76f8613d8", [:mix], [], "hexpm", "6c7729a2d214806450d29766abc2afaa7a2cbecf415be64f36a6691afebb50e5"},
"vex": {:hex, :vex, "0.9.0", "613ea5eb3055662e7178b83e25b2df0975f68c3d8bb67c1645f0573e1a78d606", [:mix], [], "hexpm", "c69fff44d5c8aa3f1faee71bba1dcab05dd36364c5a629df8bb11751240c857f"},
"web_push_encryption": {:hex, :web_push_encryption, "0.3.1", "76d0e7375142dfee67391e7690e89f92578889cbcf2879377900b5620ee4708d", [:mix], [{:httpoison, "~> 1.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jose, "~> 1.11.1", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "4f82b2e57622fb9337559058e8797cb0df7e7c9790793bdc4e40bc895f70e2a2"},
- "websocket_client": {:git, "https://github.com/jeremyong/websocket_client.git", "9a6f65d05ebf2725d62fb19262b21f1805a59fbf", []},
+ "websockex": {:hex, :websockex, "0.4.3", "92b7905769c79c6480c02daacaca2ddd49de936d912976a4d3c923723b647bf0", [:mix], [], "hexpm", "95f2e7072b85a3a4cc385602d42115b73ce0b74a9121d0d6dbbf557645ac53e4"},
}
--- /dev/null
+defmodule Pleroma.Repo.Migrations.AddUpdateToNotificationsEnum do
+ use Ecto.Migration
+
+ @disable_ddl_transaction true
+
+ def up do
+ """
+ alter type notification_type add value 'update'
+ """
+ |> execute()
+ end
+
+ # 20210717000000_add_poll_to_notifications_enum.exs
+ def down do
+ alter table(:notifications) do
+ modify(:type, :string)
+ end
+
+ """
+ delete from notifications where type = 'update'
+ """
+ |> execute()
+
+ """
+ drop type if exists notification_type
+ """
+ |> execute()
+
+ """
+ create type notification_type as enum (
+ 'follow',
+ 'follow_request',
+ 'mention',
+ 'move',
+ 'pleroma:emoji_reaction',
+ 'pleroma:chat_mention',
+ 'reblog',
+ 'favourite',
+ 'pleroma:report',
+ 'poll'
+ )
+ """
+ |> execute()
+
+ """
+ alter table notifications
+ alter column type type notification_type using (type::notification_type)
+ """
+ |> execute()
+ end
+end
--- /dev/null
+defmodule Pleroma.Repo.Migrations.RemoveLocalCancelledFollows do
+ use Ecto.Migration
+
+ def up do
+ statement = """
+ DELETE FROM
+ activities
+ WHERE
+ (data->>'type') = 'Follow'
+ AND
+ (data->>'state') = 'cancelled'
+ AND
+ local = true;
+ """
+
+ execute(statement)
+ end
+
+ def down do
+ :ok
+ end
+end
Meta.allow_tag_with_these_attributes(:u, [])
Meta.allow_tag_with_these_attributes(:ul, [])
- Meta.allow_tag_with_this_attribute_values(:span, "class", ["h-card", "quote-inline"])
- Meta.allow_tag_with_these_attributes(:span, [])
+ Meta.allow_tags_with_style_attributes([:span])
+
+ Meta.allow_tag_with_this_attribute_values(:span, "class", [
+ "h-card",
+ "quote-inline",
+ "mfm",
+ "mfm _mfm_tada_",
+ "mfm _mfm_jelly_",
+ "mfm _mfm_twitch_",
+ "mfm _mfm_shake_",
+ "mfm _mfm_spin_",
+ "mfm _mfm_jump_",
+ "mfm _mfm_bounce_",
+ "mfm _mfm_flip_",
+ "mfm _mfm_x2_",
+ "mfm _mfm_x3_",
+ "mfm _mfm_x4_",
+ "mfm _mfm_blur_",
+ "mfm _mfm_rainbow_",
+ "mfm _mfm_rotate_"
+ ])
+
+ Meta.allow_tag_with_these_attributes(:span, [
+ "data-x",
+ "data-y",
+ "data-h",
+ "data-v",
+ "data-left",
+ "data-right"
+ ])
Meta.allow_tag_with_this_attribute_values(:code, "class", ["inline"])
Meta.allow_tag_with_these_attributes(:small, [])
Meta.strip_everything_not_covered()
+
+ defp scrub_css(value), do: value
end
<body>
<h3>Welcome to Akkoma!</h3>
<p>If you're seeing this page, your server works!</p>
- <p>In order to get a frontend to show here, you'll need to set up <code>:pleroma, :frontends, primary</code> and install your frontend of choice</p>
- <a href="https://docs.akkoma.dev/stable/configuration/cheatsheet/#frontend-management">Documentation</a>
+ <p>In order to get a frontend to show here, you'll need to set up <code>:pleroma, :frontends, primary</code> and install your frontend of choice, in most cases this will just be:</p>
+ <pre>
+ <code lang="bash">
+ # OTP
+ ./bin/pleroma_ctl frontend install pleroma-fe --ref stable
+ # Source
+ mix pleroma.frontend install pleroma-fe --ref stable
+
+ ## you can do the same thing for admin-fe if you so wish
+ </code>
+ </pre>
+ <p><a href="https://docs.akkoma.dev/stable/administration/CLI_tasks/frontend/">Installation Command Documentation</a></p>
+ <p><a href="https://docs.akkoma.dev/stable/configuration/cheatsheet/#frontend-management">Config Documentation</a></p>
</body>
</html>
"alsoKnownAs": {
"@id": "as:alsoKnownAs",
"@type": "@id"
- }
+ },
+ "vcard": "http://www.w3.org/2006/vcard/ns#",
+ "formerRepresentations": "litepub:formerRepresentations"
}
]
}
[Unit]
-Description=Pleroma social network
+Description=Akkoma social network
After=network.target postgresql.service nginx.service
[Service]
KillMode=process
Restart=on-failure
-; Name of the user that runs the Pleroma service.
-User=pleroma
+; Name of the user that runs the Akkoma service.
+User=akkoma
; Make sure that all paths fit your installation.
-; Path to the home directory of the user running the Pleroma service.
-Environment="HOME=/opt/pleroma"
-; Path to the folder containing the Pleroma installation.
-WorkingDirectory=/opt/pleroma
-; Path to the Pleroma binary.
-ExecStart=/opt/pleroma/bin/pleroma start
-ExecStop=/opt/pleroma/bin/pleroma stop
+; Path to the home directory of the user running the Akkoma service.
+Environment="HOME=/opt/akkoma"
+; Path to the folder containing the Akkoma installation.
+WorkingDirectory=/opt/akkoma
+; Path to the Mix binary.
+ExecStart=/opt/akkoma/bin/pleroma start
+ExecStop=/opt/akkoma/bin/pleroma stop
; Some security directives.
; Use private /tmp and /var/tmp folders inside a new file system namespace, which are discarded after the process stops.
PrivateTmp=true
-; The /home, /root, and /run/user folders can not be accessed by this service anymore. If your Pleroma user has its home folder in one of the restricted places, or use one of these folders as its working directory, you have to set this to false.
+; The /home, /root, and /run/user folders can not be accessed by this service anymore. If your Akkoma user has its home folder in one of the restricted places, or use one of these folders as its working directory, you have to set this to false.
ProtectHome=true
; Mount /usr, /boot, and /etc as read-only for processes invoked by this service.
ProtectSystem=full
supervisor=supervise-daemon
# Requires OpenRC >= 0.35
-directory=/opt/pleroma
+directory=/opt/akkoma
-command=/opt/pleroma/bin/pleroma
+command=/opt/akkoma/bin/pleroma
command_args="start"
-command_user=pleroma
+command_user=akkoma
command_background=1
# Ask process to terminate within 30 seconds, otherwise kill it
retry="SIGTERM/30/SIGKILL/5"
-pidfile="/var/run/pleroma.pid"
+pidfile="/var/run/akkoma.pid"
depend() {
want nginx
"type": "Note",
"attributedTo": "https://misskey.local.live/users/92hzkskwgy",
"summary": null,
- "content": "this gets replaced",
+ "content": "this does not get replaced",
"source": {
"content": "@akkoma_user @remote_user @full_tag_remote_user@misskey.local.live @oops_not_a_mention linkifylink #dancedance $[jelly mfm goes here] \n\n## aaa",
"mediaType": "text/x.misskeymarkdown"
Mix.Tasks.Pleroma.Relay.run(["unfollow", target_instance])
cancelled_activity = Activity.get_by_ap_id(follow_activity.data["id"])
- assert cancelled_activity.data["state"] == "cancelled"
+ assert is_nil(cancelled_activity)
[undo_activity] =
ActivityPub.fetch_activities([], %{
assert undo_activity.data["type"] == "Undo"
assert undo_activity.data["actor"] == local_user.ap_id
- assert undo_activity.data["object"]["id"] == cancelled_activity.data["id"]
refute "#{target_instance}/followers" in User.following(local_user)
end
Mix.Tasks.Pleroma.Relay.run(["unfollow", target_instance, "--force"])
cancelled_activity = Activity.get_by_ap_id(follow_activity.data["id"])
- assert cancelled_activity.data["state"] == "cancelled"
+ assert is_nil(cancelled_activity)
[undo_activity] =
ActivityPub.fetch_activities(
assert undo_activity.data["type"] == "Undo"
assert undo_activity.data["actor"] == local_user.ap_id
- assert undo_activity.data["object"]["id"] == cancelled_activity.data["id"]
refute "#{target_instance}/followers" in User.following(local_user)
end
end
}
end)
- {:ok, objects} = Fetcher.fetch_collection_by_ap_id(ap_id)
+ {:ok, objects} = Fetcher.fetch_collection(ap_id)
assert [%{"type" => "Create"}, %{"type" => "Like"}] = objects
end
}
end)
- {:ok, objects} = Fetcher.fetch_collection_by_ap_id(ap_id)
+ {:ok, objects} = Fetcher.fetch_collection(ap_id)
assert [%{"type" => "Create"}, %{"type" => "Like"}] = objects
end
}
end)
- {:ok, objects} = Fetcher.fetch_collection_by_ap_id(ap_id)
+ {:ok, objects} = Fetcher.fetch_collection(ap_id)
assert [%{"type" => "Create"}, %{"type" => "Like"}] = objects
end
}
end)
- {:ok, objects} = Fetcher.fetch_collection_by_ap_id(ap_id)
+ {:ok, objects} = Fetcher.fetch_collection(ap_id)
+ assert [%{"type" => "Create"}] = objects
+ end
+
+ test "it should stop fetching when we hit a 404" do
+ clear_config([:activitypub, :max_collection_objects], 1)
+
+ unordered_collection =
+ "test/fixtures/collections/unordered_page_reference.json"
+ |> File.read!()
+
+ first_page =
+ "test/fixtures/collections/unordered_page_first.json"
+ |> File.read!()
+
+ ap_id = "https://example.com/collection/unordered_page_reference"
+ first_page_id = "https://example.com/collection/unordered_page_reference?page=1"
+ second_page_id = "https://example.com/collection/unordered_page_reference?page=2"
+
+ Tesla.Mock.mock(fn
+ %{
+ method: :get,
+ url: ^ap_id
+ } ->
+ %Tesla.Env{
+ status: 200,
+ body: unordered_collection,
+ headers: [{"content-type", "application/activity+json"}]
+ }
+
+ %{
+ method: :get,
+ url: ^first_page_id
+ } ->
+ %Tesla.Env{
+ status: 200,
+ body: first_page,
+ headers: [{"content-type", "application/activity+json"}]
+ }
+
+ %{
+ method: :get,
+ url: ^second_page_id
+ } ->
+ %Tesla.Env{
+ status: 404,
+ body: nil,
+ headers: [{"content-type", "application/activity+json"}]
+ }
+ end)
+
+ {:ok, objects} = Fetcher.fetch_collection(ap_id)
assert [%{"type" => "Create"}] = objects
end
end
alias Pleroma.Config.TransferTask
- setup do: clear_config(:configurable_from_database, true)
+ setup do
+ clear_config(:configurable_from_database, true)
+ end
test "transfer config values from db to env" do
refute Application.get_env(:pleroma, :test_key)
refute Application.get_env(:idna, :test_key)
refute Application.get_env(:quack, :test_key)
refute Application.get_env(:postgrex, :test_key)
+
initial = Application.get_env(:logger, :level)
insert(:config, key: :test_key, value: [live: 2, com: 3])
insert(:config, group: :quack, key: :test_key, value: [:test_value1, :test_value2])
insert(:config, group: :postgrex, key: :test_key, value: :value)
insert(:config, group: :logger, key: :level, value: :debug)
-
+ insert(:config, group: :pleroma, key: :instance, value: [static_dir: "static_dir_from_db"])
TransferTask.start_link([])
assert Application.get_env(:pleroma, :test_key) == [live: 2, com: 3]
assert Application.get_env(:quack, :test_key) == [:test_value1, :test_value2]
assert Application.get_env(:logger, :level) == :debug
assert Application.get_env(:postgrex, :test_key) == :value
+ assert Application.get_env(:pleroma, :instance)[:static_dir] == "static_dir_from_db"
on_exit(fn ->
Application.delete_env(:pleroma, :test_key)
Application.delete_env(:quack, :test_key)
Application.delete_env(:postgrex, :test_key)
Application.put_env(:logger, :level, initial)
+ System.delete_env("RELEASE_NAME")
+ end)
+ end
+
+ test "transfer task falls back to env before default" do
+ instance = Application.get_env(:pleroma, :instance)
+
+ insert(:config, key: :instance, value: [name: "wow"])
+ clear_config([:instance, :static_dir], "static_dir_from_env")
+ TransferTask.start_link([])
+
+ assert Application.get_env(:pleroma, :instance)[:name] == "wow"
+ assert Application.get_env(:pleroma, :instance)[:static_dir] == "static_dir_from_env"
+
+ on_exit(fn ->
+ Application.put_env(:pleroma, :instance, instance)
+ end)
+ end
+
+ test "transfer task falls back to release defaults if no other values found" do
+ instance = Application.get_env(:pleroma, :instance)
+
+ System.put_env("RELEASE_NAME", "akkoma")
+ Pleroma.Config.Holder.save_default()
+ insert(:config, key: :instance, value: [name: "wow"])
+ Application.delete_env(:pleroma, :instance)
+
+ TransferTask.start_link([])
+
+ assert Application.get_env(:pleroma, :instance)[:name] == "wow"
+ assert Application.get_env(:pleroma, :instance)[:static_dir] == "/var/lib/akkoma/static"
+
+ on_exit(fn ->
+ System.delete_env("RELEASE_NAME")
+ Pleroma.Config.Holder.save_default()
+ Application.put_env(:pleroma, :instance, instance)
end)
end
WebsocketClient.start_link(self(), path, headers)
end
- test "refuses invalid requests" do
+ test "allows multi-streams" do
capture_log(fn ->
- assert {:error, {404, _}} = start_socket()
- assert {:error, {404, _}} = start_socket("?stream=ncjdk")
+ assert {:ok, _} = start_socket()
+
+ assert {:error, %WebSockex.RequestError{code: 404, message: "Not Found"}} =
+ start_socket("?stream=ncjdk")
+
Process.sleep(30)
end)
end
test "requires authentication and a valid token for protected streams" do
capture_log(fn ->
- assert {:error, {401, _}} = start_socket("?stream=user&access_token=aaaaaaaaaaaa")
- assert {:error, {401, _}} = start_socket("?stream=user")
+ assert {:error, %WebSockex.RequestError{code: 401}} =
+ start_socket("?stream=user&access_token=aaaaaaaaaaaa")
+
+ assert {:error, %WebSockex.RequestError{code: 401}} = start_socket("?stream=user")
Process.sleep(30)
end)
end
{:ok, token} = OAuth.Token.exchange_token(app, auth)
- %{user: user, token: token}
+ %{app: app, user: user, token: token}
end
test "accepts valid tokens", state do
assert {:ok, _} = start_socket("?stream=user&access_token=#{token.token}")
capture_log(fn ->
- assert {:error, {401, _}} = start_socket("?stream=user")
+ assert {:error, %WebSockex.RequestError{code: 401}} = start_socket("?stream=user")
Process.sleep(30)
end)
end
assert {:ok, _} = start_socket("?stream=user:notification&access_token=#{token.token}")
capture_log(fn ->
- assert {:error, {401, _}} = start_socket("?stream=user:notification")
+ assert {:error, %WebSockex.RequestError{code: 401}} =
+ start_socket("?stream=user:notification")
+
Process.sleep(30)
end)
end
assert {:ok, _} = start_socket("?stream=user", [{"Sec-WebSocket-Protocol", token.token}])
capture_log(fn ->
- assert {:error, {401, _}} =
+ assert {:error, %WebSockex.RequestError{code: 401}} =
start_socket("?stream=user", [{"Sec-WebSocket-Protocol", "I am a friend"}])
Process.sleep(30)
end)
end
+
+ test "disconnect when token is revoked", %{app: app, user: user, token: token} do
+ assert {:ok, _} = start_socket("?stream=user:notification&access_token=#{token.token}")
+ assert {:ok, _} = start_socket("?stream=user&access_token=#{token.token}")
+
+ {:ok, auth} = OAuth.Authorization.create_authorization(app, user)
+
+ {:ok, token2} = OAuth.Token.exchange_token(app, auth)
+ assert {:ok, _} = start_socket("?stream=user&access_token=#{token2.token}")
+
+ OAuth.Token.Strategy.Revoke.revoke(token)
+
+ assert_receive {:close, _}
+ assert_receive {:close, _}
+ refute_receive {:close, _}
+ end
end
end
subscriber_notifications = Notification.for_user(subscriber)
assert Enum.empty?(subscriber_notifications)
end
+
+ test "it sends edited notifications to those who repeated a status" do
+ user = insert(:user)
+ repeated_user = insert(:user)
+ other_user = insert(:user)
+
+ {:ok, activity_one} =
+ CommonAPI.post(user, %{
+ status: "hey @#{other_user.nickname}!"
+ })
+
+ {:ok, _activity_two} = CommonAPI.repeat(activity_one.id, repeated_user)
+
+ {:ok, _edit_activity} =
+ CommonAPI.update(user, activity_one, %{
+ status: "hey @#{other_user.nickname}! mew mew"
+ })
+
+ assert [%{type: "reblog"}] = Notification.for_user(user)
+ assert [%{type: "update"}] = Notification.for_user(repeated_user)
+ assert [%{type: "mention"}] = Notification.for_user(other_user)
+ end
end
test "create_poll_notifications/1" do
task =
Task.async(fn ->
{:ok, _topic} = Streamer.get_topic_and_add_socket("user", user, oauth_token)
- assert_receive {:render_with_user, _, _, _}, 4_000
+ assert_receive {:render_with_user, _, _, _, "user"}, 4_000
end)
task_user_notification =
{:ok, _topic} =
Streamer.get_topic_and_add_socket("user:notification", user, oauth_token)
- assert_receive {:render_with_user, _, _, _}, 4_000
+ assert_receive {:render_with_user, _, _, _, "user:notification"}, 4_000
end)
activity = insert(:note_activity)
{:ok, _, _, _activity} = CommonAPI.follow(user, followed_user)
assert FollowingRelationship.following?(user, followed_user)
- assert [notification] = Notification.for_user(followed_user)
+ assert [_notification] = Notification.for_user(followed_user)
CommonAPI.unfollow(user, followed_user)
{:ok, _, _, _activity_dupe} = CommonAPI.follow(user, followed_user)
- notification_id = notification.id
- assert [%{id: ^notification_id}] = Notification.for_user(followed_user)
+ assert Enum.count(Notification.for_user(followed_user)) == 1
end
test "dismisses the notification on follow request rejection" do
assert [other_user] == enabled_receivers
assert [] == disabled_receivers
end
+
+ test "it sends edited notifications to those who repeated a status" do
+ user = insert(:user)
+ repeated_user = insert(:user)
+ other_user = insert(:user)
+
+ {:ok, activity_one} =
+ CommonAPI.post(user, %{
+ status: "hey @#{other_user.nickname}!"
+ })
+
+ {:ok, _activity_two} = CommonAPI.repeat(activity_one.id, repeated_user)
+
+ {:ok, edit_activity} =
+ CommonAPI.update(user, activity_one, %{
+ status: "hey @#{other_user.nickname}! mew mew"
+ })
+
+ {enabled_receivers, _disabled_receivers} =
+ Notification.get_notified_from_activity(edit_activity)
+
+ assert repeated_user in enabled_receivers
+ assert other_user not in enabled_receivers
+ end
end
describe "notification lifecycle" do
refute called(Pleroma.Signature.sign(:_, :_))
end
end
+
+ describe "refetching" do
+ setup do
+ object1 = %{
+ "id" => "https://mastodon.social/1",
+ "actor" => "https://mastodon.social/users/emelie",
+ "attributedTo" => "https://mastodon.social/users/emelie",
+ "type" => "Note",
+ "content" => "test 1",
+ "bcc" => [],
+ "bto" => [],
+ "cc" => [],
+ "to" => [],
+ "summary" => ""
+ }
+
+ object2 = %{
+ "id" => "https://mastodon.social/2",
+ "actor" => "https://mastodon.social/users/emelie",
+ "attributedTo" => "https://mastodon.social/users/emelie",
+ "type" => "Note",
+ "content" => "test 2",
+ "bcc" => [],
+ "bto" => [],
+ "cc" => [],
+ "to" => [],
+ "summary" => "",
+ "formerRepresentations" => %{
+ "type" => "OrderedCollection",
+ "orderedItems" => [
+ %{
+ "type" => "Note",
+ "content" => "orig 2",
+ "actor" => "https://mastodon.social/users/emelie",
+ "attributedTo" => "https://mastodon.social/users/emelie",
+ "bcc" => [],
+ "bto" => [],
+ "cc" => [],
+ "to" => [],
+ "summary" => ""
+ }
+ ],
+ "totalItems" => 1
+ }
+ }
+
+ mock(fn
+ %{
+ method: :get,
+ url: "https://mastodon.social/1"
+ } ->
+ %Tesla.Env{
+ status: 200,
+ headers: [{"content-type", "application/activity+json"}],
+ body: Jason.encode!(object1)
+ }
+
+ %{
+ method: :get,
+ url: "https://mastodon.social/2"
+ } ->
+ %Tesla.Env{
+ status: 200,
+ headers: [{"content-type", "application/activity+json"}],
+ body: Jason.encode!(object2)
+ }
+
+ %{
+ method: :get,
+ url: "https://mastodon.social/users/emelie/collections/featured"
+ } ->
+ %Tesla.Env{
+ status: 200,
+ headers: [{"content-type", "application/activity+json"}],
+ body:
+ Jason.encode!(%{
+ "id" => "https://mastodon.social/users/emelie/collections/featured",
+ "type" => "OrderedCollection",
+ "actor" => "https://mastodon.social/users/emelie",
+ "attributedTo" => "https://mastodon.social/users/emelie",
+ "orderedItems" => [],
+ "totalItems" => 0
+ })
+ }
+
+ env ->
+ apply(HttpRequestMock, :request, [env])
+ end)
+
+ %{object1: object1, object2: object2}
+ end
+
+ test "it keeps formerRepresentations if remote does not have this attr", %{object1: object1} do
+ full_object1 =
+ object1
+ |> Map.merge(%{
+ "formerRepresentations" => %{
+ "type" => "OrderedCollection",
+ "orderedItems" => [
+ %{
+ "type" => "Note",
+ "content" => "orig 2",
+ "actor" => "https://mastodon.social/users/emelie",
+ "attributedTo" => "https://mastodon.social/users/emelie",
+ "bcc" => [],
+ "bto" => [],
+ "cc" => [],
+ "to" => [],
+ "summary" => ""
+ }
+ ],
+ "totalItems" => 1
+ }
+ })
+
+ {:ok, o} = Object.create(full_object1)
+
+ assert {:ok, refetched} = Fetcher.refetch_object(o)
+
+ assert %{"formerRepresentations" => %{"orderedItems" => [%{"content" => "orig 2"}]}} =
+ refetched.data
+ end
+
+ test "it uses formerRepresentations from remote if possible", %{object2: object2} do
+ {:ok, o} = Object.create(object2)
+
+ assert {:ok, refetched} = Fetcher.refetch_object(o)
+
+ assert %{"formerRepresentations" => %{"orderedItems" => [%{"content" => "orig 2"}]}} =
+ refetched.data
+ end
+
+ test "it replaces formerRepresentations with the one from remote", %{object2: object2} do
+ full_object2 =
+ object2
+ |> Map.merge(%{
+ "content" => "mew mew #def",
+ "formerRepresentations" => %{
+ "type" => "OrderedCollection",
+ "orderedItems" => [
+ %{"type" => "Note", "content" => "mew mew 2"}
+ ],
+ "totalItems" => 1
+ }
+ })
+
+ {:ok, o} = Object.create(full_object2)
+
+ assert {:ok, refetched} = Fetcher.refetch_object(o)
+
+ assert %{
+ "content" => "test 2",
+ "formerRepresentations" => %{"orderedItems" => [%{"content" => "orig 2"}]}
+ } = refetched.data
+ end
+
+ test "it adds to formerRepresentations if the remote does not have one and the object has changed",
+ %{object1: object1} do
+ full_object1 =
+ object1
+ |> Map.merge(%{
+ "content" => "mew mew #def",
+ "formerRepresentations" => %{
+ "type" => "OrderedCollection",
+ "orderedItems" => [
+ %{"type" => "Note", "content" => "mew mew 1"}
+ ],
+ "totalItems" => 1
+ }
+ })
+
+ {:ok, o} = Object.create(full_object1)
+
+ assert {:ok, refetched} = Fetcher.refetch_object(o)
+
+ assert %{
+ "content" => "test 1",
+ "formerRepresentations" => %{
+ "orderedItems" => [
+ %{"content" => "mew mew #def"},
+ %{"content" => "mew mew 1"}
+ ],
+ "totalItems" => 2
+ }
+ } = refetched.data
+ end
+ end
+
+ describe "fetch with history" do
+ setup do
+ object2 = %{
+ "id" => "https://mastodon.social/2",
+ "actor" => "https://mastodon.social/users/emelie",
+ "attributedTo" => "https://mastodon.social/users/emelie",
+ "type" => "Note",
+ "content" => "test 2",
+ "bcc" => [],
+ "bto" => [],
+ "cc" => ["https://mastodon.social/users/emelie/followers"],
+ "to" => [],
+ "summary" => "",
+ "formerRepresentations" => %{
+ "type" => "OrderedCollection",
+ "orderedItems" => [
+ %{
+ "type" => "Note",
+ "content" => "orig 2",
+ "actor" => "https://mastodon.social/users/emelie",
+ "attributedTo" => "https://mastodon.social/users/emelie",
+ "bcc" => [],
+ "bto" => [],
+ "cc" => ["https://mastodon.social/users/emelie/followers"],
+ "to" => [],
+ "summary" => ""
+ }
+ ],
+ "totalItems" => 1
+ }
+ }
+
+ mock(fn
+ %{
+ method: :get,
+ url: "https://mastodon.social/2"
+ } ->
+ %Tesla.Env{
+ status: 200,
+ headers: [{"content-type", "application/activity+json"}],
+ body: Jason.encode!(object2)
+ }
+
+ %{
+ method: :get,
+ url: "https://mastodon.social/users/emelie/collections/featured"
+ } ->
+ %Tesla.Env{
+ status: 200,
+ headers: [{"content-type", "application/activity+json"}],
+ body:
+ Jason.encode!(%{
+ "id" => "https://mastodon.social/users/emelie/collections/featured",
+ "type" => "OrderedCollection",
+ "actor" => "https://mastodon.social/users/emelie",
+ "attributedTo" => "https://mastodon.social/users/emelie",
+ "orderedItems" => [],
+ "totalItems" => 0
+ })
+ }
+
+ env ->
+ apply(HttpRequestMock, :request, [env])
+ end)
+
+ %{object2: object2}
+ end
+
+ test "it gets history", %{object2: object2} do
+ {:ok, object} = Fetcher.fetch_object_from_id(object2["id"])
+
+ assert %{
+ "formerRepresentations" => %{
+ "type" => "OrderedCollection",
+ "orderedItems" => [%{}]
+ }
+ } = object.data
+ end
+ end
end
--- /dev/null
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Object.UpdaterTest do
+ use Pleroma.DataCase
+ use Oban.Testing, repo: Pleroma.Repo
+
+ import Pleroma.Factory
+
+ alias Pleroma.Object.Updater
+
+ describe "make_update_object_data/3" do
+ setup do
+ note = insert(:note)
+ %{original_data: note.data}
+ end
+
+ test "it makes an updated field", %{original_data: original_data} do
+ new_data = Map.put(original_data, "content", "new content")
+
+ date = Pleroma.Web.ActivityPub.Utils.make_date()
+ update_object_data = Updater.make_update_object_data(original_data, new_data, date)
+ assert %{"updated" => ^date} = update_object_data
+ end
+
+ test "it creates formerRepresentations", %{original_data: original_data} do
+ new_data = Map.put(original_data, "content", "new content")
+
+ date = Pleroma.Web.ActivityPub.Utils.make_date()
+ update_object_data = Updater.make_update_object_data(original_data, new_data, date)
+
+ history_item = original_data |> Map.drop(["id", "formerRepresentations"])
+
+ assert %{
+ "formerRepresentations" => %{
+ "totalItems" => 1,
+ "orderedItems" => [^history_item]
+ }
+ } = update_object_data
+ end
+ end
+
+ describe "make_new_object_data_from_update_object/2" do
+ test "it reuses formerRepresentations if it exists" do
+ %{data: original_data} = insert(:note)
+
+ new_data =
+ original_data
+ |> Map.put("content", "edited")
+
+ date = Pleroma.Web.ActivityPub.Utils.make_date()
+ update_object_data = Updater.make_update_object_data(original_data, new_data, date)
+
+ history = update_object_data["formerRepresentations"]["orderedItems"]
+
+ update_object_data =
+ update_object_data
+ |> put_in(
+ ["formerRepresentations", "orderedItems"],
+ history ++ [Map.put(original_data, "summary", "additional summary")]
+ )
+ |> put_in(["formerRepresentations", "totalItems"], length(history) + 1)
+
+ %{
+ updated_data: updated_data,
+ updated: updated,
+ used_history_in_new_object?: used_history_in_new_object?
+ } = Updater.make_new_object_data_from_update_object(original_data, update_object_data)
+
+ assert updated
+ assert used_history_in_new_object?
+ assert updated_data["formerRepresentations"] == update_object_data["formerRepresentations"]
+ end
+ end
+end
--- /dev/null
+defmodule Pleroma.Akkoma.Translators.DeepLTest do
+ use Pleroma.DataCase, async: true
+
+ alias Pleroma.Akkoma.Translators.DeepL
+
+ describe "translating with deepl" do
+ setup do
+ clear_config([:deepl, :api_key], "deepl_api_key")
+ end
+
+ test "should list supported languages" do
+ clear_config([:deepl, :tier], :free)
+
+ Tesla.Mock.mock(fn
+ %{method: :get, url: "https://api-free.deepl.com/v2/languages?type=target"} = env ->
+ auth_header = Enum.find(env.headers, fn {k, _v} -> k == "authorization" end)
+ assert {"authorization", "DeepL-Auth-Key deepl_api_key"} = auth_header
+
+ %Tesla.Env{
+ status: 200,
+ body:
+ Jason.encode!([
+ %{
+ "language" => "BG",
+ "name" => "Bulgarian",
+ "supports_formality" => false
+ },
+ %{
+ "language" => "CS",
+ "name" => "Czech",
+ "supports_formality" => false
+ }
+ ])
+ }
+
+ %{method: :get, url: "https://api-free.deepl.com/v2/languages?type=source"} ->
+ %Tesla.Env{
+ status: 200,
+ body:
+ Jason.encode!([
+ %{
+ "language" => "JA",
+ "name" => "Japanese",
+ "supports_formality" => false
+ }
+ ])
+ }
+ end)
+
+ assert {:ok, [%{code: "JA", name: "Japanese"}],
+ [%{code: "BG", name: "Bulgarian"}, %{code: "CS", name: "Czech"}]} =
+ DeepL.languages()
+ end
+
+ test "should work with the free tier" do
+ clear_config([:deepl, :tier], :free)
+
+ Tesla.Mock.mock(fn
+ %{method: :post, url: "https://api-free.deepl.com/v2/translate"} = env ->
+ auth_header = Enum.find(env.headers, fn {k, _v} -> k == "authorization" end)
+ assert {"authorization", "DeepL-Auth-Key deepl_api_key"} = auth_header
+
+ %Tesla.Env{
+ status: 200,
+ body:
+ Jason.encode!(%{
+ translations: [
+ %{
+ "text" => "I will crush you",
+ "detected_source_language" => "ja"
+ }
+ ]
+ })
+ }
+ end)
+
+ assert {:ok, "ja", "I will crush you"} = DeepL.translate("ギュギュ握りつぶしちゃうぞ", nil, "en")
+ end
+
+ test "should work with the pro tier" do
+ clear_config([:deepl, :tier], :pro)
+
+ Tesla.Mock.mock(fn
+ %{method: :post, url: "https://api.deepl.com/v2/translate"} = env ->
+ auth_header = Enum.find(env.headers, fn {k, _v} -> k == "authorization" end)
+ assert {"authorization", "DeepL-Auth-Key deepl_api_key"} = auth_header
+
+ %Tesla.Env{
+ status: 200,
+ body:
+ Jason.encode!(%{
+ translations: [
+ %{
+ "text" => "I will crush you",
+ "detected_source_language" => "ja"
+ }
+ ]
+ })
+ }
+ end)
+
+ assert {:ok, "ja", "I will crush you"} = DeepL.translate("ギュギュ握りつぶしちゃうぞ", nil, "en")
+ end
+
+ test "should assign source language if set" do
+ clear_config([:deepl, :tier], :pro)
+
+ Tesla.Mock.mock(fn
+ %{method: :post, url: "https://api.deepl.com/v2/translate"} = env ->
+ auth_header = Enum.find(env.headers, fn {k, _v} -> k == "authorization" end)
+ assert {"authorization", "DeepL-Auth-Key deepl_api_key"} = auth_header
+ assert String.contains?(env.body, "source_lang=ja")
+
+ %Tesla.Env{
+ status: 200,
+ body:
+ Jason.encode!(%{
+ translations: [
+ %{
+ "text" => "I will crush you",
+ "detected_source_language" => "ja"
+ }
+ ]
+ })
+ }
+ end)
+
+ assert {:ok, "ja", "I will crush you"} = DeepL.translate("ギュギュ握りつぶしちゃうぞ", "ja", "en")
+ end
+
+ test "should gracefully fail if the API errors" do
+ clear_config([:deepl, :tier], :free)
+
+ Tesla.Mock.mock(fn
+ %{method: :post, url: "https://api-free.deepl.com/v2/translate"} ->
+ %Tesla.Env{
+ status: 403,
+ body: ""
+ }
+ end)
+
+ assert {:error, "DeepL request failed (code 403)"} =
+ DeepL.translate("ギュギュ握りつぶしちゃうぞ", nil, "en")
+ end
+ end
+end
--- /dev/null
+defmodule Pleroma.Akkoma.Translators.LibreTranslateTest do
+ use Pleroma.DataCase, async: true
+
+ alias Pleroma.Akkoma.Translators.LibreTranslate
+
+ describe "translating with libre translate" do
+ setup do
+ clear_config([:libre_translate, :url], "http://libre.translate/translate")
+ end
+
+ test "should list supported languages" do
+ clear_config([:deepl, :tier], :free)
+
+ Tesla.Mock.mock(fn
+ %{method: :get, url: "http://libre.translate/languages"} = _ ->
+ %Tesla.Env{
+ status: 200,
+ body:
+ Jason.encode!([
+ %{
+ "code" => "en",
+ "name" => "English"
+ },
+ %{
+ "code" => "ar",
+ "name" => "Arabic"
+ }
+ ])
+ }
+ end)
+
+ assert {:ok, [%{code: "en", name: "English"}, %{code: "ar", name: "Arabic"}],
+ [%{code: "en", name: "English"}, %{code: "ar", name: "Arabic"}]} =
+ LibreTranslate.languages()
+ end
+
+ test "should work without an API key" do
+ Tesla.Mock.mock(fn
+ %{method: :post, url: "http://libre.translate/translate"} = env ->
+ assert {:ok, %{"api_key" => nil, "source" => "auto"}} = Jason.decode(env.body)
+
+ %Tesla.Env{
+ status: 200,
+ body:
+ Jason.encode!(%{
+ detectedLanguage: %{
+ confidence: 83,
+ language: "ja"
+ },
+ translatedText: "I will crush you"
+ })
+ }
+ end)
+
+ assert {:ok, "ja", "I will crush you"} =
+ LibreTranslate.translate("ギュギュ握りつぶしちゃうぞ", nil, "en")
+ end
+
+ test "should work with an API key" do
+ clear_config([:libre_translate, :api_key], "libre_translate_api_key")
+
+ Tesla.Mock.mock(fn
+ %{method: :post, url: "http://libre.translate/translate"} = env ->
+ assert {:ok, %{"api_key" => "libre_translate_api_key"}} = Jason.decode(env.body)
+
+ %Tesla.Env{
+ status: 200,
+ body:
+ Jason.encode!(%{
+ detectedLanguage: %{
+ confidence: 83,
+ language: "ja"
+ },
+ translatedText: "I will crush you"
+ })
+ }
+ end)
+
+ assert {:ok, "ja", "I will crush you"} =
+ LibreTranslate.translate("ギュギュ握りつぶしちゃうぞ", nil, "en")
+ end
+
+ test "should gracefully handle API key errors" do
+ clear_config([:libre_translate, :api_key], "")
+
+ Tesla.Mock.mock(fn
+ %{method: :post, url: "http://libre.translate/translate"} ->
+ %Tesla.Env{
+ status: 403,
+ body:
+ Jason.encode!(%{
+ error: "Please contact the server operator to obtain an API key"
+ })
+ }
+ end)
+
+ assert {:error, "libre_translate: request failed (code 403)"} =
+ LibreTranslate.translate("ギュギュ握りつぶしちゃうぞ", nil, "en")
+ end
+
+ test "should set a source language if requested" do
+ Tesla.Mock.mock(fn
+ %{method: :post, url: "http://libre.translate/translate"} = env ->
+ assert {:ok, %{"api_key" => nil, "source" => "ja"}} = Jason.decode(env.body)
+
+ %Tesla.Env{
+ status: 200,
+ body:
+ Jason.encode!(%{
+ translatedText: "I will crush you"
+ })
+ }
+ end)
+
+ assert {:ok, "ja", "I will crush you"} =
+ LibreTranslate.translate("ギュギュ握りつぶしちゃうぞ", "ja", "en")
+ end
+
+ test "should gracefully handle an unsupported language" do
+ clear_config([:libre_translate, :api_key], "")
+
+ Tesla.Mock.mock(fn
+ %{method: :post, url: "http://libre.translate/translate"} ->
+ %Tesla.Env{
+ status: 400,
+ body:
+ Jason.encode!(%{
+ error: "zoop is not supported"
+ })
+ }
+ end)
+
+ assert {:error, "libre_translate: request failed (code 400)"} =
+ LibreTranslate.translate("ギュギュ握りつぶしちゃうぞ", nil, "zoop")
+ end
+ end
+end
test "it returns file" do
File.cp!("test/fixtures/image.jpg", "test/fixtures/image_tmp.jpg")
- assert Upload.store(@upload_file) ==
- {:ok,
- %{
- "name" => "image.jpg",
- "type" => "Document",
- "mediaType" => "image/jpeg",
- "url" => [
- %{
- "href" => "http://localhost:4001/media/post-process-file.jpg",
- "mediaType" => "image/jpeg",
- "type" => "Link"
- }
- ]
- }}
+ assert {:ok, result} = Upload.store(@upload_file)
+
+ assert result ==
+ %{
+ "id" => result["id"],
+ "name" => "image.jpg",
+ "type" => "Document",
+ "mediaType" => "image/jpeg",
+ "url" => [
+ %{
+ "href" => "http://localhost:4001/media/post-process-file.jpg",
+ "mediaType" => "image/jpeg",
+ "type" => "Link"
+ }
+ ]
+ }
Task.await(Agent.get(TestUploaderSuccess, fn task_pid -> task_pid end))
end
|> String.replace("{{status_id}}", status_id)
status_url = "https://example.com/users/lain/statuses/#{status_id}"
+ replies_url = status_url <> "/replies?only_other_accounts=true&page=true"
user =
File.read!("test/fixtures/users_mock/user.json")
|> String.replace("{{nickname}}", "lain"),
headers: [{"content-type", "application/activity+json"}]
}
+
+ %{
+ method: :get,
+ url: ^replies_url
+ } ->
+ %Tesla.Env{
+ status: 404,
+ body: "",
+ headers: [{"content-type", "application/activity+json"}]
+ }
end)
data = %{
assert embedded_object["id"] == follow_activity.data["id"]
end
+ test "it removes the follow activity if it was local" do
+ follower = insert(:user, local: true)
+ followed = insert(:user)
+
+ {:ok, _, _, follow_activity} = CommonAPI.follow(follower, followed)
+ {:ok, activity} = ActivityPub.unfollow(follower, followed, nil, true)
+
+ assert activity.data["type"] == "Undo"
+ assert activity.data["actor"] == follower.ap_id
+
+ follow_activity = Activity.get_by_id(follow_activity.id)
+ assert is_nil(follow_activity)
+ assert is_nil(Utils.fetch_latest_follow(follower, followed))
+
+ # We need to keep our own undo
+ undo_activity = Activity.get_by_ap_id(activity.data["id"])
+ refute is_nil(undo_activity)
+ end
+
test "it removes the follow activity if it was remote" do
follower = insert(:user, local: false)
followed = insert(:user)
assert activity.data["type"] == "Undo"
assert activity.data["actor"] == follower.ap_id
- activity = Activity.get_by_id(follow_activity.id)
- assert is_nil(activity)
+ follow_activity = Activity.get_by_id(follow_activity.id)
+ assert is_nil(follow_activity)
assert is_nil(Utils.fetch_latest_follow(follower, followed))
+
+ undo_activity = Activity.get_by_ap_id(activity.data["id"])
+ assert is_nil(undo_activity)
end
end
assert {:ok, ^expected, []} = Builder.note(draft)
end
end
+
+ describe "emoji_react/1" do
+ test "unicode emoji" do
+ user = insert(:user)
+ note = insert(:note)
+
+ assert {:ok, %{"content" => "👍", "type" => "EmojiReact"}, []} =
+ Builder.emoji_react(user, note, "👍")
+ end
+
+ test "custom emoji" do
+ user = insert(:user)
+ note = insert(:note)
+
+ assert {:ok,
+ %{
+ "content" => ":dinosaur:",
+ "type" => "EmojiReact",
+ "tag" => [
+ %{
+ "name" => ":dinosaur:",
+ "id" => "http://localhost:4001/emoji/dino walking.gif",
+ "icon" => %{
+ "type" => "Image",
+ "url" => "http://localhost:4001/emoji/dino walking.gif"
+ }
+ }
+ ]
+ }, []} = Builder.emoji_react(user, note, ":dinosaur:")
+ end
+
+ test "remote custom emoji" do
+ user = insert(:user)
+ other_user = insert(:user, local: false)
+
+ note =
+ insert(:note,
+ data: %{"reactions" => [["wow", [other_user.ap_id], "https://remote/emoji/wow"]]}
+ )
+
+ assert {:ok,
+ %{
+ "content" => ":wow:",
+ "type" => "EmojiReact",
+ "tag" => [
+ %{
+ "name" => ":wow:",
+ "id" => "https://remote/emoji/wow",
+ "icon" => %{
+ "type" => "Image",
+ "url" => "https://remote/emoji/wow"
+ }
+ }
+ ]
+ }, []} = Builder.emoji_react(user, note, ":wow@remote:")
+ end
+ end
end
import Pleroma.Factory
import ExUnit.CaptureLog
+ alias Pleroma.Web.ActivityPub.MRF
alias Pleroma.Web.ActivityPub.MRF.AntiLinkSpamPolicy
@linkless_message %{
assert user.note_count == 0
+ message = %{
+ "type" => "Create",
+ "actor" => user.ap_id,
+ "object" => %{
+ "formerRepresentations" => %{
+ "type" => "OrderedCollection",
+ "orderedItems" => [
+ %{
+ "content" => "<a href='https://example.com'>hi world!</a>"
+ }
+ ]
+ },
+ "content" => "mew"
+ }
+ }
+
+ {:reject, _} = MRF.filter_one(AntiLinkSpamPolicy, message)
+ end
+
+ test "it allows posts with links for local users" do
+ user = insert(:user)
+
+ assert user.note_count == 0
+
message =
@linkful_message
|> Map.put("actor", user.ap_id)
- {:reject, _} = AntiLinkSpamPolicy.filter(message)
+ {:ok, _message} = AntiLinkSpamPolicy.filter(message)
end
- test "it allows posts with links for local users" do
- user = insert(:user)
+ test "it disallows posts with links in history" do
+ user = insert(:user, local: false)
assert user.note_count == 0
@linkful_message
|> Map.put("actor", user.ap_id)
- {:ok, _message} = AntiLinkSpamPolicy.filter(message)
+ {:reject, _} = AntiLinkSpamPolicy.filter(message)
end
end
alias Pleroma.Activity
alias Pleroma.Object
+ alias Pleroma.Web.ActivityPub.MRF
alias Pleroma.Web.ActivityPub.MRF.EnsureRePrepended
describe "rewrites summary" do
assert {:ok, res} = EnsureRePrepended.filter(message)
assert res["object"]["summary"] == "re: object-summary"
end
+
+ test "it adds `re:` to history" do
+ message = %{
+ "type" => "Create",
+ "object" => %{
+ "summary" => "object-summary",
+ "inReplyTo" => %Activity{object: %Object{data: %{"summary" => "object-summary"}}},
+ "formerRepresentations" => %{
+ "orderedItems" => [
+ %{
+ "summary" => "object-summary",
+ "inReplyTo" => %Activity{object: %Object{data: %{"summary" => "object-summary"}}}
+ }
+ ]
+ }
+ }
+ }
+
+ assert {:ok, res} = MRF.filter_one(EnsureRePrepended, message)
+ assert res["object"]["summary"] == "re: object-summary"
+
+ assert Enum.at(res["object"]["formerRepresentations"]["orderedItems"], 0)["summary"] ==
+ "re: object-summary"
+ end
+
+ test "it accepts Updates" do
+ message = %{
+ "type" => "Update",
+ "object" => %{
+ "summary" => "object-summary",
+ "inReplyTo" => %Activity{object: %Object{data: %{"summary" => "object-summary"}}},
+ "formerRepresentations" => %{
+ "orderedItems" => [
+ %{
+ "summary" => "object-summary",
+ "inReplyTo" => %Activity{object: %Object{data: %{"summary" => "object-summary"}}}
+ }
+ ]
+ }
+ }
+ }
+
+ assert {:ok, res} = MRF.filter_one(EnsureRePrepended, message)
+ assert res["object"]["summary"] == "re: object-summary"
+
+ assert Enum.at(res["object"]["formerRepresentations"]["orderedItems"], 0)["summary"] ==
+ "re: object-summary"
+ end
end
describe "skip filter" do
- test "it skip if type isn't 'Create'" do
+ test "it skip if type isn't 'Create' or 'Update'" do
message = %{
"type" => "Annotation",
"object" => %{"summary" => "object-summary"}
assert modified["object"]["sensitive"]
end
+ test "it is history-aware" do
+ activity = %{
+ "type" => "Create",
+ "object" => %{
+ "content" => "hey",
+ "tag" => []
+ }
+ }
+
+ activity_data =
+ activity
+ |> put_in(
+ ["object", "formerRepresentations"],
+ %{
+ "type" => "OrderedCollection",
+ "orderedItems" => [
+ Map.put(
+ activity["object"],
+ "tag",
+ [%{"type" => "Hashtag", "name" => "#nsfw"}]
+ )
+ ]
+ }
+ )
+
+ {:ok, modified} =
+ Pleroma.Web.ActivityPub.MRF.filter_one(
+ Pleroma.Web.ActivityPub.MRF.HashtagPolicy,
+ activity_data
+ )
+
+ refute modified["object"]["sensitive"]
+ assert Enum.at(modified["object"]["formerRepresentations"]["orderedItems"], 0)["sensitive"]
+ end
+
+ test "it works with Update" do
+ activity = %{
+ "type" => "Update",
+ "object" => %{
+ "content" => "hey",
+ "tag" => []
+ }
+ }
+
+ activity_data =
+ activity
+ |> put_in(
+ ["object", "formerRepresentations"],
+ %{
+ "type" => "OrderedCollection",
+ "orderedItems" => [
+ Map.put(
+ activity["object"],
+ "tag",
+ [%{"type" => "Hashtag", "name" => "#nsfw"}]
+ )
+ ]
+ }
+ )
+
+ {:ok, modified} =
+ Pleroma.Web.ActivityPub.MRF.filter_one(
+ Pleroma.Web.ActivityPub.MRF.HashtagPolicy,
+ activity_data
+ )
+
+ refute modified["object"]["sensitive"]
+ assert Enum.at(modified["object"]["formerRepresentations"]["orderedItems"], 0)["sensitive"]
+ end
+
test "it doesn't sets the sensitive property with irrelevant hashtags" do
user = insert(:user)
KeywordPolicy.filter(message)
end)
end
+
+ test "rejects if string matches in history" do
+ clear_config([:mrf_keyword, :reject], ["pun"])
+
+ message = %{
+ "type" => "Create",
+ "object" => %{
+ "content" => "just a daily reminder that compLAINer is a good",
+ "summary" => "",
+ "formerRepresentations" => %{
+ "type" => "OrderedCollection",
+ "orderedItems" => [
+ %{
+ "content" => "just a daily reminder that compLAINer is a good pun",
+ "summary" => ""
+ }
+ ]
+ }
+ }
+ }
+
+ assert {:reject, "[KeywordPolicy] Matches with rejected keyword"} =
+ KeywordPolicy.filter(message)
+ end
+
+ test "rejects Updates" do
+ clear_config([:mrf_keyword, :reject], ["pun"])
+
+ message = %{
+ "type" => "Update",
+ "object" => %{
+ "content" => "just a daily reminder that compLAINer is a good",
+ "summary" => "",
+ "formerRepresentations" => %{
+ "type" => "OrderedCollection",
+ "orderedItems" => [
+ %{
+ "content" => "just a daily reminder that compLAINer is a good pun",
+ "summary" => ""
+ }
+ ]
+ }
+ }
+ }
+
+ assert {:reject, "[KeywordPolicy] Matches with rejected keyword"} =
+ KeywordPolicy.filter(message)
+ end
end
describe "delisting from ftl based on keywords" do
not (["https://www.w3.org/ns/activitystreams#Public"] == result["to"])
end)
end
+
+ test "delists if string matches in history" do
+ clear_config([:mrf_keyword, :federated_timeline_removal], ["pun"])
+
+ message = %{
+ "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+ "type" => "Create",
+ "object" => %{
+ "content" => "just a daily reminder that compLAINer is a good",
+ "summary" => "",
+ "formerRepresentations" => %{
+ "orderedItems" => [
+ %{
+ "content" => "just a daily reminder that compLAINer is a good pun",
+ "summary" => ""
+ }
+ ]
+ }
+ }
+ }
+
+ {:ok, result} = KeywordPolicy.filter(message)
+ assert ["https://www.w3.org/ns/activitystreams#Public"] == result["cc"]
+ refute ["https://www.w3.org/ns/activitystreams#Public"] == result["to"]
+ end
end
describe "replacing keywords" do
result == "ZFS is free software"
end)
end
+
+ test "replaces keyword if string matches in history" do
+ clear_config([:mrf_keyword, :replace], [{"opensource", "free software"}])
+
+ message = %{
+ "type" => "Create",
+ "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+ "object" => %{
+ "content" => "ZFS is opensource",
+ "summary" => "",
+ "formerRepresentations" => %{
+ "type" => "OrderedCollection",
+ "orderedItems" => [
+ %{"content" => "ZFS is opensource mew mew", "summary" => ""}
+ ]
+ }
+ }
+ }
+
+ {:ok,
+ %{
+ "object" => %{
+ "content" => "ZFS is free software",
+ "formerRepresentations" => %{
+ "orderedItems" => [%{"content" => "ZFS is free software mew mew"}]
+ }
+ }
+ }} = KeywordPolicy.filter(message)
+ end
+
+ test "replaces keyword in Updates" do
+ clear_config([:mrf_keyword, :replace], [{"opensource", "free software"}])
+
+ message = %{
+ "type" => "Update",
+ "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+ "object" => %{
+ "content" => "ZFS is opensource",
+ "summary" => "",
+ "formerRepresentations" => %{
+ "type" => "OrderedCollection",
+ "orderedItems" => [
+ %{"content" => "ZFS is opensource mew mew", "summary" => ""}
+ ]
+ }
+ }
+ }
+
+ {:ok,
+ %{
+ "object" => %{
+ "content" => "ZFS is free software",
+ "formerRepresentations" => %{
+ "orderedItems" => [%{"content" => "ZFS is free software mew mew"}]
+ }
+ }
+ }} = KeywordPolicy.filter(message)
+ end
end
end
use Pleroma.Tests.Helpers
alias Pleroma.HTTP
+ alias Pleroma.Web.ActivityPub.MRF
alias Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy
import Mock
}
}
+ @message_with_history %{
+ "type" => "Create",
+ "object" => %{
+ "type" => "Note",
+ "content" => "content",
+ "formerRepresentations" => %{
+ "orderedItems" => [
+ %{
+ "type" => "Note",
+ "content" => "content",
+ "attachment" => [
+ %{"url" => [%{"href" => "http://example.com/image.jpg"}]}
+ ]
+ }
+ ]
+ }
+ }
+ }
+
setup do: clear_config([:media_proxy, :enabled], true)
test "it prefetches media proxy URIs" do
refute called(HTTP.get(:_, :_, :_))
end
end
+
+ test "history-aware" do
+ Tesla.Mock.mock(fn %{method: :get, url: "http://example.com/image.jpg"} ->
+ {:ok, %Tesla.Env{status: 200, body: ""}}
+ end)
+
+ with_mock HTTP, get: fn _, _, _ -> {:ok, []} end do
+ MRF.filter_one(MediaProxyWarmingPolicy, @message_with_history)
+
+ assert called(HTTP.get(:_, :_, :_))
+ end
+ end
+
+ test "works with Updates" do
+ Tesla.Mock.mock(fn %{method: :get, url: "http://example.com/image.jpg"} ->
+ {:ok, %Tesla.Env{status: 200, body: ""}}
+ end)
+
+ with_mock HTTP, get: fn _, _, _ -> {:ok, []} end do
+ MRF.filter_one(MediaProxyWarmingPolicy, @message_with_history |> Map.put("type", "Update"))
+
+ assert called(HTTP.get(:_, :_, :_))
+ end
+ end
end
assert NoEmptyPolicy.filter(message) == {:reject, "[NoEmptyPolicy]"}
end
+
+ test "works with Update" do
+ message = %{
+ "actor" => "http://localhost:4001/users/testuser",
+ "cc" => ["http://localhost:4001/users/testuser/followers"],
+ "object" => %{
+ "actor" => "http://localhost:4001/users/testuser",
+ "attachment" => [],
+ "cc" => ["http://localhost:4001/users/testuser/followers"],
+ "source" => "",
+ "to" => [
+ "https://www.w3.org/ns/activitystreams#Public"
+ ],
+ "type" => "Note"
+ },
+ "to" => [
+ "https://www.w3.org/ns/activitystreams#Public"
+ ],
+ "type" => "Update"
+ }
+
+ assert NoEmptyPolicy.filter(message) == {:reject, "[NoEmptyPolicy]"}
+ end
end
defmodule Pleroma.Web.ActivityPub.MRF.NoPlaceholderTextPolicyTest do
use Pleroma.DataCase, async: true
+ alias Pleroma.Web.ActivityPub.MRF
alias Pleroma.Web.ActivityPub.MRF.NoPlaceholderTextPolicy
test "it clears content object" do
assert res["object"]["content"] == ""
end
+ test "history-aware" do
+ message = %{
+ "type" => "Create",
+ "object" => %{
+ "content" => ".",
+ "attachment" => "image",
+ "formerRepresentations" => %{
+ "orderedItems" => [%{"content" => ".", "attachment" => "image"}]
+ }
+ }
+ }
+
+ assert {:ok, res} = MRF.filter_one(NoPlaceholderTextPolicy, message)
+
+ assert %{
+ "content" => "",
+ "formerRepresentations" => %{"orderedItems" => [%{"content" => ""}]}
+ } = res["object"]
+ end
+
+ test "works with Updates" do
+ message = %{
+ "type" => "Update",
+ "object" => %{
+ "content" => ".",
+ "attachment" => "image",
+ "formerRepresentations" => %{
+ "orderedItems" => [%{"content" => ".", "attachment" => "image"}]
+ }
+ }
+ }
+
+ assert {:ok, res} = MRF.filter_one(NoPlaceholderTextPolicy, message)
+
+ assert %{
+ "content" => "",
+ "formerRepresentations" => %{"orderedItems" => [%{"content" => ""}]}
+ } = res["object"]
+ end
+
@messages [
%{
"type" => "Create",
defmodule Pleroma.Web.ActivityPub.MRF.NormalizeMarkupTest do
use Pleroma.DataCase, async: true
+ alias Pleroma.Web.ActivityPub.MRF
alias Pleroma.Web.ActivityPub.MRF.NormalizeMarkup
@html_sample """
<script>alert('hacked')</script>
"""
- test "it filter html tags" do
- expected = """
- <b>this is in bold</b>
- <p>this is a paragraph</p>
- this is a linebreak<br/>
- this is a link with allowed "rel" attribute: <a href="http://example.com/" rel="tag">example.com</a>
- this is a link with not allowed "rel" attribute: <a href="http://example.com/">example.com</a>
- this is an image: <img src="http://example.com/image.jpg"/><br/>
- alert('hacked')
- """
+ @expected """
+ <b>this is in bold</b>
+ <p>this is a paragraph</p>
+ this is a linebreak<br/>
+ this is a link with allowed "rel" attribute: <a href="http://example.com/" rel="tag">example.com</a>
+ this is a link with not allowed "rel" attribute: <a href="http://example.com/">example.com</a>
+ this is an image: <img src="http://example.com/image.jpg"/><br/>
+ alert('hacked')
+ """
+ test "it filter html tags" do
message = %{"type" => "Create", "object" => %{"content" => @html_sample}}
assert {:ok, res} = NormalizeMarkup.filter(message)
- assert res["object"]["content"] == expected
+ assert res["object"]["content"] == @expected
+ end
+
+ test "history-aware" do
+ message = %{
+ "type" => "Create",
+ "object" => %{
+ "content" => @html_sample,
+ "formerRepresentations" => %{"orderedItems" => [%{"content" => @html_sample}]}
+ }
+ }
+
+ assert {:ok, res} = MRF.filter_one(NormalizeMarkup, message)
+
+ assert %{
+ "content" => @expected,
+ "formerRepresentations" => %{"orderedItems" => [%{"content" => @expected}]}
+ } = res["object"]
+ end
+
+ test "works with Updates" do
+ message = %{
+ "type" => "Update",
+ "object" => %{
+ "content" => @html_sample,
+ "formerRepresentations" => %{"orderedItems" => [%{"content" => @html_sample}]}
+ }
+ }
+
+ assert {:ok, res} = MRF.filter_one(NormalizeMarkup, message)
+
+ assert %{
+ "content" => @expected,
+ "formerRepresentations" => %{"orderedItems" => [%{"content" => @expected}]}
+ } = res["object"]
end
- test "it skips filter if type isn't `Create`" do
+ test "it skips filter if type isn't `Create` or `Update`" do
message = %{"type" => "Note", "object" => %{}}
assert {:ok, res} = NormalizeMarkup.filter(message)
end
end
+ describe "describe/1" do
+ test "returns a description of the policy" do
+ clear_config([:mrf_simple, :reject], [
+ {"remote.instance", "did not give my catboy a burg"}
+ ])
+
+ assert {:ok, %{mrf_simple: %{reject: ["remote.instance"]}}} = SimplePolicy.describe()
+ end
+
+ test "excludes domains listed in :transparency_exclusions" do
+ clear_config([:mrf, :transparency_exclusions], [{"remote.instance", ":("}])
+
+ clear_config([:mrf_simple, :reject], [
+ {"remote.instance", "did not give my catboy a burg"}
+ ])
+
+ {:ok, description} = SimplePolicy.describe()
+ assert %{mrf_simple: %{reject: []}} = description
+ assert description[:mrf_simple_info][:reject] == nil
+ end
+
+ test "obfuscates domains listed in :transparency_obfuscate_domains" do
+ clear_config([:mrf, :transparency_obfuscate_domains], ["remote.instance", "a.b"])
+
+ clear_config([:mrf_simple, :reject], [
+ {"remote.instance", "did not give my catboy a burg"},
+ {"a.b", "spam-poked me on facebook in 2006"}
+ ])
+
+ assert {:ok,
+ %{
+ mrf_simple: %{reject: ["rem***.*****nce", "a.b"]},
+ mrf_simple_info: %{reject: %{"rem***.*****nce" => %{}}}
+ }} = SimplePolicy.describe()
+ end
+ end
+
defp build_ftl_actor_and_message do
actor = insert(:user)
clear_config([:mrf, :policies], [Pleroma.Web.ActivityPub.MRF.NoOpPolicy])
expected = %{
- mrf_policies: ["NoOpPolicy", "HashtagPolicy"],
+ mrf_policies: ["NoOpPolicy", "HashtagPolicy", "InlineQuotePolicy"],
mrf_hashtag: %{
federated_timeline_removal: [],
reject: [],
clear_config([:mrf, :policies], [MRFModuleMock])
expected = %{
- mrf_policies: ["MRFModuleMock", "HashtagPolicy"],
+ mrf_policies: ["MRFModuleMock", "HashtagPolicy", "InlineQuotePolicy"],
mrf_module_mock: "some config data",
mrf_hashtag: %{
federated_timeline_removal: [],
defmodule Pleroma.Web.ActivityPub.ObjectValidators.ArticleNotePageValidatorTest do
use Pleroma.DataCase, async: true
+ alias Pleroma.Web.ActivityPub.ObjectValidator
alias Pleroma.Web.ActivityPub.ObjectValidators.ArticleNotePageValidator
alias Pleroma.Web.ActivityPub.Utils
%{valid?: true} = ArticleNotePageValidator.cast_and_validate(note)
end
+ test "a note from factory validates" do
+ note = insert(:note)
+ %{valid?: true} = ArticleNotePageValidator.cast_and_validate(note.data)
+ end
+
test "a note with a remote replies collection should validate", _ do
insert(:user, %{ap_id: "https://bookwyrm.com/user/TestUser"})
collection = File.read!("test/fixtures/bookwyrm-replies-collection.json")
changes: %{
content: content,
source: %{
- "content" =>
- "@akkoma_user @remote_user @full_tag_remote_user@misskey.local.live @oops_not_a_mention linkifylink #dancedance $[jelly mfm goes here] \n\n## aaa",
"mediaType" => "text/x.misskeymarkdown"
}
}
"<span class=\"h-card\"><a class=\"u-url mention\" data-user=\"#{full_tag_remote_user.id}\" href=\"#{full_tag_remote_user.ap_id}\" rel=\"ugc\">@<span>full_tag_remote_user</span></a></span>"
assert content =~ "@oops_not_a_mention"
- assert content =~ "$[jelly mfm goes here] <br><br>## aaa"
+
+ assert content =~
+ "<span class=\"mfm _mfm_jelly_\" style=\"display: inline-block; animation: 1s linear 0s infinite normal both running mfm-rubberBand;\">mfm goes here</span> </p>aaa"
end
test "a misskey MFM status with a _misskey_content field should work and be linked", _ do
|> File.read!()
|> Jason.decode!()
- expected_content =
- "<span class=\"h-card\"><a class=\"u-url mention\" data-user=\"#{local_user.id}\" href=\"#{local_user.ap_id}\" rel=\"ugc\">@<span>akkoma_user</span></a></span> linkifylink <a class=\"hashtag\" data-tag=\"dancedance\" href=\"http://localhost:4001/tag/dancedance\">#dancedance</a> $[jelly mfm goes here] <br><br>## aaa"
-
changes = ArticleNotePageValidator.cast_and_validate(note)
%{
valid?: true,
changes: %{
+ content: content,
source: %{
- "content" => "@akkoma_user linkifylink #dancedance $[jelly mfm goes here] \n\n## aaa",
- "mediaType" => "text/x.misskeymarkdown"
+ "mediaType" => "text/x.misskeymarkdown",
+ "content" => "@akkoma_user linkifylink #dancedance $[jelly mfm goes here] \n\n## aaa"
}
}
} = changes
- assert changes.changes[:content] == expected_content
+ assert content =~
+ "<span class=\"h-card\"><a class=\"u-url mention\" data-user=\"#{local_user.id}\" href=\"#{local_user.ap_id}\" rel=\"ugc\">@<span>akkoma_user</span></a></span>"
+ end
+ end
+
+ test "a Note without replies/first/items validates" do
+ insert(:user, ap_id: "https://mastodon.social/users/emelie")
+
+ note =
+ "test/fixtures/tesla_mock/status.emelie.json"
+ |> File.read!()
+ |> Jason.decode!()
+ |> pop_in(["replies", "first", "items"])
+ |> elem(1)
+
+ %{valid?: true} = ArticleNotePageValidator.cast_and_validate(note)
+ end
+
+ describe "Note with history" do
+ setup do
+ user = insert(:user)
+ {:ok, activity} = Pleroma.Web.CommonAPI.post(user, %{status: "mew mew :dinosaur:"})
+ {:ok, edit} = Pleroma.Web.CommonAPI.update(user, activity, %{status: "edited :blank:"})
+
+ {:ok, %{"object" => external_rep}} =
+ Pleroma.Web.ActivityPub.Transmogrifier.prepare_outgoing(edit.data)
+
+ %{external_rep: external_rep}
+ end
+
+ test "edited note", %{external_rep: external_rep} do
+ assert %{"formerRepresentations" => %{"orderedItems" => [%{"tag" => [_]}]}} = external_rep
+
+ {:ok, validate_res, []} = ObjectValidator.validate(external_rep, [])
+
+ assert %{"formerRepresentations" => %{"orderedItems" => [%{"emoji" => %{"dinosaur" => _}}]}} =
+ validate_res
+ end
+
+ test "edited note, badly-formed formerRepresentations", %{external_rep: external_rep} do
+ external_rep = Map.put(external_rep, "formerRepresentations", %{})
+
+ assert {:error, _} = ObjectValidator.validate(external_rep, [])
+ end
+
+ test "edited note, badly-formed history item", %{external_rep: external_rep} do
+ history_item =
+ Enum.at(external_rep["formerRepresentations"]["orderedItems"], 0)
+ |> Map.put("type", "Foo")
+
+ external_rep =
+ put_in(
+ external_rep,
+ ["formerRepresentations", "orderedItems"],
+ [history_item]
+ )
+
+ assert {:error, _} = ObjectValidator.validate(external_rep, [])
end
end
end
test "returns an error if the object can't be updated by the actor", %{
valid_update: valid_update
} do
- other_user = insert(:user)
+ other_user = insert(:user, local: false)
update =
valid_update
assert {:error, _cng} = ObjectValidator.validate(update, [])
end
+
+ test "validates as long as the object is same-origin with the actor", %{
+ valid_update: valid_update
+ } do
+ other_user = insert(:user)
+
+ update =
+ valid_update
+ |> Map.put("actor", other_user.ap_id)
+
+ assert {:ok, _update, []} = ObjectValidator.validate(update, [])
+ end
+
+ test "validates if the object is not of an Actor type" do
+ note = insert(:note)
+ updated_note = note.data |> Map.put("content", "edited content")
+ other_user = insert(:user)
+
+ {:ok, update, _} = Builder.update(other_user, updated_note)
+
+ assert {:ok, _update, _} = ObjectValidator.validate(update, [])
+ end
+ end
+
+ describe "update note" do
+ test "converts object into Pleroma's format" do
+ mastodon_tags = [
+ %{
+ "icon" => %{
+ "mediaType" => "image/png",
+ "type" => "Image",
+ "url" => "https://somewhere.org/emoji/url/1.png"
+ },
+ "id" => "https://somewhere.org/emoji/1",
+ "name" => ":some_emoji:",
+ "type" => "Emoji",
+ "updated" => "2021-04-07T11:00:00Z"
+ }
+ ]
+
+ user = insert(:user)
+ note = insert(:note, user: user)
+
+ updated_note =
+ note.data
+ |> Map.put("content", "edited content")
+ |> Map.put("tag", mastodon_tags)
+
+ {:ok, update, _} = Builder.update(user, updated_note)
+
+ assert {:ok, _update, meta} = ObjectValidator.validate(update, [])
+
+ assert %{"emoji" => %{"some_emoji" => "https://somewhere.org/emoji/url/1.png"}} =
+ meta[:object_data]
+ end
+
+ test "returns no object_data in meta for a local Update" do
+ user = insert(:user)
+ note = insert(:note, user: user)
+
+ updated_note =
+ note.data
+ |> Map.put("content", "edited content")
+
+ {:ok, update, _} = Builder.update(user, updated_note)
+
+ assert {:ok, _update, meta} = ObjectValidator.validate(update, local: true)
+ assert is_nil(meta[:object_data])
+ end
+
+ test "returns object_data in meta for a remote Update" do
+ user = insert(:user)
+ note = insert(:note, user: user)
+
+ updated_note =
+ note.data
+ |> Map.put("content", "edited content")
+
+ {:ok, update, _} = Builder.update(user, updated_note)
+
+ assert {:ok, _update, meta} = ObjectValidator.validate(update, local: false)
+ assert meta[:object_data]
+
+ assert {:ok, _update, meta} = ObjectValidator.validate(update, [])
+ assert meta[:object_data]
+ end
+ end
+
+ describe "update with history" do
+ setup do
+ user = insert(:user)
+ {:ok, activity} = Pleroma.Web.CommonAPI.post(user, %{status: "mew mew :dinosaur:"})
+ {:ok, edit} = Pleroma.Web.CommonAPI.update(user, activity, %{status: "edited :blank:"})
+ {:ok, external_rep} = Pleroma.Web.ActivityPub.Transmogrifier.prepare_outgoing(edit.data)
+ %{external_rep: external_rep}
+ end
+
+ test "edited note", %{external_rep: external_rep} do
+ {:ok, _validate_res, meta} = ObjectValidator.validate(external_rep, [])
+
+ assert %{"formerRepresentations" => %{"orderedItems" => [%{"emoji" => %{"dinosaur" => _}}]}} =
+ meta[:object_data]
+ end
+
+ test "edited note, badly-formed formerRepresentations", %{external_rep: external_rep} do
+ external_rep = put_in(external_rep, ["object", "formerRepresentations"], %{})
+
+ assert {:error, _} = ObjectValidator.validate(external_rep, [])
+ end
+
+ test "edited note, badly-formed history item", %{external_rep: external_rep} do
+ history_item =
+ Enum.at(external_rep["object"]["formerRepresentations"]["orderedItems"], 0)
+ |> Map.put("type", "Foo")
+
+ external_rep =
+ put_in(
+ external_rep,
+ ["object", "formerRepresentations", "orderedItems"],
+ [history_item]
+ )
+
+ assert {:error, _} = ObjectValidator.validate(external_rep, [])
+ end
end
end
describe "update users" do
setup do
user = insert(:user, local: false)
- {:ok, update_data, []} = Builder.update(user, %{"id" => user.ap_id, "name" => "new name!"})
+
+ {:ok, update_data, []} =
+ Builder.update(user, %{"id" => user.ap_id, "type" => "Person", "name" => "new name!"})
+
{:ok, update, _meta} = ActivityPub.persist(update_data, local: true)
%{user: user, update_data: update_data, update: update}
end
end
+ describe "update notes" do
+ setup do
+ make_time = fn ->
+ Pleroma.Web.ActivityPub.Utils.make_date()
+ end
+
+ user = insert(:user)
+ note = insert(:note, user: user, data: %{"published" => make_time.()})
+ _note_activity = insert(:note_activity, note: note)
+
+ updated_note =
+ note.data
+ |> Map.put("summary", "edited summary")
+ |> Map.put("content", "edited content")
+ |> Map.put("updated", make_time.())
+
+ {:ok, update_data, []} = Builder.update(user, updated_note)
+ {:ok, update, _meta} = ActivityPub.persist(update_data, local: true)
+
+ %{
+ user: user,
+ note: note,
+ object_id: note.id,
+ update_data: update_data,
+ update: update,
+ updated_note: updated_note
+ }
+ end
+
+ test "it updates the note", %{
+ object_id: object_id,
+ update: update,
+ updated_note: updated_note
+ } do
+ {:ok, _, _} = SideEffects.handle(update, object_data: updated_note)
+ updated_time = updated_note["updated"]
+
+ new_note = Pleroma.Object.get_by_id(object_id)
+
+ assert %{
+ "summary" => "edited summary",
+ "content" => "edited content",
+ "updated" => ^updated_time
+ } = new_note.data
+ end
+
+ test "it rejects updates with no updated attribute in object", %{
+ object_id: object_id,
+ update: update,
+ updated_note: updated_note
+ } do
+ old_note = Pleroma.Object.get_by_id(object_id)
+ updated_note = Map.drop(updated_note, ["updated"])
+ {:ok, _, _} = SideEffects.handle(update, object_data: updated_note)
+ new_note = Pleroma.Object.get_by_id(object_id)
+ assert old_note.data == new_note.data
+ end
+
+ test "it rejects updates with updated attribute older than what we have in the original object",
+ %{
+ object_id: object_id,
+ update: update,
+ updated_note: updated_note
+ } do
+ old_note = Pleroma.Object.get_by_id(object_id)
+ {:ok, creation_time, _} = DateTime.from_iso8601(old_note.data["published"])
+
+ updated_note =
+ Map.put(updated_note, "updated", DateTime.to_iso8601(DateTime.add(creation_time, -10)))
+
+ {:ok, _, _} = SideEffects.handle(update, object_data: updated_note)
+ new_note = Pleroma.Object.get_by_id(object_id)
+ assert old_note.data == new_note.data
+ end
+
+ test "it rejects updates with updated attribute older than the last Update", %{
+ object_id: object_id,
+ update: update,
+ updated_note: updated_note
+ } do
+ old_note = Pleroma.Object.get_by_id(object_id)
+ {:ok, creation_time, _} = DateTime.from_iso8601(old_note.data["published"])
+
+ updated_note =
+ Map.put(updated_note, "updated", DateTime.to_iso8601(DateTime.add(creation_time, +10)))
+
+ {:ok, _, _} = SideEffects.handle(update, object_data: updated_note)
+
+ old_note = Pleroma.Object.get_by_id(object_id)
+ {:ok, update_time, _} = DateTime.from_iso8601(old_note.data["updated"])
+
+ updated_note =
+ Map.put(updated_note, "updated", DateTime.to_iso8601(DateTime.add(update_time, -5)))
+
+ {:ok, _, _} = SideEffects.handle(update, object_data: updated_note)
+
+ new_note = Pleroma.Object.get_by_id(object_id)
+ assert old_note.data == new_note.data
+ end
+
+ test "it updates using object_data", %{
+ object_id: object_id,
+ update: update,
+ updated_note: updated_note
+ } do
+ updated_note = Map.put(updated_note, "summary", "mew mew")
+ {:ok, _, _} = SideEffects.handle(update, object_data: updated_note)
+ new_note = Pleroma.Object.get_by_id(object_id)
+ assert %{"summary" => "mew mew", "content" => "edited content"} = new_note.data
+ end
+
+ test "it records the original note in formerRepresentations", %{
+ note: note,
+ object_id: object_id,
+ update: update,
+ updated_note: updated_note
+ } do
+ {:ok, _, _} = SideEffects.handle(update, object_data: updated_note)
+ %{data: new_note} = Pleroma.Object.get_by_id(object_id)
+ assert %{"summary" => "edited summary", "content" => "edited content"} = new_note
+
+ assert [Map.drop(note.data, ["id", "formerRepresentations"])] ==
+ new_note["formerRepresentations"]["orderedItems"]
+
+ assert new_note["formerRepresentations"]["totalItems"] == 1
+ end
+
+ test "it puts the original note at the front of formerRepresentations", %{
+ user: user,
+ note: note,
+ object_id: object_id,
+ update: update,
+ updated_note: updated_note
+ } do
+ {:ok, _, _} = SideEffects.handle(update, object_data: updated_note)
+ %{data: first_edit} = Pleroma.Object.get_by_id(object_id)
+
+ second_updated_note =
+ note.data
+ |> Map.put("summary", "edited summary 2")
+ |> Map.put("content", "edited content 2")
+ |> Map.put(
+ "updated",
+ first_edit["updated"]
+ |> DateTime.from_iso8601()
+ |> elem(1)
+ |> DateTime.add(10)
+ |> DateTime.to_iso8601()
+ )
+
+ {:ok, second_update_data, []} = Builder.update(user, second_updated_note)
+ {:ok, update, _meta} = ActivityPub.persist(second_update_data, local: true)
+ {:ok, _, _} = SideEffects.handle(update, object_data: second_updated_note)
+ %{data: new_note} = Pleroma.Object.get_by_id(object_id)
+ assert %{"summary" => "edited summary 2", "content" => "edited content 2"} = new_note
+
+ original_version = Map.drop(note.data, ["id", "formerRepresentations"])
+ first_edit = Map.drop(first_edit, ["id", "formerRepresentations"])
+
+ assert [first_edit, original_version] ==
+ new_note["formerRepresentations"]["orderedItems"]
+
+ assert new_note["formerRepresentations"]["totalItems"] == 2
+ end
+
+ test "it does not prepend to formerRepresentations if no actual changes are made", %{
+ note: note,
+ object_id: object_id,
+ update: update,
+ updated_note: updated_note
+ } do
+ {:ok, _, _} = SideEffects.handle(update, object_data: updated_note)
+ %{data: first_edit} = Pleroma.Object.get_by_id(object_id)
+
+ updated_note =
+ updated_note
+ |> Map.put(
+ "updated",
+ first_edit["updated"]
+ |> DateTime.from_iso8601()
+ |> elem(1)
+ |> DateTime.add(10)
+ |> DateTime.to_iso8601()
+ )
+
+ {:ok, _, _} = SideEffects.handle(update, object_data: updated_note)
+ %{data: new_note} = Pleroma.Object.get_by_id(object_id)
+ assert %{"summary" => "edited summary", "content" => "edited content"} = new_note
+
+ original_version = Map.drop(note.data, ["id", "formerRepresentations"])
+
+ assert [original_version] ==
+ new_note["formerRepresentations"]["orderedItems"]
+
+ assert new_note["formerRepresentations"]["totalItems"] == 1
+ end
+ end
+
+ describe "update questions" do
+ setup do
+ user = insert(:user)
+
+ question =
+ insert(:question,
+ user: user,
+ data: %{"published" => Pleroma.Web.ActivityPub.Utils.make_date()}
+ )
+
+ %{user: user, data: question.data, id: question.id}
+ end
+
+ test "allows updating choice count without generating edit history", %{
+ user: user,
+ data: data,
+ id: id
+ } do
+ new_choices =
+ data["oneOf"]
+ |> Enum.map(fn choice -> put_in(choice, ["replies", "totalItems"], 5) end)
+
+ updated_question =
+ data
+ |> Map.put("oneOf", new_choices)
+ |> Map.put("updated", Pleroma.Web.ActivityPub.Utils.make_date())
+
+ {:ok, update_data, []} = Builder.update(user, updated_question)
+ {:ok, update, _meta} = ActivityPub.persist(update_data, local: true)
+
+ {:ok, _, _} = SideEffects.handle(update, object_data: updated_question)
+
+ %{data: new_question} = Pleroma.Object.get_by_id(id)
+
+ assert [%{"replies" => %{"totalItems" => 5}}, %{"replies" => %{"totalItems" => 5}}] =
+ new_question["oneOf"]
+
+ refute Map.has_key?(new_question, "formerRepresentations")
+ end
+
+ test "allows updating choice count without updated field", %{
+ user: user,
+ data: data,
+ id: id
+ } do
+ new_choices =
+ data["oneOf"]
+ |> Enum.map(fn choice -> put_in(choice, ["replies", "totalItems"], 5) end)
+
+ updated_question =
+ data
+ |> Map.put("oneOf", new_choices)
+
+ {:ok, update_data, []} = Builder.update(user, updated_question)
+ {:ok, update, _meta} = ActivityPub.persist(update_data, local: true)
+
+ {:ok, _, _} = SideEffects.handle(update, object_data: updated_question)
+
+ %{data: new_question} = Pleroma.Object.get_by_id(id)
+
+ assert [%{"replies" => %{"totalItems" => 5}}, %{"replies" => %{"totalItems" => 5}}] =
+ new_question["oneOf"]
+
+ refute Map.has_key?(new_question, "formerRepresentations")
+ end
+
+ test "allows updating choice count with updated field same as the creation date", %{
+ user: user,
+ data: data,
+ id: id
+ } do
+ new_choices =
+ data["oneOf"]
+ |> Enum.map(fn choice -> put_in(choice, ["replies", "totalItems"], 5) end)
+
+ updated_question =
+ data
+ |> Map.put("oneOf", new_choices)
+ |> Map.put("updated", data["published"])
+
+ {:ok, update_data, []} = Builder.update(user, updated_question)
+ {:ok, update, _meta} = ActivityPub.persist(update_data, local: true)
+
+ {:ok, _, _} = SideEffects.handle(update, object_data: updated_question)
+
+ %{data: new_question} = Pleroma.Object.get_by_id(id)
+
+ assert [%{"replies" => %{"totalItems" => 5}}, %{"replies" => %{"totalItems" => 5}}] =
+ new_question["oneOf"]
+
+ refute Map.has_key?(new_question, "formerRepresentations")
+ end
+ end
+
describe "EmojiReact objects" do
setup do
poster = insert(:user)
clear_config([:instance, :federation_incoming_replies_max_depth], 10)
{:ok, activity} = Transmogrifier.handle_incoming(data)
-
object = Object.normalize(activity.data["object"])
assert object.data["replies"] == items
assert url == "http://localhost:4001/emoji/dino%20walking.gif"
end
+
+ test "Updates of Notes are handled" do
+ user = insert(:user)
+
+ {:ok, activity} = CommonAPI.post(user, %{status: "everybody do the dinosaur :dinosaur:"})
+ {:ok, update} = CommonAPI.update(user, activity, %{status: "mew mew :blank:"})
+
+ {:ok, prepared} = Transmogrifier.prepare_outgoing(update.data)
+
+ assert %{
+ "content" => "mew mew :blank:",
+ "tag" => [%{"name" => ":blank:", "type" => "Emoji"}],
+ "formerRepresentations" => %{
+ "orderedItems" => [
+ %{
+ "content" => "everybody do the dinosaur :dinosaur:",
+ "tag" => [%{"name" => ":dinosaur:", "type" => "Emoji"}]
+ }
+ ]
+ }
+ } = prepared["object"]
+ end
end
describe "user upgrade" do
assert Transmogrifier.fix_attachments(object) == expected
end
end
+
+ describe "prepare_object/1" do
+ test "it processes history" do
+ original = %{
+ "formerRepresentations" => %{
+ "orderedItems" => [
+ %{
+ "generator" => %{},
+ "emoji" => %{"blobcat" => "http://localhost:4001/emoji/blobcat.png"}
+ }
+ ]
+ }
+ }
+
+ processed = Transmogrifier.prepare_object(original)
+
+ history_item = Enum.at(processed["formerRepresentations"]["orderedItems"], 0)
+
+ refute Map.has_key?(history_item, "generator")
+
+ assert [%{"name" => ":blobcat:"}] = history_item["tag"]
+ end
+
+ test "it works when there is no or bad history" do
+ original = %{
+ "formerRepresentations" => %{
+ "items" => [
+ %{
+ "generator" => %{},
+ "emoji" => %{"blobcat" => "http://localhost:4001/emoji/blobcat.png"}
+ }
+ ]
+ }
+ }
+
+ processed = Transmogrifier.prepare_object(original)
+ assert processed["formerRepresentations"] == original["formerRepresentations"]
+ end
+ end
end
end
end
- describe "update_follow_state/2" do
- test "updates the state of the given follow activity" do
- user = insert(:user, is_locked: true)
- follower = insert(:user)
-
- {:ok, _, _, follow_activity} = CommonAPI.follow(follower, user)
- {:ok, _, _, follow_activity_two} = CommonAPI.follow(follower, user)
-
- data =
- follow_activity_two.data
- |> Map.put("state", "accept")
-
- cng = Ecto.Changeset.change(follow_activity_two, data: data)
-
- {:ok, follow_activity_two} = Repo.update(cng)
-
- {:ok, follow_activity_two} = Utils.update_follow_state(follow_activity_two, "reject")
-
- assert refresh_record(follow_activity).data["state"] == "pending"
- assert refresh_record(follow_activity_two).data["state"] == "reject"
- end
- end
-
describe "update_element_in_object/3" do
test "updates likes" do
user = insert(:user)
refute User.subscribed_to?(follower, followed)
end
- test "cancels a pending follow for a local user" do
+ test "removes a pending follow for a local user" do
follower = insert(:user)
followed = insert(:user, is_locked: true)
- assert {:ok, follower, followed, %{id: activity_id, data: %{"state" => "pending"}}} =
+ assert {:ok, follower, followed, %{id: _activity_id, data: %{"state" => "pending"}}} =
CommonAPI.follow(follower, followed)
assert User.get_follow_state(follower, followed) == :follow_pending
assert {:ok, follower} = CommonAPI.unfollow(follower, followed)
assert User.get_follow_state(follower, followed) == nil
- assert %{id: ^activity_id, data: %{"state" => "cancelled"}} =
- Pleroma.Web.ActivityPub.Utils.fetch_latest_follow(follower, followed)
+ assert is_nil(Pleroma.Web.ActivityPub.Utils.fetch_latest_follow(follower, followed))
assert %{
data: %{
"type" => "Undo",
- "object" => %{"type" => "Follow", "state" => "cancelled"}
+ "object" => %{"type" => "Follow"}
}
} = Pleroma.Web.ActivityPub.Utils.fetch_latest_undo(follower)
end
follower = insert(:user)
followed = insert(:user, is_locked: true, local: false, ap_enabled: true)
- assert {:ok, follower, followed, %{id: activity_id, data: %{"state" => "pending"}}} =
+ assert {:ok, follower, followed, %{id: _activity_id, data: %{"state" => "pending"}}} =
CommonAPI.follow(follower, followed)
assert User.get_follow_state(follower, followed) == :follow_pending
assert {:ok, follower} = CommonAPI.unfollow(follower, followed)
assert User.get_follow_state(follower, followed) == nil
- assert %{id: ^activity_id, data: %{"state" => "cancelled"}} =
- Pleroma.Web.ActivityPub.Utils.fetch_latest_follow(follower, followed)
+ assert is_nil(Pleroma.Web.ActivityPub.Utils.fetch_latest_follow(follower, followed))
assert %{
data: %{
"type" => "Undo",
- "object" => %{"type" => "Follow", "state" => "cancelled"}
+ "object" => %{"type" => "Follow"}
}
} = Pleroma.Web.ActivityPub.Utils.fetch_latest_undo(follower)
end
end
end
end
+
+ describe "update/3" do
+ test "updates a post" do
+ user = insert(:user)
+ {:ok, activity} = CommonAPI.post(user, %{status: "foo1", spoiler_text: "title 1"})
+
+ {:ok, updated} = CommonAPI.update(user, activity, %{status: "updated 2"})
+
+ updated_object = Object.normalize(updated)
+ assert updated_object.data["content"] == "updated 2"
+ assert Map.get(updated_object.data, "summary", "") == ""
+ assert Map.has_key?(updated_object.data, "updated")
+ end
+
+ test "does not change visibility" do
+ user = insert(:user)
+
+ {:ok, activity} =
+ CommonAPI.post(user, %{status: "foo1", spoiler_text: "title 1", visibility: "private"})
+
+ {:ok, updated} = CommonAPI.update(user, activity, %{status: "updated 2"})
+
+ updated_object = Object.normalize(updated)
+ assert updated_object.data["content"] == "updated 2"
+ assert Map.get(updated_object.data, "summary", "") == ""
+ assert Visibility.get_visibility(updated_object) == "private"
+ assert Visibility.get_visibility(updated) == "private"
+ end
+
+ test "updates a post with emoji" do
+ [{emoji1, _}, {emoji2, _} | _] = Pleroma.Emoji.get_all()
+
+ user = insert(:user)
+
+ {:ok, activity} =
+ CommonAPI.post(user, %{status: "foo1", spoiler_text: "title 1 :#{emoji1}:"})
+
+ {:ok, updated} = CommonAPI.update(user, activity, %{status: "updated 2 :#{emoji2}:"})
+
+ updated_object = Object.normalize(updated)
+ assert updated_object.data["content"] == "updated 2 :#{emoji2}:"
+ assert %{^emoji2 => _} = updated_object.data["emoji"]
+ end
+
+ test "updates a post with emoji and federate properly" do
+ [{emoji1, _}, {emoji2, _} | _] = Pleroma.Emoji.get_all()
+
+ user = insert(:user)
+
+ {:ok, activity} =
+ CommonAPI.post(user, %{status: "foo1", spoiler_text: "title 1 :#{emoji1}:"})
+
+ clear_config([:instance, :federating], true)
+
+ with_mock Pleroma.Web.Federator,
+ publish: fn _p -> nil end do
+ {:ok, updated} = CommonAPI.update(user, activity, %{status: "updated 2 :#{emoji2}:"})
+
+ assert updated.data["object"]["content"] == "updated 2 :#{emoji2}:"
+ assert %{^emoji2 => _} = updated.data["object"]["emoji"]
+
+ assert called(Pleroma.Web.Federator.publish(updated))
+ end
+ end
+
+ test "editing a post that copied a remote title with remote emoji should keep that emoji" do
+ remote_emoji_uri = "https://remote.org/emoji.png"
+
+ note =
+ insert(
+ :note,
+ data: %{
+ "summary" => ":remoteemoji:",
+ "emoji" => %{
+ "remoteemoji" => remote_emoji_uri
+ },
+ "tag" => [
+ %{
+ "type" => "Emoji",
+ "name" => "remoteemoji",
+ "icon" => %{"url" => remote_emoji_uri}
+ }
+ ]
+ }
+ )
+
+ note_activity = insert(:note_activity, note: note)
+
+ user = insert(:user)
+
+ {:ok, reply} =
+ CommonAPI.post(user, %{
+ status: "reply",
+ spoiler_text: ":remoteemoji:",
+ in_reply_to_id: note_activity.id
+ })
+
+ assert reply.object.data["emoji"]["remoteemoji"] == remote_emoji_uri
+
+ {:ok, edit} =
+ CommonAPI.update(user, reply, %{status: "reply mew mew", spoiler_text: ":remoteemoji:"})
+
+ edited_note = Pleroma.Object.normalize(edit)
+
+ assert edited_note.data["emoji"]["remoteemoji"] == remote_emoji_uri
+ end
+
+ test "respects MRF" do
+ user = insert(:user)
+
+ clear_config([:mrf, :policies], [Pleroma.Web.ActivityPub.MRF.KeywordPolicy])
+ clear_config([:mrf_keyword, :replace], [{"updated", "mewmew"}])
+
+ {:ok, activity} = CommonAPI.post(user, %{status: "foo1", spoiler_text: "updated 1"})
+ assert Object.normalize(activity).data["summary"] == "mewmew 1"
+
+ {:ok, updated} = CommonAPI.update(user, activity, %{status: "updated 2"})
+
+ updated_object = Object.normalize(updated)
+ assert updated_object.data["content"] == "mewmew 2"
+ assert Map.get(updated_object.data, "summary", "") == ""
+ assert Map.has_key?(updated_object.data, "updated")
+ end
+ end
end
--- /dev/null
+defmodule Pleroma.Web.MastoFEControllerTest do
+ use Pleroma.Web.ConnCase, async: true
+ alias Pleroma.Web.MastodonAPI.AuthController
+
+ describe "index/2 (main page)" do
+ test "GET /web/ (glitch-soc)" do
+ clear_config([:frontends, :mastodon], %{"name" => "mastodon-fe"})
+
+ {:ok, masto_app} = AuthController.local_mastofe_app()
+ user = Pleroma.Factory.insert(:user)
+ token = Pleroma.Factory.insert(:oauth_token, app: masto_app, user: user)
+ %{conn: conn} = oauth_access(["read", "write"], oauth_token: token, user: user)
+
+ resp =
+ conn
+ |> get("/web/getting-started")
+ |> html_response(200)
+
+ assert resp =~ "glitch"
+ end
+
+ test "GET /web/ (fedibird)" do
+ clear_config([:frontends, :mastodon], %{"name" => "fedibird-fe"})
+
+ {:ok, masto_app} = AuthController.local_mastofe_app()
+ user = Pleroma.Factory.insert(:user)
+ token = Pleroma.Factory.insert(:oauth_token, app: masto_app, user: user)
+ %{conn: conn} = oauth_access(["read", "write"], oauth_token: token, user: user)
+
+ resp =
+ conn
+ |> get("/web/getting-started")
+ |> html_response(200)
+
+ refute resp =~ "glitch"
+ end
+ end
+end
import Pleroma.Factory
test "get instance information", %{conn: conn} do
+ clear_config([:instance, :languages], ["en", "ja"])
conn = get(conn, "/api/v1/instance")
assert result = json_response_and_validate_schema(conn, 200)
-
email = Pleroma.Config.get([:instance, :email])
+
thumbnail = Pleroma.Web.Endpoint.url() <> Pleroma.Config.get([:instance, :instance_thumbnail])
background = Pleroma.Web.Endpoint.url() <> Pleroma.Config.get([:instance, :background_image])
},
"stats" => _,
"thumbnail" => from_config_thumbnail,
- "languages" => _,
+ "languages" => ["en", "ja"],
"registrations" => _,
"approval_required" => _,
"poll_limits" => _,
|> json_response_and_validate_schema(422)
end
end
+
+ describe "get status history" do
+ setup do
+ %{conn: build_conn()}
+ end
+
+ test "unedited post", %{conn: conn} do
+ activity = insert(:note_activity)
+
+ conn = get(conn, "/api/v1/statuses/#{activity.id}/history")
+
+ assert [_] = json_response_and_validate_schema(conn, 200)
+ end
+
+ test "edited post", %{conn: conn} do
+ note =
+ insert(
+ :note,
+ data: %{
+ "formerRepresentations" => %{
+ "type" => "OrderedCollection",
+ "orderedItems" => [
+ %{
+ "type" => "Note",
+ "content" => "mew mew 2",
+ "summary" => "title 2"
+ },
+ %{
+ "type" => "Note",
+ "content" => "mew mew 1",
+ "summary" => "title 1"
+ }
+ ],
+ "totalItems" => 2
+ }
+ }
+ )
+
+ activity = insert(:note_activity, note: note)
+
+ conn = get(conn, "/api/v1/statuses/#{activity.id}/history")
+
+ assert [%{"spoiler_text" => "title 1"}, %{"spoiler_text" => "title 2"}, _] =
+ json_response_and_validate_schema(conn, 200)
+ end
+ end
+
+ describe "translating statuses" do
+ setup do
+ clear_config([:translator, :enabled], true)
+ clear_config([:translator, :module], Pleroma.Akkoma.Translators.DeepL)
+ clear_config([:deepl, :api_key], "deepl_api_key")
+ oauth_access(["read:statuses"])
+ end
+
+ test "listing languages", %{conn: conn} do
+ Tesla.Mock.mock_global(fn
+ %{method: :get, url: "https://api-free.deepl.com/v2/languages?type=source"} ->
+ %Tesla.Env{
+ status: 200,
+ body:
+ Jason.encode!([
+ %{language: "en", name: "English"}
+ ])
+ }
+
+ %{method: :get, url: "https://api-free.deepl.com/v2/languages?type=target"} ->
+ %Tesla.Env{
+ status: 200,
+ body:
+ Jason.encode!([
+ %{language: "ja", name: "Japanese"}
+ ])
+ }
+ end)
+
+ conn =
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> get("/api/v1/akkoma/translation/languages")
+
+ response = json_response_and_validate_schema(conn, 200)
+
+ assert %{
+ "source" => [%{"code" => "en", "name" => "English"}],
+ "target" => [%{"code" => "ja", "name" => "Japanese"}]
+ } = response
+ end
+
+ test "should return text and detected language", %{conn: conn} do
+ clear_config([:deepl, :tier], :free)
+
+ Tesla.Mock.mock_global(fn
+ %{method: :post, url: "https://api-free.deepl.com/v2/translate"} ->
+ %Tesla.Env{
+ status: 200,
+ body:
+ Jason.encode!(%{
+ translations: [
+ %{
+ "text" => "Tell me, for whom do you fight?",
+ "detected_source_language" => "ja"
+ }
+ ]
+ })
+ }
+ end)
+
+ user = insert(:user)
+ {:ok, to_translate} = CommonAPI.post(user, %{status: "何のために闘う?"})
+
+ conn =
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> get("/api/v1/statuses/#{to_translate.id}/translations/en")
+
+ response = json_response_and_validate_schema(conn, 200)
+
+ assert response["text"] == "Tell me, for whom do you fight?"
+ assert response["detected_language"] == "ja"
+ end
+
+ test "should not allow translating of statuses you cannot see", %{conn: conn} do
+ clear_config([:deepl, :tier], :free)
+
+ Tesla.Mock.mock_global(fn
+ %{method: :post, url: "https://api-free.deepl.com/v2/translate"} ->
+ %Tesla.Env{
+ status: 200,
+ body:
+ Jason.encode!(%{
+ translations: [
+ %{
+ "text" => "Tell me, for whom do you fight?",
+ "detected_source_language" => "ja"
+ }
+ ]
+ })
+ }
+ end)
+
+ user = insert(:user)
+ {:ok, to_translate} = CommonAPI.post(user, %{status: "何のために闘う?", visibility: "private"})
+
+ conn =
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> get("/api/v1/statuses/#{to_translate.id}/translations/en")
+
+ json_response_and_validate_schema(conn, 404)
+ end
+ end
+
+ describe "get status source" do
+ setup do
+ %{conn: build_conn()}
+ end
+
+ test "it returns the source", %{conn: conn} do
+ user = insert(:user)
+
+ {:ok, activity} = CommonAPI.post(user, %{status: "mew mew #abc", spoiler_text: "#def"})
+
+ conn = get(conn, "/api/v1/statuses/#{activity.id}/source")
+
+ id = activity.id
+
+ assert %{"id" => ^id, "text" => "mew mew #abc", "spoiler_text" => "#def"} =
+ json_response_and_validate_schema(conn, 200)
+ end
+ end
+
+ describe "update status" do
+ setup do
+ oauth_access(["write:statuses"])
+ end
+
+ test "it updates the status" do
+ %{conn: conn, user: user} = oauth_access(["write:statuses", "read:statuses"])
+
+ {:ok, activity} = CommonAPI.post(user, %{status: "mew mew #abc", spoiler_text: "#def"})
+
+ conn
+ |> get("/api/v1/statuses/#{activity.id}")
+ |> json_response_and_validate_schema(200)
+
+ response =
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> put("/api/v1/statuses/#{activity.id}", %{
+ "status" => "edited",
+ "spoiler_text" => "lol"
+ })
+ |> json_response_and_validate_schema(200)
+
+ assert response["content"] == "edited"
+ assert response["spoiler_text"] == "lol"
+
+ response =
+ conn
+ |> get("/api/v1/statuses/#{activity.id}")
+ |> json_response_and_validate_schema(200)
+
+ assert response["content"] == "edited"
+ assert response["spoiler_text"] == "lol"
+ end
+
+ test "it updates the attachments", %{conn: conn, user: user} do
+ attachment = insert(:attachment, user: user)
+ attachment_id = to_string(attachment.id)
+
+ {:ok, activity} = CommonAPI.post(user, %{status: "mew mew #abc", spoiler_text: "#def"})
+
+ response =
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> put("/api/v1/statuses/#{activity.id}", %{
+ "status" => "mew mew #abc",
+ "spoiler_text" => "#def",
+ "media_ids" => [attachment_id]
+ })
+ |> json_response_and_validate_schema(200)
+
+ assert [%{"id" => ^attachment_id}] = response["media_attachments"]
+ end
+
+ test "it does not update visibility", %{conn: conn, user: user} do
+ {:ok, activity} =
+ CommonAPI.post(user, %{
+ status: "mew mew #abc",
+ spoiler_text: "#def",
+ visibility: "private"
+ })
+
+ response =
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> put("/api/v1/statuses/#{activity.id}", %{
+ "status" => "edited",
+ "spoiler_text" => "lol"
+ })
+ |> json_response_and_validate_schema(200)
+
+ assert response["visibility"] == "private"
+ end
+
+ test "it refuses to update when original post is not by the user", %{conn: conn} do
+ another_user = insert(:user)
+
+ {:ok, activity} =
+ CommonAPI.post(another_user, %{status: "mew mew #abc", spoiler_text: "#def"})
+
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> put("/api/v1/statuses/#{activity.id}", %{
+ "status" => "edited",
+ "spoiler_text" => "lol"
+ })
+ |> json_response_and_validate_schema(:forbidden)
+ end
+
+ test "it returns 404 if the user cannot see the post", %{conn: conn} do
+ another_user = insert(:user)
+
+ {:ok, activity} =
+ CommonAPI.post(another_user, %{
+ status: "mew mew #abc",
+ spoiler_text: "#def",
+ visibility: "private"
+ })
+
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> put("/api/v1/statuses/#{activity.id}", %{
+ "status" => "edited",
+ "spoiler_text" => "lol"
+ })
+ |> json_response_and_validate_schema(:not_found)
+ end
+ end
end
test_notifications_rendering([notification], moderator_user, [expected])
end
+ test "Edit notification" do
+ user = insert(:user)
+ repeat_user = insert(:user)
+
+ {:ok, activity} = CommonAPI.post(user, %{status: "mew"})
+ {:ok, _} = CommonAPI.repeat(activity.id, repeat_user)
+ {:ok, update} = CommonAPI.update(user, activity, %{status: "mew mew"})
+
+ user = Pleroma.User.get_by_ap_id(user.ap_id)
+ activity = Pleroma.Activity.normalize(activity)
+ update = Pleroma.Activity.normalize(update)
+
+ {:ok, [notification]} = Notification.create_notifications(update)
+
+ expected = %{
+ id: to_string(notification.id),
+ pleroma: %{is_seen: false, is_muted: false},
+ type: "update",
+ account: AccountView.render("show.json", %{user: user, for: repeat_user}),
+ created_at: Utils.to_masto_date(notification.inserted_at),
+ status: StatusView.render("show.json", %{activity: activity, for: repeat_user})
+ }
+
+ test_notifications_rendering([notification], repeat_user, [expected])
+ end
+
test "muted notification" do
user = insert(:user)
another_user = insert(:user)
assert_schema(status, "Status", Pleroma.Web.ApiSpec.spec())
assert status[:pleroma][:emoji_reactions] == [
- %{name: "☕", count: 2, me: false, url: nil},
+ %{name: "☕", count: 2, me: false, url: nil, account_ids: [other_user.id, user.id]},
%{
count: 2,
me: false,
name: "dinosaur",
- url: "http://localhost:4001/emoji/dino walking.gif"
+ url: "http://localhost:4001/emoji/dino walking.gif",
+ account_ids: [other_user.id, user.id]
},
- %{name: "🍵", count: 1, me: false, url: nil}
+ %{name: "🍵", count: 1, me: false, url: nil, account_ids: [third_user.id]}
]
status = StatusView.render("show.json", activity: activity, for: user)
assert_schema(status, "Status", Pleroma.Web.ApiSpec.spec())
assert status[:pleroma][:emoji_reactions] == [
- %{name: "☕", count: 2, me: true, url: nil},
+ %{name: "☕", count: 2, me: true, url: nil, account_ids: [other_user.id, user.id]},
%{
count: 2,
me: true,
name: "dinosaur",
- url: "http://localhost:4001/emoji/dino walking.gif"
+ url: "http://localhost:4001/emoji/dino walking.gif",
+ account_ids: [other_user.id, user.id]
},
- %{name: "🍵", count: 1, me: false, url: nil}
+ %{name: "🍵", count: 1, me: false, url: nil, account_ids: [third_user.id]}
]
end
status = StatusView.render("show.json", activity: activity, for: user)
assert status[:pleroma][:emoji_reactions] == [
- %{name: "☕", count: 1, me: true, url: nil}
+ %{name: "☕", count: 1, me: true, url: nil, account_ids: [user.id]}
]
end
status = StatusView.render("show.json", activity: activity)
assert status[:pleroma][:emoji_reactions] == [
- %{name: "☕", count: 1, me: false, url: nil}
+ %{name: "☕", count: 1, me: false, url: nil, account_ids: [other_user.id]}
]
status = StatusView.render("show.json", activity: activity, for: user)
status = StatusView.render("show.json", activity: activity)
assert status[:pleroma][:emoji_reactions] == [
- %{name: "☕", count: 2, me: false, url: nil}
+ %{
+ name: "☕",
+ count: 2,
+ me: false,
+ url: nil,
+ account_ids: [third_user.id, other_user.id]
+ }
]
status = StatusView.render("show.json", activity: activity, for: user)
assert status[:pleroma][:emoji_reactions] == [
- %{name: "☕", count: 1, me: false, url: nil}
+ %{name: "☕", count: 1, me: false, url: nil, account_ids: [third_user.id]}
]
status = StatusView.render("show.json", activity: activity, for: other_user)
assert status[:pleroma][:emoji_reactions] == [
- %{name: "☕", count: 1, me: true, url: nil}
+ %{name: "☕", count: 1, me: true, url: nil, account_ids: [other_user.id]}
]
end
content: HTML.filter_tags(object_data["content"]),
text: nil,
created_at: created_at,
+ edited_at: nil,
reblogs_count: 0,
replies_count: 0,
favourites_count: 0,
spoiler_text: HTML.filter_tags(object_data["summary"]),
visibility: "public",
media_attachments: [],
+ emoji_reactions: [],
mentions: [],
tags: [
%{
assert is_nil(status.quote)
end
+ test "a quote from a user we block" do
+ user = insert(:user)
+ other_user = insert(:user)
+ blocked_user = insert(:user)
+
+ {:ok, _relationship} = User.block(user, blocked_user)
+
+ {:ok, activity} = CommonAPI.post(blocked_user, %{status: ":< i am ANGERY"})
+ {:ok, quote_activity} = CommonAPI.post(other_user, %{status: "hehe", quote_id: activity.id})
+
+ status = StatusView.render("show.json", %{activity: quote_activity, for: user})
+ assert is_nil(status.quote)
+ end
+
+ test "a quote from a user we mute" do
+ user = insert(:user)
+ other_user = insert(:user)
+ blocked_user = insert(:user)
+
+ {:ok, _relationship} = User.mute(user, blocked_user)
+
+ {:ok, activity} = CommonAPI.post(blocked_user, %{status: ":< i am ANGERY"})
+ {:ok, quote_activity} = CommonAPI.post(other_user, %{status: "hehe", quote_id: activity.id})
+
+ status = StatusView.render("show.json", %{activity: quote_activity, for: user})
+ assert is_nil(status.quote)
+ end
+
test "contains mentions" do
user = insert(:user)
mentioned = insert(:user)
status = StatusView.render("show.json", activity: visible, for: poster)
assert status.pleroma.parent_visible
end
+
+ test "it shows edited_at" do
+ poster = insert(:user)
+
+ {:ok, post} = CommonAPI.post(poster, %{status: "hey"})
+
+ status = StatusView.render("show.json", activity: post)
+ refute status.edited_at
+
+ {:ok, _} = CommonAPI.update(poster, post, %{status: "mew mew"})
+ edited = Pleroma.Activity.normalize(post)
+
+ status = StatusView.render("show.json", activity: edited)
+ assert status.edited_at
+ end
+
+ test "with a source object" do
+ note =
+ insert(:note,
+ data: %{"source" => %{"content" => "object source", "mediaType" => "text/markdown"}}
+ )
+
+ activity = insert(:note_activity, note: note)
+
+ status = StatusView.render("show.json", activity: activity, with_source: true)
+ assert status.text == "object source"
+ end
+
+ describe "source.json" do
+ test "with a source object, renders both source and content type" do
+ note =
+ insert(:note,
+ data: %{"source" => %{"content" => "object source", "mediaType" => "text/markdown"}}
+ )
+
+ activity = insert(:note_activity, note: note)
+
+ status = StatusView.render("source.json", activity: activity)
+ assert status.text == "object source"
+ assert status.content_type == "text/markdown"
+ end
+
+ test "with a source string, renders source and put text/plain as the content type" do
+ note = insert(:note, data: %{"source" => "string source"})
+ activity = insert(:note_activity, note: note)
+
+ status = StatusView.render("source.json", activity: activity)
+ assert status.text == "string source"
+ assert status.content_type == "text/plain"
+ end
+ end
end
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Metadata.UtilsTest do
- use Pleroma.DataCase, async: true
+ use Pleroma.DataCase, async: false
import Pleroma.Factory
alias Pleroma.Web.Metadata.Utils
assert Utils.scrub_html_and_truncate(note) == "Pleroma's really cool!"
end
+
+ test "it does not return old content after editing" do
+ user = insert(:user)
+
+ {:ok, activity} = Pleroma.Web.CommonAPI.post(user, %{status: "mew mew #def"})
+
+ object = Pleroma.Object.normalize(activity)
+ assert Utils.scrub_html_and_truncate(object) == "mew mew #def"
+
+ {:ok, update} = Pleroma.Web.CommonAPI.update(user, activity, %{status: "mew mew #abc"})
+ update = Pleroma.Activity.normalize(update)
+ object = Pleroma.Object.normalize(update)
+ assert Utils.scrub_html_and_truncate(object) == "mew mew #abc"
+ end
end
describe "scrub_html_and_truncate/2" do
assert html_response(conn, 200) =~ ~s(type="submit")
end
+ test "allows access if the user has a prior authorization but is authenticated with another client",
+ %{
+ app: app,
+ conn: conn
+ } do
+ user = insert(:user)
+ token = insert(:oauth_token, app: app, user: user)
+
+ other_app = insert(:oauth_app, redirect_uris: "https://other_redirect.url")
+ authorization = insert(:oauth_authorization, user: user, app: other_app)
+ _reusable_token = insert(:oauth_token, app: other_app, user: user)
+
+ conn =
+ conn
+ |> AuthHelper.put_session_token(token.token)
+ |> AuthHelper.put_session_user(user.id)
+ |> get(
+ "/oauth/authorize",
+ %{
+ "response_type" => "code",
+ "client_id" => other_app.client_id,
+ "redirect_uri" => OAuthController.default_redirect_uri(other_app),
+ "scope" => "read"
+ }
+ )
+
+ assert URI.decode(redirected_to(conn)) ==
+ "https://other_redirect.url?code=#{authorization.token}"
+ end
+
+ test "renders login page if the user has an authorization but no token",
+ %{
+ app: app,
+ conn: conn
+ } do
+ user = insert(:user)
+ token = insert(:oauth_token, app: app, user: user)
+
+ other_app = insert(:oauth_app, redirect_uris: "https://other_redirect.url")
+ _authorization = insert(:oauth_authorization, user: user, app: other_app)
+
+ conn =
+ conn
+ |> AuthHelper.put_session_token(token.token)
+ |> AuthHelper.put_session_user(user.id)
+ |> get(
+ "/oauth/authorize",
+ %{
+ "response_type" => "code",
+ "client_id" => other_app.client_id,
+ "redirect_uri" => OAuthController.default_redirect_uri(other_app),
+ "scope" => "read"
+ }
+ )
+
+ assert html_response(conn, 200) =~ ~s(type="submit")
+ end
+
+ test "does not reuse other people's tokens",
+ %{
+ app: app,
+ conn: conn
+ } do
+ user = insert(:user)
+ other_user = insert(:user)
+ token = insert(:oauth_token, app: app, user: user)
+
+ other_app = insert(:oauth_app, redirect_uris: "https://other_redirect.url")
+ _authorization = insert(:oauth_authorization, user: other_user, app: other_app)
+ _reusable_token = insert(:oauth_token, app: other_app, user: other_user)
+
+ conn =
+ conn
+ |> AuthHelper.put_session_token(token.token)
+ |> AuthHelper.put_session_user(user.id)
+ |> get(
+ "/oauth/authorize",
+ %{
+ "response_type" => "code",
+ "client_id" => other_app.client_id,
+ "redirect_uri" => OAuthController.default_redirect_uri(other_app),
+ "scope" => "read"
+ }
+ )
+
+ assert html_response(conn, 200) =~ ~s(type="submit")
+ end
+
+ test "does not reuse expired tokens",
+ %{
+ app: app,
+ conn: conn
+ } do
+ user = insert(:user)
+ token = insert(:oauth_token, app: app, user: user)
+
+ other_app = insert(:oauth_app, redirect_uris: "https://other_redirect.url")
+ _authorization = insert(:oauth_authorization, user: user, app: other_app)
+
+ _reusable_token =
+ insert(:oauth_token,
+ app: other_app,
+ user: user,
+ valid_until: NaiveDateTime.add(NaiveDateTime.utc_now(), -100)
+ )
+
+ conn =
+ conn
+ |> AuthHelper.put_session_token(token.token)
+ |> AuthHelper.put_session_user(user.id)
+ |> get(
+ "/oauth/authorize",
+ %{
+ "response_type" => "code",
+ "client_id" => other_app.client_id,
+ "redirect_uri" => OAuthController.default_redirect_uri(other_app),
+ "scope" => "read"
+ }
+ )
+
+ assert html_response(conn, 200) =~ ~s(type="submit")
+ end
+
test "with existing authentication and non-OOB `redirect_uri`, redirects to app with `token` and `state` params",
%{
app: app,
user = insert(:user)
other_user = insert(:user)
- {:ok, activity} = CommonAPI.post(user, %{status: "#cofe"})
+ note = insert(:note, user: user, data: %{"reactions" => [["👍", [other_user.ap_id], nil]]})
+ activity = insert(:note_activity, note: note, user: user)
result =
conn
|> assign(:user, other_user)
|> assign(:token, insert(:oauth_token, user: other_user, scopes: ["write:statuses"]))
- |> put("/api/v1/pleroma/statuses/#{activity.id}/reactions/☕")
+ |> put("/api/v1/pleroma/statuses/#{activity.id}/reactions/\u26A0")
|> json_response_and_validate_schema(200)
- # We return the status, but this our implementation detail.
assert %{"id" => id} = result
assert to_string(activity.id) == id
assert result["pleroma"]["emoji_reactions"] == [
- %{"name" => "☕", "count" => 1, "me" => true, "url" => nil}
+ %{
+ "name" => "👍",
+ "count" => 1,
+ "me" => true,
+ "url" => nil,
+ "account_ids" => [other_user.id]
+ },
+ %{
+ "name" => "\u26A0\uFE0F",
+ "count" => 1,
+ "me" => true,
+ "url" => nil,
+ "account_ids" => [other_user.id]
+ }
]
{:ok, activity} = CommonAPI.post(user, %{status: "#cofe"})
ObanHelpers.perform_all()
+
# Reacting with a custom emoji
result =
conn
|> put("/api/v1/pleroma/statuses/#{activity.id}/reactions/:dinosaur:")
|> json_response_and_validate_schema(200)
- # We return the status, but this our implementation detail.
assert %{"id" => id} = result
assert to_string(activity.id) == id
"name" => "dinosaur",
"count" => 1,
"me" => true,
- "url" => "http://localhost:4001/emoji/dino walking.gif"
+ "url" => "http://localhost:4001/emoji/dino walking.gif",
+ "account_ids" => [other_user.id]
+ }
+ ]
+
+ # Reacting with a remote emoji
+ note =
+ insert(:note,
+ user: user,
+ data: %{"reactions" => [["wow", [other_user.ap_id], "https://remote/emoji/wow"]]}
+ )
+
+ activity = insert(:note_activity, note: note, user: user)
+
+ result =
+ conn
+ |> assign(:user, user)
+ |> assign(:token, insert(:oauth_token, user: user, scopes: ["write:statuses"]))
+ |> put("/api/v1/pleroma/statuses/#{activity.id}/reactions/:wow@remote:")
+ |> json_response(200)
+
+ assert result["pleroma"]["emoji_reactions"] == [
+ %{
+ "name" => "wow@remote",
+ "count" => 2,
+ "me" => true,
+ "url" => "https://remote/emoji/wow",
+ "account_ids" => [user.id, other_user.id]
}
]
+ # Reacting with a remote custom emoji that hasn't been reacted with yet
+ note =
+ insert(:note,
+ user: user
+ )
+
+ activity = insert(:note_activity, note: note, user: user)
+
+ assert conn
+ |> assign(:user, user)
+ |> assign(:token, insert(:oauth_token, user: user, scopes: ["write:statuses"]))
+ |> put("/api/v1/pleroma/statuses/#{activity.id}/reactions/:wow@remote:")
+ |> json_response(400)
+
# Reacting with a non-emoji
assert conn
|> assign(:user, other_user)
user = insert(:user)
other_user = insert(:user)
- {:ok, activity} = CommonAPI.post(user, %{status: "#cofe"})
+ note =
+ insert(:note,
+ user: user,
+ data: %{"reactions" => [["wow", [user.ap_id], "https://remote/emoji/wow"]]}
+ )
+
+ activity = insert(:note_activity, note: note, user: user)
+
+ ObanHelpers.perform_all()
+
{:ok, _reaction_activity} = CommonAPI.react_with_emoji(activity.id, other_user, "☕")
{:ok, _reaction_activity} = CommonAPI.react_with_emoji(activity.id, other_user, ":dinosaur:")
+ {:ok, _reaction_activity} =
+ CommonAPI.react_with_emoji(activity.id, other_user, ":wow@remote:")
+
ObanHelpers.perform_all()
result =
object = Object.get_by_ap_id(activity.data["object"])
- assert object.data["reaction_count"] == 0
+ assert object.data["reaction_count"] == 2
+
+ # Remove custom remote emoji
+ result =
+ conn
+ |> assign(:user, other_user)
+ |> assign(:token, insert(:oauth_token, user: other_user, scopes: ["write:statuses"]))
+ |> delete("/api/v1/pleroma/statuses/#{activity.id}/reactions/:wow@remote:")
+ |> json_response(200)
+
+ assert result["pleroma"]["emoji_reactions"] == [
+ %{
+ "name" => "wow@remote",
+ "count" => 1,
+ "me" => false,
+ "url" => "https://remote/emoji/wow",
+ "account_ids" => [user.id]
+ }
+ ]
+
+ # Remove custom remote emoji that hasn't been reacted with yet
+ assert conn
+ |> assign(:user, other_user)
+ |> assign(:token, insert(:oauth_token, user: other_user, scopes: ["write:statuses"]))
+ |> delete("/api/v1/pleroma/statuses/#{activity.id}/reactions/:zoop@remote:")
+ |> json_response(400)
end
test "GET /api/v1/pleroma/statuses/:id/reactions", %{conn: conn} do
test "aliases redirected /object endpoints", _ do
obj = insert(:note)
act = insert(:note_activity, note: obj)
- params = %{"actor" => "http://mastodon.example.org/users/admin"}
+ params = %{"actor" => "someparam"}
path = URI.parse(obj.data["id"]).path
conn = build_conn(:get, path, params)
- assert ["/notice/#{act.id}"] == HTTPSignaturePlug.route_aliases(conn)
+
+ assert ["/notice/#{act.id}", "/notice/#{act.id}?actor=someparam"] ==
+ HTTPSignaturePlug.route_aliases(conn)
end
end
end
Streamer.get_topic_and_add_socket("user", user, oauth_token)
{:ok, activity} = CommonAPI.post(user, %{status: "hey"})
- assert_receive {:render_with_user, _, _, ^activity}
+ stream_name = "user:#{user.id}"
+ assert_receive {:render_with_user, _, _, ^activity, ^stream_name}
refute Streamer.filtered_by_user?(user, activity)
end
{:ok, activity} = CommonAPI.post(other_user, %{status: "hey"})
{:ok, announce} = CommonAPI.repeat(activity.id, user)
- assert_receive {:render_with_user, Pleroma.Web.StreamerView, "update.json", ^announce}
+ stream_name = "user:#{user.id}"
+
+ assert_receive {:render_with_user, Pleroma.Web.StreamerView, "update.json", ^announce,
+ ^stream_name}
+
refute Streamer.filtered_by_user?(user, announce)
end
{:ok, %Pleroma.Activity{data: _data, local: false} = announce} =
Pleroma.Web.ActivityPub.Transmogrifier.handle_incoming(data)
- assert_receive {:render_with_user, Pleroma.Web.StreamerView, "update.json", ^announce}
+ stream_name = "user:#{user.id}"
+
+ assert_receive {:render_with_user, Pleroma.Web.StreamerView, "update.json", ^announce,
+ ^stream_name}
+
refute Streamer.filtered_by_user?(user, announce)
end
Streamer.get_topic_and_add_socket("user", user, oauth_token)
Streamer.stream("user", notify)
- assert_receive {:render_with_user, _, _, ^notify}
+ assert_receive {:render_with_user, _, _, ^notify, "user"}
refute Streamer.filtered_by_user?(user, notify)
end
Streamer.get_topic_and_add_socket("user:notification", user, oauth_token)
Streamer.stream("user:notification", notify)
- assert_receive {:render_with_user, _, _, ^notify}
+ assert_receive {:render_with_user, _, _, ^notify, "user:notification"}
refute Streamer.filtered_by_user?(user, notify)
end
Streamer.get_topic_and_add_socket("user:notification", user, oauth_token)
{:ok, favorite_activity} = CommonAPI.favorite(user2, activity.id)
- assert_receive {:render_with_user, _, "notification.json", notif}
+ assert_receive {:render_with_user, _, "notification.json", notif, "user:notification"}
assert notif.activity.id == favorite_activity.id
refute Streamer.filtered_by_user?(user, notif)
end
Streamer.get_topic_and_add_socket("user:notification", user, oauth_token)
{:ok, _follower, _followed, follow_activity} = CommonAPI.follow(user2, user)
- assert_receive {:render_with_user, _, "notification.json", notif}
+ assert_receive {:render_with_user, _, "notification.json", notif, "user:notification"}
assert notif.activity.id == follow_activity.id
refute Streamer.filtered_by_user?(user, notif)
end
"state" => "follow_accept"
} = Jason.decode!(payload)
end
+
+ test "it streams edits in the 'user' stream", %{user: user, token: oauth_token} do
+ sender = insert(:user)
+ {:ok, _, _, _} = CommonAPI.follow(user, sender)
+
+ {:ok, activity} = CommonAPI.post(sender, %{status: "hey"})
+
+ Streamer.get_topic_and_add_socket("user", user, oauth_token)
+ {:ok, edited} = CommonAPI.update(sender, activity, %{status: "mew mew"})
+ create = Pleroma.Activity.get_create_by_object_ap_id_with_object(activity.object.data["id"])
+
+ stream = "user:#{user.id}"
+ assert_receive {:render_with_user, _, "status_update.json", ^create, ^stream}
+ refute Streamer.filtered_by_user?(user, edited)
+ end
+
+ test "it streams own edits in the 'user' stream", %{user: user, token: oauth_token} do
+ {:ok, activity} = CommonAPI.post(user, %{status: "hey"})
+
+ Streamer.get_topic_and_add_socket("user", user, oauth_token)
+ {:ok, edited} = CommonAPI.update(user, activity, %{status: "mew mew"})
+ create = Pleroma.Activity.get_create_by_object_ap_id_with_object(activity.object.data["id"])
+
+ stream = "user:#{user.id}"
+ assert_receive {:render_with_user, _, "status_update.json", ^create, ^stream}
+ refute Streamer.filtered_by_user?(user, edited)
+ end
end
describe "public streams" do
Streamer.get_topic_and_add_socket("public", user, oauth_token)
{:ok, activity} = CommonAPI.post(other_user, %{status: "Test"})
- assert_receive {:render_with_user, _, _, ^activity}
+ assert_receive {:render_with_user, _, _, ^activity, "public"}
refute Streamer.filtered_by_user?(other_user, activity)
end
assert_receive {:text, event}
assert %{"event" => "delete", "payload" => ^activity_id} = Jason.decode!(event)
end
+
+ test "it streams edits in the 'public' stream" do
+ sender = insert(:user)
+
+ Streamer.get_topic_and_add_socket("public", nil, nil)
+ {:ok, activity} = CommonAPI.post(sender, %{status: "hey"})
+ assert_receive {:text, _}
+
+ {:ok, edited} = CommonAPI.update(sender, activity, %{status: "mew mew"})
+
+ edited = Pleroma.Activity.normalize(edited)
+
+ %{id: activity_id} = Pleroma.Activity.get_create_by_object_ap_id(edited.object.data["id"])
+
+ assert_receive {:text, event}
+ assert %{"event" => "status.update", "payload" => payload} = Jason.decode!(event)
+ assert %{"id" => ^activity_id} = Jason.decode!(payload)
+ refute Streamer.filtered_by_user?(sender, edited)
+ end
+
+ test "it streams multiple edits in the 'public' stream correctly" do
+ sender = insert(:user)
+
+ Streamer.get_topic_and_add_socket("public", nil, nil)
+ {:ok, activity} = CommonAPI.post(sender, %{status: "hey"})
+ assert_receive {:text, _}
+
+ {:ok, edited} = CommonAPI.update(sender, activity, %{status: "mew mew"})
+
+ edited = Pleroma.Activity.normalize(edited)
+
+ %{id: activity_id} = Pleroma.Activity.get_create_by_object_ap_id(edited.object.data["id"])
+
+ assert_receive {:text, event}
+ assert %{"event" => "status.update", "payload" => payload} = Jason.decode!(event)
+ assert %{"id" => ^activity_id} = Jason.decode!(payload)
+ refute Streamer.filtered_by_user?(sender, edited)
+
+ {:ok, edited} = CommonAPI.update(sender, activity, %{status: "mew mew 2"})
+
+ edited = Pleroma.Activity.normalize(edited)
+
+ %{id: activity_id} = Pleroma.Activity.get_create_by_object_ap_id(edited.object.data["id"])
+ assert_receive {:text, event}
+ assert %{"event" => "status.update", "payload" => payload} = Jason.decode!(event)
+ assert %{"id" => ^activity_id, "content" => "mew mew 2"} = Jason.decode!(payload)
+ refute Streamer.filtered_by_user?(sender, edited)
+ end
end
describe "thread_containment/2" do
Streamer.get_topic_and_add_socket("public", user, oauth_token)
Streamer.stream("public", activity)
- assert_receive {:render_with_user, _, _, ^activity}
+ assert_receive {:render_with_user, _, _, ^activity, "public"}
assert Streamer.filtered_by_user?(user, activity)
end
Streamer.get_topic_and_add_socket("public", user, oauth_token)
Streamer.stream("public", activity)
- assert_receive {:render_with_user, _, _, ^activity}
+ assert_receive {:render_with_user, _, _, ^activity, "public"}
refute Streamer.filtered_by_user?(user, activity)
end
Streamer.get_topic_and_add_socket("public", user, oauth_token)
Streamer.stream("public", activity)
- assert_receive {:render_with_user, _, _, ^activity}
+ assert_receive {:render_with_user, _, _, ^activity, "public"}
refute Streamer.filtered_by_user?(user, activity)
end
end
Streamer.get_topic_and_add_socket("public", user, oauth_token)
{:ok, activity} = CommonAPI.post(blocked_user, %{status: "Test"})
- assert_receive {:render_with_user, _, _, ^activity}
+ assert_receive {:render_with_user, _, _, ^activity, "public"}
assert Streamer.filtered_by_user?(user, activity)
end
{:ok, activity_one} = CommonAPI.post(friend, %{status: "hey! @#{blockee.nickname}"})
- assert_receive {:render_with_user, _, _, ^activity_one}
+ assert_receive {:render_with_user, _, _, ^activity_one, "public"}
assert Streamer.filtered_by_user?(blocker, activity_one)
{:ok, activity_two} = CommonAPI.post(blockee, %{status: "hey! @#{friend.nickname}"})
- assert_receive {:render_with_user, _, _, ^activity_two}
+ assert_receive {:render_with_user, _, _, ^activity_two, "public"}
assert Streamer.filtered_by_user?(blocker, activity_two)
{:ok, activity_three} = CommonAPI.post(blockee, %{status: "hey! @#{blocker.nickname}"})
- assert_receive {:render_with_user, _, _, ^activity_three}
+ assert_receive {:render_with_user, _, _, ^activity_three, "public"}
assert Streamer.filtered_by_user?(blocker, activity_three)
end
end
visibility: "private"
})
- assert_receive {:render_with_user, _, _, ^activity}
+ stream_name = "list:#{list.id}"
+ assert_receive {:render_with_user, _, _, ^activity, ^stream_name}
refute Streamer.filtered_by_user?(user_a, activity)
end
end
Streamer.get_topic_and_add_socket("user", user1, user1_token)
{:ok, announce_activity} = CommonAPI.repeat(create_activity.id, user2)
- assert_receive {:render_with_user, _, _, ^announce_activity}
+ stream_name = "user:#{user1.id}"
+ assert_receive {:render_with_user, _, _, ^announce_activity, ^stream_name}
assert Streamer.filtered_by_user?(user1, announce_activity)
end
Streamer.get_topic_and_add_socket("user", user1, user1_token)
{:ok, _announce_activity} = CommonAPI.repeat(create_activity.id, user2)
- assert_receive {:render_with_user, _, "notification.json", notif}
+ assert_receive {:render_with_user, _, "notification.json", notif, "user"}
assert Streamer.filtered_by_user?(user1, notif)
end
Streamer.get_topic_and_add_socket("user", user1, user1_token)
{:ok, _favorite_activity} = CommonAPI.favorite(user2, create_activity.id)
- assert_receive {:render_with_user, _, "notification.json", notif}
+ assert_receive {:render_with_user, _, "notification.json", notif, "user"}
refute Streamer.filtered_by_user?(user1, notif)
end
end
{:ok, activity} = CommonAPI.post(user, %{status: "super hot take"})
{:ok, _} = CommonAPI.add_mute(user2, activity)
- assert_receive {:render_with_user, _, _, ^activity}
+ stream_name = "user:#{user2.id}"
+ assert_receive {:render_with_user, _, _, ^activity, ^stream_name}
assert Streamer.filtered_by_user?(user2, activity)
end
end
})
create_activity_id = create_activity.id
- assert_receive {:render_with_user, _, _, ^create_activity}
+ stream_name = "direct:#{user.id}"
+ assert_receive {:render_with_user, _, _, ^create_activity, ^stream_name}
assert_receive {:text, received_conversation1}
assert %{"event" => "conversation", "payload" => _} = Jason.decode!(received_conversation1)
visibility: "direct"
})
- assert_receive {:render_with_user, _, _, ^create_activity}
- assert_receive {:render_with_user, _, _, ^create_activity2}
+ stream_name = "direct:#{user.id}"
+ assert_receive {:render_with_user, _, _, ^create_activity, ^stream_name}
+ assert_receive {:render_with_user, _, _, ^create_activity2, ^stream_name}
assert_receive {:text, received_conversation1}
assert %{"event" => "conversation", "payload" => _} = Jason.decode!(received_conversation1)
assert_receive {:text, received_conversation1}
assert last_status["id"] == to_string(create_activity.id)
end
end
+
+ describe "stop streaming if token got revoked" do
+ setup do
+ child_proc = fn start, finalize ->
+ fn ->
+ start.()
+
+ receive do
+ {StreamerTest, :ready} ->
+ assert_receive {:render_with_user, _, "update.json", _, _}
+
+ receive do
+ {StreamerTest, :revoked} -> finalize.()
+ end
+ end
+ end
+ end
+
+ starter = fn user, token ->
+ fn -> Streamer.get_topic_and_add_socket("user", user, token) end
+ end
+
+ hit = fn -> assert_receive :close end
+ miss = fn -> refute_receive :close end
+
+ send_all = fn tasks, thing -> Enum.each(tasks, &send(&1.pid, thing)) end
+
+ %{
+ child_proc: child_proc,
+ starter: starter,
+ hit: hit,
+ miss: miss,
+ send_all: send_all
+ }
+ end
+
+ test "do not revoke other tokens", %{
+ child_proc: child_proc,
+ starter: starter,
+ hit: hit,
+ miss: miss,
+ send_all: send_all
+ } do
+ %{user: user, token: token} = oauth_access(["read"])
+ %{token: token2} = oauth_access(["read"], user: user)
+ %{user: user2, token: user2_token} = oauth_access(["read"])
+
+ post_user = insert(:user)
+ CommonAPI.follow(user, post_user)
+ CommonAPI.follow(user2, post_user)
+
+ tasks = [
+ Task.async(child_proc.(starter.(user, token), hit)),
+ Task.async(child_proc.(starter.(user, token2), miss)),
+ Task.async(child_proc.(starter.(user2, user2_token), miss))
+ ]
+
+ {:ok, _} =
+ CommonAPI.post(post_user, %{
+ status: "hi"
+ })
+
+ send_all.(tasks, {StreamerTest, :ready})
+
+ Pleroma.Web.OAuth.Token.Strategy.Revoke.revoke(token)
+
+ send_all.(tasks, {StreamerTest, :revoked})
+
+ Enum.each(tasks, &Task.await/1)
+ end
+
+ test "revoke all streams for this token", %{
+ child_proc: child_proc,
+ starter: starter,
+ hit: hit,
+ send_all: send_all
+ } do
+ %{user: user, token: token} = oauth_access(["read"])
+
+ post_user = insert(:user)
+ CommonAPI.follow(user, post_user)
+
+ tasks = [
+ Task.async(child_proc.(starter.(user, token), hit)),
+ Task.async(child_proc.(starter.(user, token), hit))
+ ]
+
+ {:ok, _} =
+ CommonAPI.post(post_user, %{
+ status: "hi"
+ })
+
+ send_all.(tasks, {StreamerTest, :ready})
+
+ Pleroma.Web.OAuth.Token.Strategy.Revoke.revoke(token)
+
+ send_all.(tasks, {StreamerTest, :revoked})
+
+ Enum.each(tasks, &Task.await/1)
+ end
+ end
end
end
end
+ describe "POST /main/ostatus - remote_subscribe/2 - with statuses" do
+ setup do: clear_config([:instance, :federating], true)
+
+ test "renders subscribe form", %{conn: conn} do
+ user = insert(:user)
+ status = insert(:note_activity, %{user: user})
+ status_id = status.id
+
+ assert is_binary(status_id)
+
+ response =
+ conn
+ |> post("/main/ostatus", %{"status_id" => status_id, "profile" => ""})
+ |> response(:ok)
+
+ refute response =~ "Could not find status"
+ assert response =~ "Interacting with"
+ end
+
+ test "renders subscribe form with error when status not found", %{conn: conn} do
+ response =
+ conn
+ |> post("/main/ostatus", %{"status_id" => "somerandomid", "profile" => ""})
+ |> response(:ok)
+
+ assert response =~ "Could not find status"
+ refute response =~ "Interacting with"
+ end
+
+ test "it redirect to webfinger url", %{conn: conn} do
+ user = insert(:user)
+ status = insert(:note_activity, %{user: user})
+ status_id = status.id
+ status_ap_id = status.data["object"]
+
+ assert is_binary(status_id)
+ assert is_binary(status_ap_id)
+
+ user2 = insert(:user, ap_id: "shp@social.heldscal.la")
+
+ conn =
+ conn
+ |> post("/main/ostatus", %{
+ "status" => %{"status_id" => status_id, "profile" => user2.ap_id}
+ })
+
+ assert redirected_to(conn) ==
+ "https://social.heldscal.la/main/ostatussub?profile=#{status_ap_id}"
+ end
+
+ test "it renders form with error when status not found", %{conn: conn} do
+ user2 = insert(:user, ap_id: "shp@social.heldscal.la")
+
+ response =
+ conn
+ |> post("/main/ostatus", %{
+ "status" => %{"status_id" => "somerandomid", "profile" => user2.ap_id}
+ })
+ |> response(:ok)
+
+ assert response =~ "Something went wrong."
+ end
+ end
+
+ describe "GET /main/ostatus - show_subscribe_form/2" do
+ setup do: clear_config([:instance, :federating], true)
+
+ test "it works with users", %{conn: conn} do
+ user = insert(:user)
+
+ response =
+ conn
+ |> get("/main/ostatus", %{"nickname" => user.nickname})
+ |> response(:ok)
+
+ refute response =~ "Could not find user"
+ assert response =~ "Remotely follow #{user.nickname}"
+ end
+
+ test "it works with statuses", %{conn: conn} do
+ user = insert(:user)
+ status = insert(:note_activity, %{user: user})
+ status_id = status.id
+
+ assert is_binary(status_id)
+
+ response =
+ conn
+ |> get("/main/ostatus", %{"status_id" => status_id})
+ |> response(:ok)
+
+ refute response =~ "Could not find status"
+ assert response =~ "Interacting with"
+ end
+ end
+
test "it returns new captcha", %{conn: conn} do
with_mock Pleroma.Captcha,
new: fn -> "test_captcha" end do
}
end
+ def attachment_factory(attrs \\ %{}) do
+ user = attrs[:user] || insert(:user)
+
+ data =
+ attachment_data(user.ap_id, nil)
+ |> Map.put("id", Pleroma.Web.ActivityPub.Utils.generate_object_id())
+
+ %Pleroma.Object{
+ data: merge_attributes(data, Map.get(attrs, :data, %{}))
+ }
+ end
+
def attachment_note_factory(attrs \\ %{}) do
user = attrs[:user] || insert(:user)
{length, attrs} = Map.pop(attrs, :length, 1)
}}
end
+ def get(
+ "http://mastodon.example.org/users/admin/statuses/99512778738411822/replies?min_id=99512778738411824&page=true",
+ _,
+ _,
+ _
+ ) do
+ {:ok, %Tesla.Env{status: 404, body: ""}}
+ end
+
def get("http://mastodon.example.org/users/relay", _, _, [
{"accept", "application/activity+json"}
]) do
defmodule Pleroma.Integration.WebsocketClient do
# https://github.com/phoenixframework/phoenix/blob/master/test/support/websocket_client.exs
+ use WebSockex
+
@doc """
Starts the WebSocket server for given ws URL. Received Socket.Message's
are forwarded to the sender pid
"""
def start_link(sender, url, headers \\ []) do
- :crypto.start()
- :ssl.start()
-
- :websocket_client.start_link(
- String.to_charlist(url),
+ WebSockex.start_link(
+ url,
__MODULE__,
- [sender],
+ %{sender: sender},
extra_headers: headers
)
end
end
@doc false
- def init([sender], _conn_state) do
- {:ok, %{sender: sender}}
+ @impl true
+ def handle_frame(frame, state) do
+ send(state.sender, frame)
+ {:ok, state}
end
- @doc false
- def websocket_handle(frame, _conn_state, state) do
- send(state.sender, frame)
+ @impl true
+ def handle_disconnect(conn_status, state) do
+ send(state.sender, {:close, conn_status})
{:ok, state}
end
@doc false
- def websocket_info({:text, msg}, _conn_state, state) do
+ @impl true
+ def handle_info({:text, msg}, state) do
{:reply, {:text, msg}, state}
end
- def websocket_info(:close, _conn_state, _state) do
+ @impl true
+ def handle_info(:close, _state) do
{:close, <<>>, "done"}
end
@doc false
- def websocket_terminate(_reason, _conn_state, _state) do
+ @impl true
+ def terminate(_reason, _state) do
:ok
end
end