ForForkMerge #2

Merged
sliver merged 185 commits from ForForkMerge into stable 2024-03-31 06:59:36 +00:00
196 changed files with 4807 additions and 1431 deletions

View file

@ -1,3 +1,14 @@
[ [
inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}", "priv/repo/migrations/*.exs", "priv/repo/optional_migrations/**/*.exs", "priv/scrubbers/*.ex"] import_deps: [:ecto, :ecto_sql, :phoenix],
subdirectories: ["priv/*/migrations"],
plugins: [Phoenix.LiveView.HTMLFormatter],
inputs: [
"mix.exs",
"*.{heex,ex,exs}",
"{config,lib,test}/**/*.{heex,ex,exs}",
"priv/*/seeds.exs",
"priv/repo/migrations/*.exs",
"priv/repo/optional_migrations/**/*.exs",
"priv/scrubbers/*.ex"
]
] ]

View file

@ -37,7 +37,7 @@ variables:
pipeline: pipeline:
# Canonical amd64 # Canonical amd64
debian-bookworm: debian-bookworm:
image: hexpm/elixir:1.15.4-erlang-25.3.2.5-debian-bookworm-20230612 image: hexpm/elixir:1.15.4-erlang-26.0.2-debian-bookworm-20230612
<<: *on-release <<: *on-release
environment: environment:
MIX_ENV: prod MIX_ENV: prod
@ -66,7 +66,7 @@ pipeline:
- /bin/sh /entrypoint.sh - /bin/sh /entrypoint.sh
debian-bullseye: debian-bullseye:
image: hexpm/elixir:1.15.4-erlang-25.3.2.5-debian-bullseye-20230612 image: hexpm/elixir:1.15.4-erlang-26.0.2-debian-bullseye-20230612
<<: *on-release <<: *on-release
environment: environment:
MIX_ENV: prod MIX_ENV: prod
@ -94,7 +94,7 @@ pipeline:
# Canonical amd64-musl # Canonical amd64-musl
musl: musl:
image: hexpm/elixir:1.14.3-erlang-25.2.2-alpine-3.18.0 image: hexpm/elixir:1.15.4-erlang-26.0.2-alpine-3.18.2
<<: *on-stable <<: *on-stable
environment: environment:
MIX_ENV: prod MIX_ENV: prod

View file

@ -37,7 +37,7 @@ variables:
pipeline: pipeline:
# Canonical arm64 # Canonical arm64
debian-bookworm: debian-bookworm:
image: hexpm/elixir:1.15.4-erlang-25.3.2.5-debian-bookworm-20230612 image: hexpm/elixir:1.15.4-erlang-26.0.2-debian-bookworm-20230612
<<: *on-release <<: *on-release
environment: environment:
MIX_ENV: prod MIX_ENV: prod
@ -65,7 +65,7 @@ pipeline:
# Canonical arm64-musl # Canonical arm64-musl
musl: musl:
image: hexpm/elixir:1.15.4-erlang-25.3.2.5-alpine-3.18.2 image: hexpm/elixir:1.15.4-erlang-26.0.2-alpine-3.18.2
<<: *on-stable <<: *on-stable
environment: environment:
MIX_ENV: prod MIX_ENV: prod

55
.woodpecker/lint.yml Normal file
View file

@ -0,0 +1,55 @@
platform: linux/amd64
variables:
- &scw-secrets
- SCW_ACCESS_KEY
- SCW_SECRET_KEY
- SCW_DEFAULT_ORGANIZATION_ID
- &setup-hex "mix local.hex --force && mix local.rebar --force"
- &on-release
when:
event:
- push
- tag
branch:
- develop
- stable
- refs/tags/v*
- refs/tags/stable-*
- &on-stable
when:
event:
- push
- tag
branch:
- stable
- refs/tags/stable-*
- &on-point-release
when:
event:
- push
branch:
- develop
- stable
- &on-pr-open
when:
event:
- pull_request
- &tag-build "export BUILD_TAG=$${CI_COMMIT_TAG:-\"$CI_COMMIT_BRANCH\"} && export PLEROMA_BUILD_BRANCH=$BUILD_TAG"
- &clean "(rm -rf release || true) && (rm -rf _build || true) && (rm -rf /root/.mix)"
- &mix-clean "mix deps.clean --all && mix clean"
pipeline:
lint:
image: akkoma/ci-base:1.15-otp26
<<: *on-pr-open
environment:
MIX_ENV: test
commands:
- mix local.hex --force
- mix local.rebar --force
- mix deps.get
- mix compile
- mix format --check-formatted

View file

@ -1,5 +1,8 @@
platform: linux/amd64 platform: linux/amd64
depends_on:
- lint
matrix: matrix:
ELIXIR_VERSION: ELIXIR_VERSION:
- 1.14 - 1.14
@ -12,9 +15,8 @@ matrix:
OTP_VERSION: 25 OTP_VERSION: 25
- ELIXIR_VERSION: 1.15 - ELIXIR_VERSION: 1.15
OTP_VERSION: 25 OTP_VERSION: 25
# Soon - ELIXIR_VERSION: 1.15
#- ELIXIR_VERSION: 1.15 OTP_VERSION: 26
# OTP_VERSION: 26
variables: variables:
- &scw-secrets - &scw-secrets
@ -69,15 +71,7 @@ services:
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: postgres
pipeline: pipeline:
lint: test:
<<: *on-pr-open
image: akkoma/ci-base:1.15
commands:
- mix local.hex --force
- mix local.rebar --force
- mix format --check-formatted
build:
image: akkoma/ci-base:${ELIXIR_VERSION}-otp${OTP_VERSION} image: akkoma/ci-base:${ELIXIR_VERSION}-otp${OTP_VERSION}
<<: *on-pr-open <<: *on-pr-open
environment: environment:
@ -91,24 +85,9 @@ pipeline:
- mix local.rebar --force - mix local.rebar --force
- mix deps.get - mix deps.get
- mix compile - mix compile
- mix ecto.drop -f -q
test: - mix ecto.create
image: akkoma/ci-base:${ELIXIR_VERSION}-otp${OTP_VERSION} - mix ecto.migrate
<<: *on-pr-open - mkdir -p test/tmp
environment: - mix test --preload-modules --exclude erratic --exclude federated --exclude mocked
MIX_ENV: test - mix test --preload-modules --only mocked
POSTGRES_DB: pleroma_test_${ELIXIR_VERSION}_${OTP_VERSION}
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
DB_HOST: postgres
commands:
- mix local.hex --force
- mix local.rebar --force
- mix deps.get
- mix compile
- mix ecto.drop -f -q
- mix ecto.create
- mix ecto.migrate
- mkdir -p test/tmp
- mix test --preload-modules --exclude erratic --exclude federated --exclude mocked
- mix test --preload-modules --only mocked

View file

@ -4,10 +4,61 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## Unreleased ## 2024.03
## Added
- CLI tasks best-effort checking for past abuse of the recent spoofing exploit
- new `:mrf_steal_emoji, :download_unknown_size` option; defaults to `false`
## Changed
- `Pleroma.Upload, :base_url` now MUST be configured explicitly if used;
use of the same domain as the instance is **strongly** discouraged
- `:media_proxy, :base_url` now MUST be configured explicitly if used;
use of the same domain as the instance is **strongly** discouraged
- StealEmoji:
- now uses the pack.json format;
existing users must migrate with an out-of-band script (check release notes)
- only steals shortcodes recognised as valid
- URLs of stolen emoji is no longer predictable
- The `Dedupe` upload filter is now always active;
`AnonymizeFilenames` is again opt-in
- received AP data is sanity checked before we attempt to parse it as a user
- Uploads, emoji and media proxy now restrict Content-Type headers to a safe subset
- Akkoma will no longer fetch and parse objects hosted on the same domain
## Fixed ## Fixed
- Critical security issue allowing Akkoma to be used as a vector for
(depending on configuration) impersonation of other users or creation
of bogus users and posts on the upload domain
- Critical security issue letting Akkoma fall for the above impersonation
payloads due to lack of strict id checking
- Critical security issue allowing domains redirect to to pose as the initial domain
(e.g. with media proxy's fallback redirects)
- refetched objects can no longer attribute themselves to third-party actors
(this had no externally visible effect since actor info is read from the Create activity)
- our litepub JSON-LD schema is now served with the correct content type
- remote APNG attachments are now recognised as images
## 2024.02
## Added
- Full compatibility with Erlang OTP26
- handling of GET /api/v1/preferences
- Akkoma API is now documented
- ability to auto-approve follow requests from users you are already following
- The SimplePolicy MRF can now strip user backgrounds from selected remote hosts
## Changed
- OTP builds are now built on erlang OTP26
- The base Phoenix framework is now updated to 1.7
- An `outbox` field has been added to actor profiles to comply with AP spec
- User profile backgrounds do now federate with other Akkoma instances and Sharkey
## Fixed
- Documentation issue in which a non-existing nginx file was referenced
- Issue where a bad inbox URL could break federation - Issue where a bad inbox URL could break federation
- Issue where hashtag rel values would be scrubbed
- Issue where short domains listed in `transparency_obfuscate_domains` were not actually obfuscated
## 2023.08 ## 2023.08

View file

@ -1,4 +1,4 @@
FROM hexpm/elixir:1.15.4-erlang-25.3.2.5-alpine-3.18.2 FROM hexpm/elixir:1.15.4-erlang-26.0.2-alpine-3.18.2
ENV MIX_ENV=prod ENV MIX_ENV=prod
ENV ERL_EPMD_ADDRESS=127.0.0.1 ENV ERL_EPMD_ADDRESS=127.0.0.1

View file

@ -1,16 +1,21 @@
# Pleroma backend security policy # Akkoma backend security handling
## Supported versions
Currently, Pleroma offers bugfixes and security patches only for the latest minor release.
| Version | Support
|---------| --------
| 2.2 | Bugfixes and security patches
## Reporting a vulnerability ## Reporting a vulnerability
Please use confidential issues (tick the "This issue is confidential and should only be visible to team members with at least Reporter access." box when submitting) at our [bugtracker](https://git.pleroma.social/pleroma/pleroma/-/issues/new) for reporting vulnerabilities. Please send an email (preferably encrypted) or
a DM via our IRC to one of the following people:
| Forgejo nick | IRC nick | Email | GPG |
| ------------ | ------------- | ------------- | --------------------------------------- |
| floatinghost | FloatingGhost | *see GPG key* | https://coffee-and-dreams.uk/pubkey.asc |
## Announcements ## Announcements
New releases are announced at [pleroma.social](https://pleroma.social/announcements/). All security releases are tagged with ["Security"](https://pleroma.social/announcements/tags/security/). You can be notified of them by subscribing to an Atom feed at <https://pleroma.social/announcements/tags/security/feed.xml>. New releases and security issues are announced at
[meta.akkoma.dev](https://meta.akkoma.dev/c/releases) and
[@akkoma@ihatebeinga.live](https://ihatebeinga.live/akkoma).
Both also offer RSS feeds
([meta](https://meta.akkoma.dev/c/releases/7.rss),
[fedi](https://ihatebeinga.live/users/akkoma.rss))
so you can keep an eye on it without any accounts.

View file

@ -61,11 +61,12 @@
# Upload configuration # Upload configuration
config :pleroma, Pleroma.Upload, config :pleroma, Pleroma.Upload,
uploader: Pleroma.Uploaders.Local, uploader: Pleroma.Uploaders.Local,
filters: [Pleroma.Upload.Filter.Dedupe], filters: [],
link_name: false, link_name: false,
proxy_remote: false, proxy_remote: false,
filename_display_max_length: 30, filename_display_max_length: 30,
base_url: nil base_url: nil,
allowed_mime_types: ["image", "audio", "video"]
config :pleroma, Pleroma.Uploaders.Local, uploads: "uploads" config :pleroma, Pleroma.Uploaders.Local, uploads: "uploads"
@ -110,17 +111,6 @@
"xmpp" "xmpp"
] ]
websocket_config = [
path: "/websocket",
serializer: [
{Phoenix.Socket.V1.JSONSerializer, "~> 1.0.0"},
{Phoenix.Socket.V2.JSONSerializer, "~> 2.0.0"}
],
timeout: 60_000,
transport_log: false,
compress: false
]
# Configures the endpoint # Configures the endpoint
config :pleroma, Pleroma.Web.Endpoint, config :pleroma, Pleroma.Web.Endpoint,
url: [host: "localhost"], url: [host: "localhost"],
@ -130,10 +120,7 @@
{:_, {:_,
[ [
{"/api/v1/streaming", Pleroma.Web.MastodonAPI.WebsocketHandler, []}, {"/api/v1/streaming", Pleroma.Web.MastodonAPI.WebsocketHandler, []},
{"/websocket", Phoenix.Endpoint.CowboyWebSocket, {:_, Plug.Cowboy.Handler, {Pleroma.Web.Endpoint, []}}
{Phoenix.Transports.WebSocket,
{Pleroma.Web.Endpoint, Pleroma.Web.UserSocket, websocket_config}}},
{:_, Phoenix.Endpoint.Cowboy2Handler, {Pleroma.Web.Endpoint, []}}
]} ]}
] ]
], ],
@ -162,14 +149,38 @@
format: "$metadata[$level] $message", format: "$metadata[$level] $message",
metadata: [:request_id] metadata: [:request_id]
# ———————————————————————————————————————————————————————————————
# W A R N I N G
# ———————————————————————————————————————————————————————————————
#
# Whenever adding a privileged new custom type for e.g.
# ActivityPub objects, ALWAYS map their extension back
# to "application/octet-stream".
# Else files served by us can automatically end up with
# those privileged types causing severe security hazards.
# (We need those mappings so Phoenix can assoiate its format
# (the "extension") to incoming requests of those MIME types)
#
# ———————————————————————————————————————————————————————————————
config :mime, :types, %{ config :mime, :types, %{
"application/xml" => ["xml"], "application/xml" => ["xml"],
"application/xrd+xml" => ["xrd+xml"], "application/xrd+xml" => ["xrd+xml"],
"application/jrd+json" => ["jrd+json"], "application/jrd+json" => ["jrd+json"],
"application/activity+json" => ["activity+json"], "application/activity+json" => ["activity+json"],
"application/ld+json" => ["activity+json"] "application/ld+json" => ["activity+json"],
# Can be removed when bumping MIME past 2.0.5
# see https://akkoma.dev/AkkomaGang/akkoma/issues/657
"image/apng" => ["apng"]
} }
config :mime, :extensions, %{
"xrd+xml" => "text/plain",
"jrd+json" => "text/plain",
"activity+json" => "text/plain"
}
# ———————————————————————————————————————————————————————————————
config :tesla, :adapter, {Tesla.Adapter.Finch, name: MyFinch} config :tesla, :adapter, {Tesla.Adapter.Finch, name: MyFinch}
# Configures http settings, upstream proxy etc. # Configures http settings, upstream proxy etc.
@ -300,7 +311,6 @@
alwaysShowSubjectInput: true, alwaysShowSubjectInput: true,
background: "/images/city.jpg", background: "/images/city.jpg",
collapseMessageWithSubject: false, collapseMessageWithSubject: false,
disableChat: false,
greentext: false, greentext: false,
hideFilteredStatuses: false, hideFilteredStatuses: false,
hideMutedPosts: false, hideMutedPosts: false,
@ -388,6 +398,7 @@
accept: [], accept: [],
avatar_removal: [], avatar_removal: [],
banner_removal: [], banner_removal: [],
background_removal: [],
reject_deletes: [], reject_deletes: [],
handle_threads: true handle_threads: true
@ -416,8 +427,6 @@
threshold: 604_800, threshold: 604_800,
actions: [:delist, :strip_followers] actions: [:delist, :strip_followers]
config :pleroma, :mrf_follow_bot, follower_nickname: nil
config :pleroma, :mrf_reject_newly_created_account_notes, age: 86_400 config :pleroma, :mrf_reject_newly_created_account_notes, age: 86_400
config :pleroma, :rich_media, config :pleroma, :rich_media,
@ -463,10 +472,6 @@
image_quality: 85, image_quality: 85,
min_content_length: 100 * 1024 min_content_length: 100 * 1024
config :pleroma, :shout,
enabled: true,
limit: 5_000
config :phoenix, :format_encoders, json: Jason, "activity+json": Jason config :phoenix, :format_encoders, json: Jason, "activity+json": Jason
config :phoenix, :json_library, Jason config :phoenix, :json_library, Jason

View file

@ -105,6 +105,19 @@
"https://cdn-host.com" "https://cdn-host.com"
] ]
}, },
%{
key: :allowed_mime_types,
label: "Allowed MIME types",
type: {:list, :string},
description:
"List of MIME (main) types uploads are allowed to identify themselves with. Other types may still be uploaded, but will identify as a generic binary to clients. WARNING: Loosening this over the defaults can lead to security issues. Removing types is safe, but only add to the list if you are sure you know what you are doing.",
suggestions: [
"image",
"audio",
"video",
"font"
]
},
%{ %{
key: :proxy_remote, key: :proxy_remote,
type: :boolean, type: :boolean,

View file

@ -4,6 +4,7 @@ services:
db: db:
image: akkoma-db:latest image: akkoma-db:latest
build: ./docker-resources/database build: ./docker-resources/database
shm_size: 4gb
restart: unless-stopped restart: unless-stopped
user: ${DOCKER_USER} user: ${DOCKER_USER}
environment: { environment: {

View file

@ -17,5 +17,5 @@ If you want to generate a restrictive `robots.txt`, you can run the following mi
=== "From Source" === "From Source"
```sh ```sh
mix pleroma.robotstxt disallow_all mix pleroma.robots_txt disallow_all
``` ```

View file

@ -0,0 +1,56 @@
# Security-related tasks
{! administration/CLI_tasks/general_cli_task_info.include !}
!!! danger
Many of these tasks were written in response to a patched exploit.
It is recommended to run those very soon after installing its respective security update.
Over time with db migrations they might become less accurate or be removed altogether.
If you never ran an affected version, theres no point in running them.
## Spoofed AcitivityPub objects exploit (2024-03, fixed in 3.11.1)
### Search for uploaded spoofing payloads
Scans local uploads for spoofing payloads.
If the instance is not using the local uploader it was not affected.
Attachments wil be scanned anyway in case local uploader was used in the past.
!!! note
This cannot reliably detect payloads attached to deleted posts.
=== "OTP"
```sh
./bin/pleroma_ctl security spoof-uploaded
```
=== "From Source"
```sh
mix pleroma.security spoof-uploaded
```
### Search for counterfeit posts in database
Scans all notes in the database for signs of being spoofed.
!!! note
Spoofs targeting local accounts can be detected rather reliably
(with some restrictions documented in the tasks logs).
Counterfeit posts from remote users cannot. A best-effort attempt is made, but
a thorough attacker can avoid this and it may yield a small amount of false positives.
Should you find counterfeit posts of local users, let other admins know so they can delete the too.
=== "OTP"
```sh
./bin/pleroma_ctl security spoof-inserted
```
=== "From Source"
```sh
mix pleroma.security spoof-inserted
```

View file

@ -45,3 +45,16 @@
8. Remove the dependencies that you don't need anymore (see installation guide). Make sure you don't remove packages that are still needed for other software that you have running! 8. Remove the dependencies that you don't need anymore (see installation guide). Make sure you don't remove packages that are still needed for other software that you have running!
[¹]: We assume the database name and user are both "akkoma". If not, you can find the correct name in your config files. [¹]: We assume the database name and user are both "akkoma". If not, you can find the correct name in your config files.
## Docker installations
If running behind Docker, it is required to run the above commands inside of a running database container.
### Example
Running `docker compose run --rm db pg_dump <...>` will fail and return:
```
pg_dump: error: connection to server on socket "/run/postgresql/.s.PGSQL.5432" failed: No such file or directory
Is the server running locally and accepting connections on that socket?"
```
However, first starting just the database container with `docker compose up db -d`, and then running `docker compose exec db pg_dump -d akkoma --format=custom -f </your/backup/dir/akkoma.pgdump>` will successfully generate a database dump.
Then to make the file accessible on the host system you can run `docker compose cp db:</your/backup/dir/akkoma.pgdump> </your/target/location>` to copy if from the container.

View file

@ -3,7 +3,7 @@
If you run akkoma, you may be inclined to collect metrics to ensure your instance is running smoothly, If you run akkoma, you may be inclined to collect metrics to ensure your instance is running smoothly,
and that there's nothing quietly failing in the background. and that there's nothing quietly failing in the background.
To facilitate this, akkoma exposes prometheus metrics to be scraped. To facilitate this, akkoma exposes a dashboard and prometheus metrics to be scraped.
## Prometheus ## Prometheus
@ -31,3 +31,15 @@ Once you have your token of the form `Bearer $ACCESS_TOKEN`, you can use that in
- targets: - targets:
- example.com - example.com
``` ```
## Dashboard
Administrators can access a live dashboard under `/phoenix/live_dashboard`
giving an overview of uptime, software versions, database stats and more.
The dashboard also includes a variation of the prometheus metrics, however
they do not exactly match due to respective limitations of the dashboard
and the prometheus exporter.
Even more important, the dashboard collects metrics locally in the browser
only while the page is open and cannot give a view on their past history.
For proper monitoring it is recommended to set up prometheus.

View file

@ -104,31 +104,60 @@ To add configuration to your config file, you can copy it from the base config.
## Message rewrite facility ## Message rewrite facility
### :mrf ### :mrf
* `policies`: Message Rewrite Policy, either one or a list. Here are the ones available by default:
* `Pleroma.Web.ActivityPub.MRF.NoOpPolicy`: Doesnt modify activities (default).
* `Pleroma.Web.ActivityPub.MRF.DropPolicy`: Drops all activities. It generally doesnt makes sense to use in production.
* `Pleroma.Web.ActivityPub.MRF.SimplePolicy`: Restrict the visibility of activities from certains instances (See [`:mrf_simple`](#mrf_simple)).
* `Pleroma.Web.ActivityPub.MRF.TagPolicy`: Applies policies to individual users based on tags, which can be set using pleroma-fe/admin-fe/any other app that supports Pleroma Admin API. For example it allows marking posts from individual users nsfw (sensitive).
* `Pleroma.Web.ActivityPub.MRF.SubchainPolicy`: Selectively runs other MRF policies when messages match (See [`:mrf_subchain`](#mrf_subchain)).
* `Pleroma.Web.ActivityPub.MRF.RejectNonPublic`: Drops posts with non-public visibility settings (See [`:mrf_rejectnonpublic`](#mrf_rejectnonpublic)).
* `Pleroma.Web.ActivityPub.MRF.EnsureRePrepended`: Rewrites posts to ensure that replies to posts with subjects do not have an identical subject and instead begin with re:.
* `Pleroma.Web.ActivityPub.MRF.AntiLinkSpamPolicy`: Rejects posts from likely spambots by rejecting posts from new users that contain links.
* `Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy`: Crawls attachments using their MediaProxy URLs so that the MediaProxy cache is primed.
* `Pleroma.Web.ActivityPub.MRF.MentionPolicy`: Drops posts mentioning configurable users. (See [`:mrf_mention`](#mrf_mention)).
* `Pleroma.Web.ActivityPub.MRF.VocabularyPolicy`: Restricts activities to a configured set of vocabulary. (See [`:mrf_vocabulary`](#mrf_vocabulary)).
* `Pleroma.Web.ActivityPub.MRF.ObjectAgePolicy`: Rejects or delists posts based on their age when received. (See [`:mrf_object_age`](#mrf_object_age)).
* `Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicy`: Sets a default expiration on all posts made by users of the local instance. Requires `Pleroma.Workers.PurgeExpiredActivity` to be enabled for processing the scheduled delections.
* `Pleroma.Web.ActivityPub.MRF.ForceBotUnlistedPolicy`: Makes all bot posts to disappear from public timelines.
* `Pleroma.Web.ActivityPub.MRF.FollowBotPolicy`: Automatically follows newly discovered users from the specified bot account. Local accounts, locked accounts, and users with "#nobot" in their bio are respected and excluded from being followed.
* `Pleroma.Web.ActivityPub.MRF.AntiFollowbotPolicy`: Drops follow requests from followbots. Users can still allow bots to follow them by first following the bot.
* `Pleroma.Web.ActivityPub.MRF.KeywordPolicy`: Rejects or removes from the federated timeline or replaces keywords. (See [`:mrf_keyword`](#mrf_keyword)).
* `Pleroma.Web.ActivityPub.MRF.NormalizeMarkup`: Pass inbound HTML through a scrubber to make sure it doesn't have anything unusual in it. On by default, cannot be turned off.
* `Pleroma.Web.ActivityPub.MRF.InlineQuotePolicy`: Append a link to a post that quotes another post with the link to the quoted post, to ensure that software that does not understand quotes can have full context. On by default, cannot be turned off.
* `transparency`: Make the content of your Message Rewrite Facility settings public (via nodeinfo). * `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_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` * `transparency_obfuscate_domains`: Show domains with `*` in the middle, to censor them if needed. For example, `ridingho.me` will show as `rid*****.me`
* `policies`: Message Rewrite Policy, either one or a list. Here are the ones available by default:
* `Pleroma.Web.ActivityPub.MRF.NoOpPolicy`: Doesnt modify activities (default).
* `Pleroma.Web.ActivityPub.MRF.DropPolicy`: Drops all activities. It generally doesnt makes sense to use in production.
* `Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicy`: Sets a default expiration on all posts made by users of the local instance. Requires `Pleroma.Workers.PurgeExpiredActivity` to be enabled for processing the scheduled delections.
(See [`:mrf_activity_expiration`](#mrf_activity_expiration))
* `Pleroma.Web.ActivityPub.MRF.AntiFollowbotPolicy`: Drops follow requests from followbots. Users can still allow bots to follow them by first following the bot.
* `Pleroma.Web.ActivityPub.MRF.AntiLinkSpamPolicy`: Rejects posts from likely spambots by rejecting posts from new users that contain links.
* `Pleroma.Web.ActivityPub.MRF.EnsureRePrepended`: Rewrites posts to ensure that replies to posts with subjects do not have an identical subject and instead begin with re:.
* `Pleroma.Web.ActivityPub.MRF.ForceBotUnlistedPolicy`: Makes all bot posts to disappear from public timelines.
* `Pleroma.Web.ActivityPub.MRF.HellthreadPolicy`: Blocks messages with too many mentions.
(See [`mrf_hellthread`](#mrf_hellthread))
* `Pleroma.Web.ActivityPub.MRF.KeywordPolicy`: Rejects or removes from the federated timeline or replaces keywords. (See [`:mrf_keyword`](#mrf_keyword)).
* `Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy`: Crawls attachments using their MediaProxy URLs so that the MediaProxy cache is primed.
* `Pleroma.Web.ActivityPub.MRF.MentionPolicy`: Drops posts mentioning configurable users. (See [`:mrf_mention`](#mrf_mention)).
* `Pleroma.Web.ActivityPub.MRF.NoEmptyPolicy`: Drops local activities which have no actual content.
(e.g. no attachments and only consists of mentions)
* `Pleroma.Web.ActivityPub.MRF.NoPlaceholderTextPolicy`: Strips content placeholders from posts
(such as the dot from mastodon)
* `Pleroma.Web.ActivityPub.MRF.ObjectAgePolicy`: Rejects or delists posts based on their age when received. (See [`:mrf_object_age`](#mrf_object_age)).
* `Pleroma.Web.ActivityPub.MRF.RejectNewlyCreatedAccountNotesPolicy`: Rejects posts of users the server only recently learned about for a while. Great to block spam accounts. (See [`:mrf_reject_newly_created_account_notes`](#mrf_reject_newly_created_account_notes))
* `Pleroma.Web.ActivityPub.MRF.RejectNonPublic`: Drops posts with non-public visibility settings (See [`:mrf_rejectnonpublic`](#mrf_rejectnonpublic)).
* `Pleroma.Web.ActivityPub.MRF.SimplePolicy`: Restrict the visibility of activities from certains instances (See [`:mrf_simple`](#mrf_simple)).
* `Pleroma.Web.ActivityPub.MRF.StealEmojiPolicy`: Steals all eligible emoji encountered in posts from remote instances
(See [`:mrf_steal_emoji`](#mrf_steal_emoji))
* `Pleroma.Web.ActivityPub.MRF.SubchainPolicy`: Selectively runs other MRF policies when messages match (See [`:mrf_subchain`](#mrf_subchain)).
* `Pleroma.Web.ActivityPub.MRF.TagPolicy`: Applies policies to individual users based on tags, which can be set using pleroma-fe/admin-fe/any other app that supports Pleroma Admin API. For example it allows marking posts from individual users nsfw (sensitive).
* `Pleroma.Web.ActivityPub.MRF.UserAllowListPolicy`: Drops all posts except from users specified in a list.
(See [`:mrf_user_allowlist`](#mrf_user_allowlist))
* `Pleroma.Web.ActivityPub.MRF.VocabularyPolicy`: Restricts activities to a configured set of vocabulary. (See [`:mrf_vocabulary`](#mrf_vocabulary)).
Additionally the following MRFs will *always* be aplied and cannot be disabled:
* `Pleroma.Web.ActivityPub.MRF.DirectMessageDisabledPolicy`: Strips users limiting who can send them DMs from the recipients of non-eligible DMs
* `Pleroma.Web.ActivityPub.MRF.HashtagPolicy`: Depending on a posts hashtags it can be rejected, get its sensitive flags force-enabled or removed from the global timeline
(See [`:mrf_hashtag`](#mrf_hashtag))
* `Pleroma.Web.ActivityPub.MRF.InlineQuotePolicy`: Append a link to a post that quotes another post with the link to the quoted post, to ensure that software that does not understand quotes can have full context.
(See [`:mrf_inline_quote`](#mrf_inline_quote))
* `Pleroma.Web.ActivityPub.MRF.NormalizeMarkup`: Pass inbound HTML through a scrubber to make sure it doesn't have anything unusual in it.
(See [`:mrf_normalize_markup`](#mrf_normalize_markup))
## Federation ## Federation
### :activitypub
* `unfollow_blocked`: Whether blocks result in people getting unfollowed
* `outgoing_blocks`: Whether to federate blocks to other instances
* `blockers_visible`: Whether a user can see the posts of users who blocked them
* `deny_follow_blocked`: Whether to disallow following an account that has blocked the user in question
* `sign_object_fetches`: Sign object fetches with HTTP signatures
* `authorized_fetch_mode`: Require HTTP signatures for AP fetches
* `max_collection_objects`: The maximum number of objects to fetch from a remote AP collection.
### MRF policies ### MRF policies
!!! note !!! note
@ -144,6 +173,7 @@ To add configuration to your config file, you can copy it from the base config.
* `report_removal`: List of instances to reject reports from and the reason for doing so. * `report_removal`: List of instances to reject reports from and the reason for doing so.
* `avatar_removal`: List of instances to strip avatars from and the reason for doing so. * `avatar_removal`: List of instances to strip avatars from and the reason for doing so.
* `banner_removal`: List of instances to strip banners from and the reason for doing so. * `banner_removal`: List of instances to strip banners from and the reason for doing so.
* `background_removal`: List of instances to strip user backgrounds from and the reason for doing so.
* `reject_deletes`: List of instances to reject deletions from and the reason for doing so. * `reject_deletes`: List of instances to reject deletions from and the reason for doing so.
#### :mrf_subchain #### :mrf_subchain
@ -206,7 +236,9 @@ config :pleroma, :mrf_user_allowlist, %{
#### :mrf_steal_emoji #### :mrf_steal_emoji
* `hosts`: List of hosts to steal emojis from * `hosts`: List of hosts to steal emojis from
* `rejected_shortcodes`: Regex-list of shortcodes to reject * `rejected_shortcodes`: Regex-list of shortcodes to reject
* `size_limit`: File size limit (in bytes), checked before an emoji is saved to the disk * `size_limit`: File size limit (in bytes), checked before download if possible (and remote server honest),
otherwise or again checked before saving emoji to the disk
* `download_unknown_size`: whether to download an emoji when the remote server doesnt report its size in advance
#### :mrf_activity_expiration #### :mrf_activity_expiration
@ -222,14 +254,24 @@ Notes:
- The hashtags in the configuration do not have a leading `#`. - The hashtags in the configuration do not have a leading `#`.
- This MRF Policy is always enabled, if you want to disable it you have to set empty lists - This MRF Policy is always enabled, if you want to disable it you have to set empty lists
### :activitypub #### :mrf_reject_newly_created_account_notes
* `unfollow_blocked`: Whether blocks result in people getting unfollowed After initially encountering an user, all their posts
* `outgoing_blocks`: Whether to federate blocks to other instances will be rejected for the configured time (in seconds).
* `blockers_visible`: Whether a user can see the posts of users who blocked them Only drops posts. Follows, reposts, etc. are not affected.
* `deny_follow_blocked`: Whether to disallow following an account that has blocked the user in question
* `sign_object_fetches`: Sign object fetches with HTTP signatures * `age`: Time below which to reject (in seconds)
* `authorized_fetch_mode`: Require HTTP signatures for AP fetches
* `max_collection_objects`: The maximum number of objects to fetch from a remote AP collection. An example: (86400 seconds = 24 hours)
```elixir
config :pleroma, :mrf_reject_newly_created_account_notes, age: 86400
```
#### :mrf_inline_quote
* `prefix`: what prefix to prepend to quoted URLs
#### :mrf_normalize_markup
* `scrub_policy`: the scrubbing module to use (by default a built-in HTML sanitiser)
## Pleroma.User ## Pleroma.User
@ -356,7 +398,8 @@ This section describe PWA manifest instance-specific values. Currently this opti
## :media_proxy ## :media_proxy
* `enabled`: Enables proxying of remote media to the instances proxy * `enabled`: Enables proxying of remote media to the instances proxy
* `base_url`: The base URL to access a user-uploaded file. Useful when you want to proxy the media files via another host/CDN fronts. * `base_url`: The base URL to access a user-uploaded file.
Using a (sub)domain distinct from the instance endpoint is **strongly** recommended.
* `proxy_opts`: All options defined in `Pleroma.ReverseProxy` documentation, defaults to `[max_body_length: (25*1_048_576)]`. * `proxy_opts`: All options defined in `Pleroma.ReverseProxy` documentation, defaults to `[max_body_length: (25*1_048_576)]`.
* `whitelist`: List of hosts with scheme to bypass the mediaproxy (e.g. `https://example.com`) * `whitelist`: List of hosts with scheme to bypass the mediaproxy (e.g. `https://example.com`)
* `invalidation`: options for remove media from cache after delete object: * `invalidation`: options for remove media from cache after delete object:
@ -557,8 +600,9 @@ the source code is here: [kocaptcha](https://github.com/koto-bank/kocaptcha). Th
* `uploader`: Which one of the [uploaders](#uploaders) to use. * `uploader`: Which one of the [uploaders](#uploaders) to use.
* `filters`: List of [upload filters](#upload-filters) to use. * `filters`: List of [upload filters](#upload-filters) to use.
* `link_name`: When enabled Akkoma will add a `name` parameter to the url of the upload, for example `https://instance.tld/media/corndog.png?name=corndog.png`. This is needed to provide the correct filename in Content-Disposition headers when using filters like `Pleroma.Upload.Filter.Dedupe` * `link_name`: When enabled Akkoma will add a `name` parameter to the url of the upload, for example `https://instance.tld/media/corndog.png?name=corndog.png`. This is needed to provide the correct filename in Content-Disposition headers
* `base_url`: The base URL to access a user-uploaded file. Useful when you want to host the media files via another domain or are using a 3rd party S3 provider. * `base_url`: The base URL to access a user-uploaded file; MUST be configured explicitly.
Using a (sub)domain distinct from the instance endpoint is **strongly** recommended.
* `proxy_remote`: If you're using a remote uploader, Akkoma will proxy media requests instead of redirecting to it. * `proxy_remote`: If you're using a remote uploader, Akkoma will proxy media requests instead of redirecting to it.
* `proxy_opts`: Proxy options, see `Pleroma.ReverseProxy` documentation. * `proxy_opts`: Proxy options, see `Pleroma.ReverseProxy` documentation.
* `filename_display_max_length`: Set max length of a filename to display. 0 = no limit. Default: 30. * `filename_display_max_length`: Set max length of a filename to display. 0 = no limit. Default: 30.
@ -598,17 +642,18 @@ config :ex_aws, :s3,
### Upload filters ### Upload filters
#### Pleroma.Upload.Filter.AnonymizeFilename
This filter replaces the filename (not the path) of an upload. For complete obfuscation, add
`Pleroma.Upload.Filter.Dedupe` before AnonymizeFilename.
* `text`: Text to replace filenames in links. If empty, `{random}.extension` will be used. You can get the original filename extension by using `{extension}`, for example `custom-file-name.{extension}`.
#### Pleroma.Upload.Filter.Dedupe #### Pleroma.Upload.Filter.Dedupe
**Always** active; cannot be turned off.
Renames files to their hash and prevents duplicate files filling up the disk.
No specific configuration. No specific configuration.
#### Pleroma.Upload.Filter.AnonymizeFilename
This filter replaces the declared filename (not the path) of an upload.
* `text`: Text to replace filenames in links. If empty, `{random}.extension` will be used. You can get the original filename extension by using `{extension}`, for example `custom-file-name.{extension}`.
#### Pleroma.Upload.Filter.Exiftool #### Pleroma.Upload.Filter.Exiftool
This filter only strips the GPS and location metadata with Exiftool leaving color profiles and attributes intact. This filter only strips the GPS and location metadata with Exiftool leaving color profiles and attributes intact.
@ -958,6 +1003,15 @@ config :ueberauth, Ueberauth,
] ]
``` ```
You may also need to set up your frontend to use oauth logins. For example, for `akkoma-fe`:
```elixir
config :pleroma, :frontend_configurations,
pleroma_fe: %{
loginMethod: "token"
}
```
## Link parsing ## Link parsing
### :uri_schemes ### :uri_schemes

View file

@ -17,6 +17,16 @@ This sets the Akkoma application server to only listen to the localhost interfac
This sets the `secure` flag on Akkomas session cookie. This makes sure, that the cookie is only accepted over encrypted HTTPs connections. This implicitly renames the cookie from `pleroma_key` to `__Host-pleroma-key` which enforces some restrictions. (see [cookie prefixes](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#Cookie_prefixes)) This sets the `secure` flag on Akkomas session cookie. This makes sure, that the cookie is only accepted over encrypted HTTPs connections. This implicitly renames the cookie from `pleroma_key` to `__Host-pleroma-key` which enforces some restrictions. (see [cookie prefixes](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#Cookie_prefixes))
### `Pleroma.Upload, :uploader, :base_url`
> Recommended value: *anything on a different domain than the instance endpoint; e.g. https://media.myinstance.net/*
Uploads are user controlled and (unless youre running a true single-user
instance) should therefore not be considered trusted. But the domain is used
as a pivilege boundary e.g. by HTTP content security policy and ActivityPub.
Having uploads on the same domain enabled several past vulnerabilities
able to be exploited by malicious users.
### `:http_security` ### `:http_security`
> Recommended value: `true` > Recommended value: `true`

View file

@ -6,7 +6,16 @@ With the `mediaproxy` function you can use nginx to cache this content, so users
## Activate it ## Activate it
* Edit your nginx config and add the following location: * Edit your nginx config and add the following location to your main server block:
```
location /proxy {
return 404;
}
```
* Set up a subdomain for the proxy with its nginx config on the same machine
*(the latter is not strictly required, but for simplicity well assume so)*
* In this subdomains server block add
``` ```
location /proxy { location /proxy {
proxy_cache akkoma_media_cache; proxy_cache akkoma_media_cache;
@ -26,9 +35,9 @@ config :pleroma, :media_proxy,
enabled: true, enabled: true,
proxy_opts: [ proxy_opts: [
redirect_on_failure: true redirect_on_failure: true
] ],
#base_url: "https://cache.akkoma.social" base_url: "https://cache.akkoma.social"
``` ```
If you want to use a subdomain to serve the files, uncomment `base_url`, change the url and add a comma after `true` in the previous line. You **really** should use a subdomain to serve proxied files; while we will fix bugs resulting from this, serving arbitrary remote content on your main domain namespace is a significant attack surface.
* Restart nginx and Akkoma * Restart nginx and Akkoma

View file

@ -60,7 +60,7 @@ Example of `my-awesome-theme.json` where we add the name "My Awesome Theme"
### Set as default theme ### Set as default theme
Now we can set the new theme as default in the [Pleroma FE configuration](https://docs-fe.akkoma.dev/stable/CONFIGURATION). Now we can set the new theme as default in the [Pleroma FE configuration](https://docs-fe.akkoma.dev/stable/CONFIGURATION/).
Example of adding the new theme in the back-end config files Example of adding the new theme in the back-end config files
```elixir ```elixir

View file

@ -35,6 +35,7 @@ Once `SimplePolicy` is enabled, you can configure various groups in the `:mrf_si
* `media_removal`: Servers in this group will have media stripped from incoming messages. * `media_removal`: Servers in this group will have media stripped from incoming messages.
* `avatar_removal`: Avatars from these servers will be stripped from incoming messages. * `avatar_removal`: Avatars from these servers will be stripped from incoming messages.
* `banner_removal`: Banner images from these servers will be stripped from incoming messages. * `banner_removal`: Banner images from these servers will be stripped from incoming messages.
* `background_removal`: User background images from these servers will be stripped from incoming messages.
* `report_removal`: Servers in this group will have their reports (flags) rejected. * `report_removal`: Servers in this group will have their reports (flags) rejected.
* `federated_timeline_removal`: Servers in this group will have their messages unlisted from the public timelines by flipping the `to` and `cc` fields. * `federated_timeline_removal`: Servers in this group will have their messages unlisted from the public timelines by flipping the `to` and `cc` fields.
* `reject_deletes`: Deletion requests will be rejected from these servers. * `reject_deletes`: Deletion requests will be rejected from these servers.
@ -61,6 +62,32 @@ config :pleroma, :mrf_simple,
The effects of MRF policies can be very drastic. It is important to use this functionality carefully. Always try to talk to an admin before writing an MRF policy concerning their instance. The effects of MRF policies can be very drastic. It is important to use this functionality carefully. Always try to talk to an admin before writing an MRF policy concerning their instance.
## Hiding or Obfuscating Policies
You can opt out of publicly displaying all MRF policies or only hide or obfuscate selected domains.
To just hide everything set:
```elixir
config :pleroma, :mrf,
...
transparency: false,
```
To hide or obfuscate only select entries, use:
```elixir
config :pleroma, :mrf,
...
transparency_obfuscate_domains: ["handholdi.ng", "badword.com"],
transparency_exclusions: [{"ghost.club", "even a fragment is too spoopy for humans"}]
```
## More MRF Policies
See the [documentation cheatsheet](cheatsheet.md)
for all available MRF policies and their options.
## Writing your own MRF Policy ## Writing your own MRF Policy
As discussed above, the MRF system is a modular system that supports pluggable policies. This means that an admin may write a custom MRF policy in Elixir or any other language that runs on the Erlang VM, by specifying the module name in the `policies` config setting. As discussed above, the MRF system is a modular system that supports pluggable policies. This means that an admin may write a custom MRF policy in Elixir or any other language that runs on the Erlang VM, by specifying the module name in the `policies` config setting.

View file

@ -25,11 +25,14 @@ Tuning the BEAM requires you provide a config file normally called [vm.args](htt
`ExecStart=/usr/bin/elixir --erl '-args_file /opt/akkoma/config/vm.args' -S /usr/bin/mix phx.server` `ExecStart=/usr/bin/elixir --erl '-args_file /opt/akkoma/config/vm.args' -S /usr/bin/mix phx.server`
If using an OTP release, set the `RELEASE_VM_ARGS` environment variable to the path to the vm.args file.
Check your OS documentation to adopt a similar strategy on other platforms. Check your OS documentation to adopt a similar strategy on other platforms.
### Virtual Machine and/or few CPU cores ### Virtual Machine and/or few CPU cores
Disable the busy-waiting. This should generally only be done if you're on a platform that does burst scheduling, like AWS. Disable the busy-waiting. This should generally be done if you're on a platform that does burst scheduling, like AWS, or if you're running other
services on the same machine.
**vm.args:** **vm.args:**
@ -39,6 +42,8 @@ Disable the busy-waiting. This should generally only be done if you're on a plat
+sbwtdio none +sbwtdio none
``` ```
These settings are enabled by default for OTP releases
### Dedicated Hardware ### Dedicated Hardware
Enable more busy waiting, increase the internal maximum limit of BEAM processes and ports. You can use this if you run on dedicated hardware, but it is not necessary. Enable more busy waiting, increase the internal maximum limit of BEAM processes and ports. You can use this if you run on dedicated hardware, but it is not necessary.

View file

@ -0,0 +1,146 @@
# Akkoma API
Request authentication (if required) and parameters work the same as for [Pleroma API](pleroma_api.md).
## `/api/v1/akkoma/preferred_frontend/available`
### Returns the available frontends which can be picked as the preferred choice
* Method: `GET`
* Authentication: not required
* Params: none
* Response: JSON
* Example response:
```json
["pleroma-fe/stable"]
```
!!! note
Theres also a browser UI under `/akkoma/frontend`
for interactively querying and changing this.
## `/api/v1/akkoma/preferred_frontend`
### Configures the preferred frontend of this session
* Method: `PUT`
* Authentication: not required
* Params:
* `frontend_name`: STRING containing one of the available frontends
* Response: JSON
* Example response:
```json
{"frontend_name":"pleroma-fe/stable"}
```
!!! note
Theres also a browser UI under `/akkoma/frontend`
for interactively querying and changing this.
## `/api/v1/akkoma/metrics`
### Provides metrics for Prometheus to scrape
* Method: `GET`
* Authentication: required (admin:metrics)
* Params: none
* Response: text
* Example response:
```
# HELP pleroma_remote_users_total
# TYPE pleroma_remote_users_total gauge
pleroma_remote_users_total 25
# HELP pleroma_local_statuses_total
# TYPE pleroma_local_statuses_total gauge
pleroma_local_statuses_total 17
# HELP pleroma_domains_total
# TYPE pleroma_domains_total gauge
pleroma_domains_total 4
# HELP pleroma_local_users_total
# TYPE pleroma_local_users_total gauge
pleroma_local_users_total 3
...
```
## `/api/v1/akkoma/translation/languages`
### Returns available source and target languages for automated text translation
* Method: `GET`
* Authentication: required
* Params: none
* Response: JSON
* Example response:
```json
{
"source": [
{"code":"LV", "name":"Latvian"},
{"code":"ZH", "name":"Chinese (traditional)"},
{"code":"EN-US", "name":"English (American)"}
],
"target": [
{"code":"EN-GB", "name":"English (British)"},
{"code":"JP", "name":"Japanese"}
]
}
```
## `/api/v1/akkoma/frontend_settings/:frontend_name`
### Lists all configuration profiles of the selected frontend for the current user
* Method: `GET`
* Authentication: required
* Params: none
* Response: JSON
* Example response:
```json
[
{"name":"default","version":31}
]
```
## `/api/v1/akkoma/frontend_settings/:frontend_name/:profile_name`
### Returns the full selected frontend settings profile of the current user
* Method: `GET`
* Authentication: required
* Params: none
* Response: JSON
* Example response:
```json
{
"version": 31,
"settings": {
"streaming": true,
"conversationDisplay": "tree",
...
}
}
```
## `/api/v1/akkoma/frontend_settings/:frontend_name/:profile_name`
### Updates the frontend settings profile
* Method: `PUT`
* Authentication: required
* Params:
* `version`: INTEGER
* `settings`: JSON object containing the entire new settings
* Response: JSON
* Example response:
```json
{
"streaming": false,
"conversationDisplay": "tree",
...
}
```
!!! note
The `version` field must be increased by exactly one on each update
## `/api/v1/akkoma/frontend_settings/:frontend_name/:profile_name`
### Drops the specified frontend settings profile
* Method: `DELETE`
* Authentication: required
* Params: none
* Response: JSON
* Example response:
```json
{"deleted":"ok"}
```
## `/api/v1/timelines/bubble`
### Returns a timeline for the local and closely related instances
Works like all other Mastodon-API timeline queries with the documented
[Akkoma-specific additions and tweaks](./differences_in_mastoapi_responses.md#timelines).

View file

@ -1,6 +1,6 @@
# Differences in Mastodon API responses from vanilla Mastodon # Differences in Mastodon API responses from vanilla Mastodon
A Akkoma instance can be identified by "<Mastodon version> (compatible; Pleroma <version>)" present in `version` field in response from `/api/v1/instance` A Akkoma instance can be identified by "<Mastodon version> (compatible; Akkoma <version>)" present in `version` field in response from `/api/v1/instance`
## Flake IDs ## Flake IDs
@ -8,20 +8,28 @@ Akkoma uses 128-bit ids as opposed to Mastodon's 64 bits. However, just like Mas
## Timelines ## Timelines
In addition to Mastodons timelines, there is also a “bubble timeline” showing
posts from the local instance and a set of closely related instances as chosen
by the administrator. It is available under `/api/v1/timelines/bubble`.
Adding the parameter `with_muted=true` to the timeline queries will also return activities by muted (not by blocked!) users. Adding the parameter `with_muted=true` to the timeline queries will also return activities by muted (not by blocked!) users.
Adding the parameter `exclude_visibilities` to the timeline queries will exclude the statuses with the given visibilities. The parameter accepts an array of visibility types (`public`, `unlisted`, `private`, `direct`), e.g., `exclude_visibilities[]=direct&exclude_visibilities[]=private`. Adding the parameter `exclude_visibilities` to the timeline queries will exclude the statuses with the given visibilities. The parameter accepts an array of visibility types (`public`, `unlisted`, `private`, `direct`), e.g., `exclude_visibilities[]=direct&exclude_visibilities[]=private`.
Adding the parameter `reply_visibility` to the public and home timelines queries will filter replies. Possible values: without parameter (default) shows all replies, `following` - replies directed to you or users you follow, `self` - replies directed to you. Adding the parameter `reply_visibility` to the public, bubble or home timelines queries will filter replies. Possible values: without parameter (default) shows all replies, `following` - replies directed to you or users you follow, `self` - replies directed to you.
Adding the parameter `instance=lain.com` to the public timeline will show only statuses originating from `lain.com` (or any remote instance). Adding the parameter `instance=lain.com` to the public timeline will show only statuses originating from `lain.com` (or any remote instance).
Home, public, hashtag & list timelines accept these parameters: All but the direct timeline accept these parameters:
- `only_media`: show only statuses with media attached - `only_media`: show only statuses with media attached
- `local`: show only local statuses
- `remote`: show only remote statuses - `remote`: show only remote statuses
Home, public, hashtag & list timelines further accept:
- `local`: show only local statuses
## Statuses ## Statuses
- `visibility`: has additional possible values `list` and `local` (for local-only statuses) - `visibility`: has additional possible values `list` and `local` (for local-only statuses)
@ -113,6 +121,12 @@ Has these additional fields under the `pleroma` object:
- `notification_settings`: object, can be absent. See `/api/v1/pleroma/notification_settings` for the parameters/keys returned. - `notification_settings`: object, can be absent. See `/api/v1/pleroma/notification_settings` for the parameters/keys returned.
- `favicon`: nullable URL string, Favicon image of the user's instance - `favicon`: nullable URL string, Favicon image of the user's instance
Has these additional fields under the `akkoma` object:
- `instance`: nullable object with metadata about the users instance
- `status_ttl_days`: nullable int, default time after which statuses are deleted
- `permit_followback`: boolean, whether follows from followed accounts are auto-approved
### Source ### Source
Has these additional fields under the `pleroma` object: Has these additional fields under the `pleroma` object:

View file

@ -2,7 +2,7 @@
* PostgreSQL 9.6+ * PostgreSQL 9.6+
* Elixir 1.14+ * Elixir 1.14+
* Erlang OTP 24+ * Erlang OTP 25+
* git * git
* file / libmagic * file / libmagic
* gcc (clang might also work) * gcc (clang might also work)

View file

@ -21,6 +21,33 @@ fork of Akkoma - luckily this isn't very hard.
You'll need to update the backend, then possibly the frontend, depending You'll need to update the backend, then possibly the frontend, depending
on your setup. on your setup.
## Backup diverging features
As time goes on Akkoma and Pleroma added or removed different features
and reorganised the database in a different way. If you want to be able to
migrate back to Pleroma without losing any affected data, youll want to
make a backup before starting the migration.
If you're not interested in migrating back, skip this section
*(although it might be a good idea to temporarily keep a full DB backup
just in case something unexpected happens during migration)*
As of 2024-02 you will want to keep a backup of:
- the entire `chats` and `chat_message_references` tables
The following columns are not deleted by a migration to Akkoma, but a migration
back to Pleroma or future Akkoma upgrades might affect them, so perhaps back them up as well:
- the `birthday` of users and their `show_birthday` setting
- the `expires_at` key of in the `user_relationships` table
*(used by temporary mutes)*
The way cached instance metadata is stored differs, but since those
will be refetched and updated anyway, theres no need for a backup.
Best check all newer migrations unique to Akkoma/Pleroma
to get an up-to-date picture of what needs to be kept.
## From Source ## From Source
If you're running the source Akkoma install, you'll need to set the If you're running the source Akkoma install, you'll need to set the
@ -34,16 +61,7 @@ git pull -r
# to run "git merge stable" instead (or develop if you want) # to run "git merge stable" instead (or develop if you want)
``` ```
### WARNING - Migrating from Pleroma Develop And compile as usual.
If you are on pleroma develop, and have updated since 2022-08, you may have issues with database migrations.
Please roll back the given migrations:
```bash
MIX_ENV=prod mix ecto.rollback --migrations-path priv/repo/optional_migrations/pleroma_develop_rollbacks -n3
```
Then compile, migrate and restart as usual.
## From OTP ## From OTP
@ -53,15 +71,44 @@ This will just be setting the update URL - find your flavour from the [mapping o
export FLAVOUR=[the flavour you found above] export FLAVOUR=[the flavour you found above]
./bin/pleroma_ctl update --zip-url https://akkoma-updates.s3-website.fr-par.scw.cloud/stable/akkoma-$FLAVOUR.zip ./bin/pleroma_ctl update --zip-url https://akkoma-updates.s3-website.fr-par.scw.cloud/stable/akkoma-$FLAVOUR.zip
./bin/pleroma_ctl migrate
``` ```
Then restart. When updating in the future, you canjust use When updating in the future, you can just use
```bash ```bash
./bin/pleroma_ctl update --branch stable ./bin/pleroma_ctl update --branch stable
``` ```
## Database Migrations
### WARNING - Migrating from Pleroma past 2022-08
If you are on Pleroma stable >= 2.5.0 or Pleroma develop, and
have updated since 2022-08, you may have issues with database migrations.
Please first roll back the given migrations:
=== "OTP"
```bash
./bin/pleroma_ctl rollback --migrations-path priv/repo/optional_migrations/pleroma_develop_rollbacks -n5
```
=== "From Source"
```bash
MIX_ENV=prod mix ecto.rollback --migrations-path priv/repo/optional_migrations/pleroma_develop_rollbacks -n5
```
### Applying Akkoma Database Migrations
Just run
=== "OTP"
```bash
./bin/pleroma_ctl migrate
```
=== "From Source"
```bash
MIX_ENV=prod mix ecto.migrate
```
## Frontend changes ## Frontend changes
Akkoma comes with a few frontend changes as well as backend ones, Akkoma comes with a few frontend changes as well as backend ones,
@ -130,3 +177,4 @@ MIX_ENV=prod mix ecto.rollback --to 20210416051708
``` ```
Then switch back to Pleroma for updates (similar to how was done to migrate to Akkoma), and remove the front-ends. The front-ends are installed in the `frontends` folder in the [static directory](../configuration/static_dir.md). Once you are back to Pleroma, you will need to run the database migrations again. See the Pleroma documentation for this. Then switch back to Pleroma for updates (similar to how was done to migrate to Akkoma), and remove the front-ends. The front-ends are installed in the `frontends` folder in the [static directory](../configuration/static_dir.md). Once you are back to Pleroma, you will need to run the database migrations again. See the Pleroma documentation for this.
After this use your previous backups to restore data from diverging features.

View file

@ -5,7 +5,7 @@
This guide covers a installation using an OTP release. To install Akkoma from source, please check out the corresponding guide for your distro. This guide covers a installation using an OTP release. To install Akkoma from source, please check out the corresponding guide for your distro.
## Pre-requisites ## Pre-requisites
* A machine running Linux with GNU (e.g. Debian, Ubuntu) or musl (e.g. Alpine) libc and an `x86_64` CPU you have root access to. If you are not sure if it's compatible see [Detecting flavour section](#detecting-flavour) below * A machine running Linux with GNU (e.g. Debian, Ubuntu) or musl (e.g. Alpine) libc and an `x86_64` or `arm64` CPU you have root access to. If you are not sure if it's compatible see [Detecting flavour section](#detecting-flavour) below
* For installing OTP releases on RedHat-based distros like Fedora and Centos Stream, please follow [this guide](./otp_redhat_en.md) instead. * For installing OTP releases on RedHat-based distros like Fedora and Centos Stream, please follow [this guide](./otp_redhat_en.md) instead.
* A (sub)domain pointed to the machine * A (sub)domain pointed to the machine
@ -187,18 +187,18 @@ The location of nginx configs is dependent on the distro
=== "Alpine" === "Alpine"
``` ```
cp /opt/akkoma/installation/nginx/akkoma.nginx /etc/nginx/conf.d/akkoma.conf cp /opt/akkoma/installation/akkoma.nginx /etc/nginx/conf.d/akkoma.conf
``` ```
=== "Debian/Ubuntu" === "Debian/Ubuntu"
``` ```
cp /opt/akkoma/installation/nginx/akkoma.nginx /etc/nginx/sites-available/akkoma.conf cp /opt/akkoma/installation/akkoma.nginx /etc/nginx/sites-available/akkoma.conf
ln -s /etc/nginx/sites-available/akkoma.conf /etc/nginx/sites-enabled/akkoma.conf ln -s /etc/nginx/sites-available/akkoma.conf /etc/nginx/sites-enabled/akkoma.conf
``` ```
If your distro does not have either of those you can append `include /etc/nginx/akkoma.conf` to the end of the http section in /etc/nginx/nginx.conf and If your distro does not have either of those you can append `include /etc/nginx/akkoma.conf` to the end of the http section in /etc/nginx/nginx.conf and
```sh ```sh
cp /opt/akkoma/installation/nginx/akkoma.nginx /etc/nginx/akkoma.conf cp /opt/akkoma/installation/akkoma.nginx /etc/nginx/akkoma.conf
``` ```
#### Edit the nginx config #### Edit the nginx config

View file

@ -178,7 +178,7 @@ certbot certonly --standalone --preferred-challenges http -d yourinstance.tld
#### Copy Akkoma nginx configuration to the nginx folder #### Copy Akkoma nginx configuration to the nginx folder
```shell ```shell
cp /opt/akkoma/installation/nginx/akkoma.nginx /etc/nginx/conf.d/akkoma.conf cp /opt/akkoma/installation/akkoma.nginx /etc/nginx/conf.d/akkoma.conf
``` ```
#### Edit the nginx config #### Edit the nginx config

View file

@ -75,9 +75,48 @@ server {
proxy_set_header Host $http_host; proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
location ~ ^/(media|proxy) {
return 404;
}
location / { location / {
proxy_pass http://phoenix; proxy_pass http://phoenix;
} }
}
# Upload and MediaProxy Subdomain
# (see main domain setup for more details)
server {
server_name media.example.tld;
listen 80;
listen [::]:80;
location / {
return 301 https://$server_name$request_uri;
}
}
server {
server_name media.example.tld;
listen 443 ssl http2;
listen [::]:443 ssl http2;
ssl_trusted_certificate /etc/letsencrypt/live/media.example.tld/chain.pem;
ssl_certificate /etc/letsencrypt/live/media.example.tld/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/media.example.tld/privkey.pem;
# .. copy all other the ssl_* and gzip_* stuff from main domain
# the nginx default is 1m, not enough for large media uploads
client_max_body_size 16m;
ignore_invalid_headers off;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
location ~ ^/(media|proxy) { location ~ ^/(media|proxy) {
proxy_cache akkoma_media_cache; proxy_cache akkoma_media_cache;
@ -91,4 +130,8 @@ server {
chunked_transfer_encoding on; chunked_transfer_encoding on;
proxy_pass http://phoenix; proxy_pass http://phoenix;
} }
location / {
return 404;
}
} }

View file

@ -130,6 +130,7 @@ def run(["get-packs" | args]) do
} }
File.write!(Path.join(pack_path, "pack.json"), Jason.encode!(pack_json, pretty: true)) File.write!(Path.join(pack_path, "pack.json"), Jason.encode!(pack_json, pretty: true))
Pleroma.Emoji.reload()
else else
IO.puts(IO.ANSI.format([:bright, :red, "No pack named \"#{pack_name}\" found"])) IO.puts(IO.ANSI.format([:bright, :red, "No pack named \"#{pack_name}\" found"]))
end end
@ -235,6 +236,8 @@ def run(["gen-pack" | args]) do
IO.puts("#{pack_file} has been created with the #{name} pack") IO.puts("#{pack_file} has been created with the #{name} pack")
end end
Pleroma.Emoji.reload()
end end
def run(["reload"]) do def run(["reload"]) do

View file

@ -20,6 +20,7 @@ def run(["gen" | rest]) do
output: :string, output: :string,
output_psql: :string, output_psql: :string,
domain: :string, domain: :string,
media_url: :string,
instance_name: :string, instance_name: :string,
admin_email: :string, admin_email: :string,
notify_email: :string, notify_email: :string,
@ -35,8 +36,7 @@ def run(["gen" | rest]) do
listen_ip: :string, listen_ip: :string,
listen_port: :string, listen_port: :string,
strip_uploads: :string, strip_uploads: :string,
anonymize_uploads: :string, anonymize_uploads: :string
dedupe_uploads: :string
], ],
aliases: [ aliases: [
o: :output, o: :output,
@ -64,6 +64,14 @@ def run(["gen" | rest]) do
":" ":"
) ++ [443] ) ++ [443]
media_url =
get_option(
options,
:media_url,
"What base url will uploads use? (e.g https://media.example.com/media)\n" <>
" Generally this should NOT use the same domain as the instance "
)
name = name =
get_option( get_option(
options, options,
@ -186,14 +194,6 @@ def run(["gen" | rest]) do
"n" "n"
) === "y" ) === "y"
dedupe_uploads =
get_option(
options,
:dedupe_uploads,
"Do you want to deduplicate uploaded files? (y/n)",
"n"
) === "y"
Config.put([:instance, :static_dir], static_dir) Config.put([:instance, :static_dir], static_dir)
secret = :crypto.strong_rand_bytes(64) |> Base.encode64() |> binary_part(0, 64) secret = :crypto.strong_rand_bytes(64) |> Base.encode64() |> binary_part(0, 64)
@ -207,6 +207,7 @@ def run(["gen" | rest]) do
EEx.eval_file( EEx.eval_file(
template_dir <> "/sample_config.eex", template_dir <> "/sample_config.eex",
domain: domain, domain: domain,
media_url: media_url,
port: port, port: port,
email: email, email: email,
notify_email: notify_email, notify_email: notify_email,
@ -230,8 +231,7 @@ def run(["gen" | rest]) do
upload_filters: upload_filters:
upload_filters(%{ upload_filters(%{
strip: strip_uploads, strip: strip_uploads,
anonymize: anonymize_uploads, anonymize: anonymize_uploads
dedupe: dedupe_uploads
}) })
) )
@ -319,13 +319,6 @@ defp upload_filters(filters) when is_map(filters) do
enabled_filters enabled_filters
end end
enabled_filters =
if filters.dedupe do
enabled_filters ++ [Pleroma.Upload.Filter.Dedupe]
else
enabled_filters
end
enabled_filters enabled_filters
end end
end end

View file

@ -30,12 +30,12 @@ def run(["index"]) do
meili_put( meili_put(
"/indexes/objects/settings/ranking-rules", "/indexes/objects/settings/ranking-rules",
[ [
"published:desc",
"words", "words",
"exactness",
"proximity", "proximity",
"typo", "typo",
"exactness",
"attribute", "attribute",
"published:desc",
"sort" "sort"
] ]
) )

View file

@ -0,0 +1,330 @@
# Akkoma: Magically expressive social media
# Copyright © 2024 Akkoma Authors <https://akkoma.dev/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Mix.Tasks.Pleroma.Security do
use Mix.Task
import Ecto.Query
import Mix.Pleroma
alias Pleroma.Config
require Logger
@shortdoc """
Security-related tasks, like e.g. checking for signs past exploits were abused.
"""
# Constants etc
defp local_id_prefix(), do: Pleroma.Web.Endpoint.url() <> "/"
defp local_id_pattern(), do: local_id_prefix() <> "%"
@activity_exts ["activity+json", "activity%2Bjson"]
defp activity_ext_url_patterns() do
for e <- @activity_exts do
for suf <- ["", "?%"] do
# Escape literal % for use in SQL patterns
ee = String.replace(e, "%", "\\%")
"%.#{ee}#{suf}"
end
end
|> List.flatten()
end
# Search for malicious uploads exploiting the lack of Content-Type sanitisation from before 2024-03
def run(["spoof-uploaded"]) do
Logger.put_process_level(self(), :notice)
start_pleroma()
IO.puts("""
+------------------------+
| SPOOF SEARCH UPLOADS |
+------------------------+
Checking if any uploads are using privileged types.
NOTE if attachment deletion is enabled, payloads used
in the past may no longer exist.
""")
do_spoof_uploaded()
end
# Fuzzy search for potentially counterfeit activities in the database resulting from the same exploit
def run(["spoof-inserted"]) do
Logger.put_process_level(self(), :notice)
start_pleroma()
IO.puts("""
+----------------------+
| SPOOF SEARCH NOTES |
+----------------------+
Starting fuzzy search for counterfeit activities.
NOTE this can not guarantee detecting all counterfeits
and may yield a small percentage of false positives.
""")
do_spoof_inserted()
end
# +-----------------------------+
# | S P O O F - U P L O A D E D |
# +-----------------------------+
defp do_spoof_uploaded() do
files =
case Config.get!([Pleroma.Upload, :uploader]) do
Pleroma.Uploaders.Local ->
uploads_search_spoofs_local_dir(Config.get!([Pleroma.Uploaders.Local, :uploads]))
_ ->
IO.puts("""
NOTE:
Not using local uploader; thus not affected by this exploit.
It's impossible to check for files, but in case local uploader was used before
or to check if anyone futilely attempted a spoof, notes will still be scanned.
""")
[]
end
emoji = uploads_search_spoofs_local_dir(Config.get!([:instance, :static_dir]))
post_attachs = uploads_search_spoofs_notes()
not_orphaned_urls =
post_attachs
|> Enum.map(fn {_u, _a, url} -> url end)
|> MapSet.new()
orphaned_attachs = upload_search_orphaned_attachments(not_orphaned_urls)
IO.puts("\nSearch concluded; here are the results:")
pretty_print_list_with_title(emoji, "Emoji")
pretty_print_list_with_title(files, "Uploaded Files")
pretty_print_list_with_title(post_attachs, "(Not Deleted) Post Attachments")
pretty_print_list_with_title(orphaned_attachs, "Orphaned Uploads")
IO.puts("""
In total found
#{length(emoji)} emoji
#{length(files)} uploads
#{length(post_attachs)} not deleted posts
#{length(orphaned_attachs)} orphaned attachments
""")
end
defp uploads_search_spoofs_local_dir(dir) do
local_dir = String.replace_suffix(dir, "/", "")
IO.puts("Searching for suspicious files in #{local_dir}...")
glob_ext = "{" <> Enum.join(@activity_exts, ",") <> "}"
Path.wildcard(local_dir <> "/**/*." <> glob_ext, match_dot: true)
|> Enum.map(fn path ->
String.replace_prefix(path, local_dir <> "/", "")
end)
|> Enum.sort()
end
defp uploads_search_spoofs_notes() do
IO.puts("Now querying DB for posts with spoofing attachments. This might take a while...")
patterns = [local_id_pattern() | activity_ext_url_patterns()]
# if jsonb_array_elemsts in FROM can be used with normal Ecto functions, idk how
"""
SELECT DISTINCT a.data->>'actor', a.id, url->>'href'
FROM public.objects AS o JOIN public.activities AS a
ON o.data->>'id' = a.data->>'object',
jsonb_array_elements(o.data->'attachment') AS attachs,
jsonb_array_elements(attachs->'url') AS url
WHERE o.data->>'type' = 'Note' AND
o.data->>'id' LIKE $1::text AND (
url->>'href' LIKE $2::text OR
url->>'href' LIKE $3::text OR
url->>'href' LIKE $4::text OR
url->>'href' LIKE $5::text
)
ORDER BY a.data->>'actor', a.id, url->>'href';
"""
|> Pleroma.Repo.query!(patterns, timeout: :infinity)
|> map_raw_id_apid_tuple()
end
defp upload_search_orphaned_attachments(not_orphaned_urls) do
IO.puts("""
Now querying DB for orphaned spoofing attachment (i.e. their post was deleted,
but if :cleanup_attachments was not enabled traces remain in the database)
This might take a bit...
""")
patterns = activity_ext_url_patterns()
"""
SELECT DISTINCT attach.id, url->>'href'
FROM public.objects AS attach,
jsonb_array_elements(attach.data->'url') AS url
WHERE (attach.data->>'type' = 'Image' OR
attach.data->>'type' = 'Document')
AND (
url->>'href' LIKE $1::text OR
url->>'href' LIKE $2::text OR
url->>'href' LIKE $3::text OR
url->>'href' LIKE $4::text
)
ORDER BY attach.id, url->>'href';
"""
|> Pleroma.Repo.query!(patterns, timeout: :infinity)
|> then(fn res -> Enum.map(res.rows, fn [id, url] -> {id, url} end) end)
|> Enum.filter(fn {_, url} -> !(url in not_orphaned_urls) end)
end
# +-----------------------------+
# | S P O O F - I N S E R T E D |
# +-----------------------------+
defp do_spoof_inserted() do
IO.puts("""
Searching for local posts whose Create activity has no ActivityPub id...
This is a pretty good indicator, but only for spoofs of local actors
and only if the spoofing happened after around late 2021.
""")
idless_create =
search_local_notes_without_create_id()
|> Enum.sort()
IO.puts("Done.\n")
IO.puts("""
Now trying to weed out other poorly hidden spoofs.
This can't detect all and may have some false positives.
""")
likely_spoofed_posts_set = MapSet.new(idless_create)
sus_pattern_posts =
search_sus_notes_by_id_patterns()
|> Enum.filter(fn r -> !(r in likely_spoofed_posts_set) end)
IO.puts("Done.\n")
IO.puts("""
Finally, searching for spoofed, local user accounts.
(It's impossible to detect spoofed remote users)
""")
spoofed_users = search_bogus_local_users()
pretty_print_list_with_title(sus_pattern_posts, "Maybe Spoofed Posts")
pretty_print_list_with_title(idless_create, "Likely Spoofed Posts")
pretty_print_list_with_title(spoofed_users, "Spoofed local user accounts")
IO.puts("""
In total found:
#{length(spoofed_users)} bogus users
#{length(idless_create)} likely spoofed posts
#{length(sus_pattern_posts)} maybe spoofed posts
""")
end
defp search_local_notes_without_create_id() do
Pleroma.Object
|> where([o], fragment("?->>'id' LIKE ?", o.data, ^local_id_pattern()))
|> join(:inner, [o], a in Pleroma.Activity,
on: fragment("?->>'object' = ?->>'id'", a.data, o.data)
)
|> where([o, a], fragment("NOT (? \\? 'id') OR ?->>'id' IS NULL", a.data, a.data))
|> select([o, a], {a.id, fragment("?->>'id'", o.data)})
|> order_by([o, a], a.id)
|> Pleroma.Repo.all(timeout: :infinity)
end
defp search_sus_notes_by_id_patterns() do
[ep1, ep2, ep3, ep4] = activity_ext_url_patterns()
Pleroma.Object
|> where(
[o],
# for local objects we know exactly how a genuine id looks like
# (though a thorough attacker can emulate this)
# for remote posts, use some best-effort patterns
fragment(
"""
(?->>'id' LIKE ? AND ?->>'id' NOT SIMILAR TO
? || 'objects/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}')
""",
o.data,
^local_id_pattern(),
o.data,
^local_id_prefix()
) or
fragment("?->>'id' LIKE ?", o.data, "%/emoji/%") or
fragment("?->>'id' LIKE ?", o.data, "%/media/%") or
fragment("?->>'id' LIKE ?", o.data, "%/proxy/%") or
fragment("?->>'id' LIKE ?", o.data, ^ep1) or
fragment("?->>'id' LIKE ?", o.data, ^ep2) or
fragment("?->>'id' LIKE ?", o.data, ^ep3) or
fragment("?->>'id' LIKE ?", o.data, ^ep4)
)
|> join(:inner, [o], a in Pleroma.Activity,
on: fragment("?->>'object' = ?->>'id'", a.data, o.data)
)
|> select([o, a], {a.id, fragment("?->>'id'", o.data)})
|> order_by([o, a], a.id)
|> Pleroma.Repo.all(timeout: :infinity)
end
defp search_bogus_local_users() do
Pleroma.User.Query.build(%{})
|> where([u], u.local == false and like(u.ap_id, ^local_id_pattern()))
|> order_by([u], u.ap_id)
|> select([u], u.ap_id)
|> Pleroma.Repo.all(timeout: :infinity)
end
# +-----------------------------------+
# | module-specific utility functions |
# +-----------------------------------+
defp pretty_print_list_with_title(list, title) do
title_len = String.length(title)
title_underline = String.duplicate("=", title_len)
IO.puts(title)
IO.puts(title_underline)
pretty_print_list(list)
end
defp pretty_print_list([]), do: IO.puts("")
defp pretty_print_list([{a, o} | rest])
when (is_binary(a) or is_number(a)) and is_binary(o) do
IO.puts(" {#{a}, #{o}}")
pretty_print_list(rest)
end
defp pretty_print_list([{u, a, o} | rest])
when is_binary(a) and is_binary(u) and is_binary(o) do
IO.puts(" {#{u}, #{a}, #{o}}")
pretty_print_list(rest)
end
defp pretty_print_list([e | rest]) when is_binary(e) do
IO.puts(" #{e}")
pretty_print_list(rest)
end
defp pretty_print_list([e | rest]), do: pretty_print_list([inspect(e) | rest])
defp map_raw_id_apid_tuple(res) do
user_prefix = local_id_prefix() <> "users/"
Enum.map(res.rows, fn
[uid, aid, oid] ->
{
String.replace_prefix(uid, user_prefix, ""),
FlakeId.to_string(aid),
oid
}
end)
end
end

View file

@ -11,6 +11,7 @@ defmodule Mix.Tasks.Pleroma.User do
alias Pleroma.UserInviteToken alias Pleroma.UserInviteToken
alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.Builder
alias Pleroma.Web.ActivityPub.Pipeline alias Pleroma.Web.ActivityPub.Pipeline
use Pleroma.Web, :verified_routes
@shortdoc "Manages Pleroma users" @shortdoc "Manages Pleroma users"
@moduledoc File.read!("docs/docs/administration/CLI_tasks/user.md") @moduledoc File.read!("docs/docs/administration/CLI_tasks/user.md")
@ -113,11 +114,7 @@ def run(["reset_password", nickname]) do
{:ok, token} <- Pleroma.PasswordResetToken.create_token(user) do {:ok, token} <- Pleroma.PasswordResetToken.create_token(user) do
shell_info("Generated password reset token for #{user.nickname}") shell_info("Generated password reset token for #{user.nickname}")
IO.puts( IO.puts("URL: #{~p[/api/v1/pleroma/password_reset/#{token.token}]}")
"URL: #{Pleroma.Web.Router.Helpers.reset_password_url(Pleroma.Web.Endpoint,
:reset,
token.token)}"
)
else else
_ -> _ ->
shell_error("No local user #{nickname}") shell_error("No local user #{nickname}")
@ -303,13 +300,7 @@ def run(["invite" | rest]) do
{:ok, invite} <- UserInviteToken.create_invite(options) do {:ok, invite} <- UserInviteToken.create_invite(options) do
shell_info("Generated user invite token " <> String.replace(invite.invite_type, "_", " ")) shell_info("Generated user invite token " <> String.replace(invite.invite_type, "_", " "))
url = url = url(~p[/registration/#{invite.token}])
Pleroma.Web.Router.Helpers.redirect_url(
Pleroma.Web.Endpoint,
:registration_page,
invite.token
)
IO.puts(url) IO.puts(url)
else else
error -> error ->

View file

@ -26,7 +26,6 @@ def init(%Plug.Conn{method: "GET"} = conn, {endpoint, handler, transport}) do
conn conn
|> fetch_query_params |> fetch_query_params
|> Transport.transport_log(opts[:transport_log]) |> Transport.transport_log(opts[:transport_log])
|> Transport.force_ssl(handler, endpoint, opts)
|> Transport.check_origin(handler, endpoint, opts) |> Transport.check_origin(handler, endpoint, opts)
case conn do case conn do

View file

@ -26,6 +26,15 @@ def prune_undos do
|> Repo.delete_all(timeout: :infinity) |> Repo.delete_all(timeout: :infinity)
end end
def prune_updates do
before_time = cutoff()
from(a in Activity,
where: fragment("?->>'type' = ?", a.data, "Update") and a.inserted_at < ^before_time
)
|> Repo.delete_all(timeout: :infinity)
end
def prune_removes do def prune_removes do
before_time = cutoff() before_time = cutoff()

View file

@ -23,7 +23,7 @@ def load(config, opts) do
with_runtime_config = with_runtime_config =
if File.exists?(config_path) do if File.exists?(config_path) do
# <https://git.pleroma.social/pleroma/pleroma/-/issues/3135> # <https://git.pleroma.social/pleroma/pleroma/-/issues/3135>
%File.Stat{mode: mode} = File.lstat!(config_path) %File.Stat{mode: mode} = File.stat!(config_path)
if Bitwise.band(mode, 0o007) > 0 do if Bitwise.band(mode, 0o007) > 0 do
raise "Configuration at #{config_path} has world-permissions, execute the following: chmod o= #{config_path}" raise "Configuration at #{config_path} has world-permissions, execute the following: chmod o= #{config_path}"

View file

@ -6,10 +6,13 @@ defmodule Pleroma.Emails.AdminEmail do
@moduledoc "Admin emails" @moduledoc "Admin emails"
import Swoosh.Email import Swoosh.Email
use Pleroma.Web, :mailer
alias Pleroma.Config alias Pleroma.Config
alias Pleroma.HTML alias Pleroma.HTML
alias Pleroma.Web.Router.Helpers
use Phoenix.VerifiedRoutes,
endpoint: Pleroma.Web.Endpoint,
router: Pleroma.Web.Router
defp instance_config, do: Config.get(:instance) defp instance_config, do: Config.get(:instance)
defp instance_name, do: instance_config()[:name] defp instance_name, do: instance_config()[:name]
@ -45,7 +48,7 @@ def report(to, reporter, account, statuses, comment) do
statuses statuses
|> Enum.map(fn |> Enum.map(fn
%{id: id} -> %{id: id} ->
status_url = Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, id) status_url = url(~p[/notice/#{id}])
"<li><a href=\"#{status_url}\">#{status_url}</li>" "<li><a href=\"#{status_url}\">#{status_url}</li>"
%{"id" => id} when is_binary(id) -> %{"id" => id} when is_binary(id) ->

View file

@ -55,12 +55,61 @@ def deliver!(email, config) do
@doc false @doc false
def validate_dependency do def validate_dependency do
parse_config([]) parse_config([], defaults: false)
|> Keyword.get(:adapter) |> Keyword.get(:adapter)
|> Swoosh.Mailer.validate_dependency() |> Swoosh.Mailer.validate_dependency()
end end
defp parse_config(config) do defp ensure_charlist(input) do
Swoosh.Mailer.parse_config(@otp_app, __MODULE__, @mailer_config, config) case input do
i when is_binary(i) -> String.to_charlist(input)
i when is_list(i) -> i
end
end
defp default_config(adapter, conf, opts)
defp default_config(_, _, defaults: false) do
[]
end
defp default_config(Swoosh.Adapters.SMTP, conf, _) do
# gen_smtp and Erlang's tls defaults are very barebones, if nothing is set.
# Add sane defaults for our usecase to make config less painful for admins
relay = ensure_charlist(Keyword.get(conf, :relay))
ssl_disabled = Keyword.get(conf, :ssl) === false
os_cacerts = :public_key.cacerts_get()
common_tls_opts = [
cacerts: os_cacerts,
versions: [:"tlsv1.2", :"tlsv1.3"],
verify: :verify_peer,
# some versions have supposedly issues verifying wildcard certs without this
server_name_indication: relay,
# the default of 10 is too restrictive
depth: 32
]
[
auth: :always,
no_mx_lookups: false,
# Direct SSL/TLS
# (if ssl was explicitly disabled, we must not pass TLS options to the socket)
ssl: true,
sockopts: if(ssl_disabled, do: [], else: common_tls_opts),
# STARTTLS upgrade (can't be set to :always when already using direct TLS)
tls: :if_available,
tls_options: common_tls_opts
]
end
defp default_config(_, _, _), do: []
defp parse_config(config, opts \\ []) do
conf = Swoosh.Mailer.parse_config(@otp_app, __MODULE__, @mailer_config, config)
adapter = Keyword.get(conf, :adapter)
default_config(adapter, conf, opts)
|> Keyword.merge(conf)
end end
end end

View file

@ -6,12 +6,11 @@ defmodule Pleroma.Emails.UserEmail do
@moduledoc "User emails" @moduledoc "User emails"
require Pleroma.Web.Gettext require Pleroma.Web.Gettext
use Pleroma.Web, :mailer
alias Pleroma.Config alias Pleroma.Config
alias Pleroma.User alias Pleroma.User
alias Pleroma.Web.Endpoint
alias Pleroma.Web.Gettext alias Pleroma.Web.Gettext
alias Pleroma.Web.Router
import Swoosh.Email import Swoosh.Email
import Phoenix.Swoosh, except: [render_body: 3] import Phoenix.Swoosh, except: [render_body: 3]
@ -75,7 +74,7 @@ def welcome(user, opts \\ %{}) do
def password_reset_email(user, token) when is_binary(token) do def password_reset_email(user, token) when is_binary(token) do
Gettext.with_locale_or_default user.language do Gettext.with_locale_or_default user.language do
password_reset_url = Router.Helpers.reset_password_url(Endpoint, :reset, token) password_reset_url = url(~p[/api/v1/pleroma/password_reset/#{token}])
html_body = html_body =
Gettext.dpgettext( Gettext.dpgettext(
@ -108,12 +107,7 @@ def user_invitation_email(
to_name \\ nil to_name \\ nil
) do ) do
Gettext.with_locale_or_default user.language do Gettext.with_locale_or_default user.language do
registration_url = registration_url = url(~p[/registration/#{user_invite_token.token}])
Router.Helpers.redirect_url(
Endpoint,
:registration_page,
user_invite_token.token
)
html_body = html_body =
Gettext.dpgettext( Gettext.dpgettext(
@ -146,13 +140,7 @@ def user_invitation_email(
def account_confirmation_email(user) do def account_confirmation_email(user) do
Gettext.with_locale_or_default user.language do Gettext.with_locale_or_default user.language do
confirmation_url = confirmation_url = url(~p[/api/account/confirm_email/#{user.id}/#{user.confirmation_token}])
Router.Helpers.confirm_email_url(
Endpoint,
:confirm_email,
user.id,
to_string(user.confirmation_token)
)
html_body = html_body =
Gettext.dpgettext( Gettext.dpgettext(
@ -342,7 +330,7 @@ def unsubscribe_url(user, notifications_type) do
|> Pleroma.JWT.generate_and_sign!() |> Pleroma.JWT.generate_and_sign!()
|> Base.encode64() |> Base.encode64()
Router.Helpers.subscription_url(Endpoint, :unsubscribe, token) url(~p[/mailer/unsubscribe/#{token}])
end end
def backup_is_ready_email(backup, admin_user_id \\ nil) do def backup_is_ready_email(backup, admin_user_id \\ nil) do

View file

@ -26,12 +26,37 @@ defmodule Pleroma.Emoji.Pack do
alias Pleroma.Emoji.Pack alias Pleroma.Emoji.Pack
alias Pleroma.Utils alias Pleroma.Utils
# Invalid/Malicious names are supposed to be filtered out before path joining,
# but there are many entrypoints to affected functions so as the code changes
# we might accidentally let an unsanitised name slip through.
# To make sure, use the below which crash the process otherwise.
# ALWAYS use this when constructing paths from external name!
# (name meaning it must be only a single path component)
defp path_join_name_safe(dir, name) do
if to_string(name) != Path.basename(name) or name in ["..", ".", ""] do
raise "Invalid or malicious pack name: #{name}"
else
Path.join(dir, name)
end
end
# ALWAYS use this to join external paths
# (which are allowed to have several components)
defp path_join_safe(dir, path) do
{:ok, safe_path} = Path.safe_relative(path)
Path.join(dir, safe_path)
end
@spec create(String.t()) :: {:ok, t()} | {:error, File.posix()} | {:error, :empty_values} @spec create(String.t()) :: {:ok, t()} | {:error, File.posix()} | {:error, :empty_values}
def create(name) do def create(name) do
with :ok <- validate_not_empty([name]), with :ok <- validate_not_empty([name]),
dir <- Path.join(emoji_path(), name), dir <- path_join_name_safe(emoji_path(), name),
:ok <- File.mkdir(dir) do :ok <- File.mkdir(dir) do
save_pack(%__MODULE__{pack_file: Path.join(dir, "pack.json")}) save_pack(%__MODULE__{
path: dir,
pack_file: Path.join(dir, "pack.json")
})
end end
end end
@ -65,7 +90,7 @@ def show(opts) do
{:ok, [binary()]} | {:error, File.posix(), binary()} | {:error, :empty_values} {:ok, [binary()]} | {:error, File.posix(), binary()} | {:error, :empty_values}
def delete(name) do def delete(name) do
with :ok <- validate_not_empty([name]), with :ok <- validate_not_empty([name]),
pack_path <- Path.join(emoji_path(), name) do pack_path <- path_join_name_safe(emoji_path(), name) do
File.rm_rf(pack_path) File.rm_rf(pack_path)
end end
end end
@ -89,7 +114,7 @@ defp unpack_zip_emojies(zip_files) do
end) end)
end end
@spec add_file(t(), String.t(), Path.t(), Plug.Upload.t()) :: @spec add_file(t(), String.t(), Path.t(), Plug.Upload.t() | binary()) ::
{:ok, t()} {:ok, t()}
| {:error, File.posix() | atom()} | {:error, File.posix() | atom()}
def add_file(%Pack{} = pack, _, _, %Plug.Upload{content_type: "application/zip"} = file) do def add_file(%Pack{} = pack, _, _, %Plug.Upload{content_type: "application/zip"} = file) do
@ -107,7 +132,7 @@ def add_file(%Pack{} = pack, _, _, %Plug.Upload{content_type: "application/zip"}
Enum.map_reduce(emojies, pack, fn item, emoji_pack -> Enum.map_reduce(emojies, pack, fn item, emoji_pack ->
emoji_file = %Plug.Upload{ emoji_file = %Plug.Upload{
filename: item[:filename], filename: item[:filename],
path: Path.join(tmp_dir, item[:path]) path: path_join_safe(tmp_dir, item[:path])
} }
{:ok, updated_pack} = {:ok, updated_pack} =
@ -137,6 +162,14 @@ def add_file(%Pack{} = pack, _, _, %Plug.Upload{content_type: "application/zip"}
end end
def add_file(%Pack{} = pack, shortcode, filename, %Plug.Upload{} = file) do def add_file(%Pack{} = pack, shortcode, filename, %Plug.Upload{} = file) do
try_add_file(pack, shortcode, filename, file)
end
def add_file(%Pack{} = pack, shortcode, filename, filedata) when is_binary(filedata) do
try_add_file(pack, shortcode, filename, filedata)
end
defp try_add_file(%Pack{} = pack, shortcode, filename, file) do
with :ok <- validate_not_empty([shortcode, filename]), with :ok <- validate_not_empty([shortcode, filename]),
:ok <- validate_emoji_not_exists(shortcode), :ok <- validate_emoji_not_exists(shortcode),
{:ok, updated_pack} <- do_add_file(pack, shortcode, filename, file) do {:ok, updated_pack} <- do_add_file(pack, shortcode, filename, file) do
@ -189,6 +222,7 @@ def import_from_filesystem do
{:ok, results} <- File.ls(emoji_path) do {:ok, results} <- File.ls(emoji_path) do
names = names =
results results
# items come from File.ls, thus safe
|> Enum.map(&Path.join(emoji_path, &1)) |> Enum.map(&Path.join(emoji_path, &1))
|> Enum.reject(fn path -> |> Enum.reject(fn path ->
File.dir?(path) and File.exists?(Path.join(path, "pack.json")) File.dir?(path) and File.exists?(Path.join(path, "pack.json"))
@ -287,8 +321,8 @@ def update_metadata(name, data) do
@spec load_pack(String.t()) :: {:ok, t()} | {:error, :file.posix()} @spec load_pack(String.t()) :: {:ok, t()} | {:error, :file.posix()}
def load_pack(name) do def load_pack(name) do
name = Path.basename(name) pack_dir = path_join_name_safe(emoji_path(), name)
pack_file = Path.join([emoji_path(), name, "pack.json"]) pack_file = Path.join(pack_dir, "pack.json")
with {:ok, _} <- File.stat(pack_file), with {:ok, _} <- File.stat(pack_file),
{:ok, pack_data} <- File.read(pack_file) do {:ok, pack_data} <- File.read(pack_file) do
@ -412,7 +446,13 @@ defp downloadable?(pack) do
end end
defp create_archive_and_cache(pack, hash) do defp create_archive_and_cache(pack, hash) do
files = [~c"pack.json" | Enum.map(pack.files, fn {_, file} -> to_charlist(file) end)] files = [
~c"pack.json"
| Enum.map(pack.files, fn {_, file} ->
{:ok, file} = Path.safe_relative(file)
to_charlist(file)
end)
]
{:ok, {_, result}} = {:ok, {_, result}} =
:zip.zip(~c"#{pack.name}.zip", files, [:memory, cwd: to_charlist(pack.path)]) :zip.zip(~c"#{pack.name}.zip", files, [:memory, cwd: to_charlist(pack.path)])
@ -474,7 +514,7 @@ defp validate_not_empty(list) do
end end
defp save_file(%Plug.Upload{path: upload_path}, pack, filename) do defp save_file(%Plug.Upload{path: upload_path}, pack, filename) do
file_path = Path.join(pack.path, filename) file_path = path_join_safe(pack.path, filename)
create_subdirs(file_path) create_subdirs(file_path)
with {:ok, _} <- File.copy(upload_path, file_path) do with {:ok, _} <- File.copy(upload_path, file_path) do
@ -482,6 +522,12 @@ defp save_file(%Plug.Upload{path: upload_path}, pack, filename) do
end end
end end
defp save_file(file_data, pack, filename) when is_binary(file_data) do
file_path = path_join_safe(pack.path, filename)
create_subdirs(file_path)
File.write(file_path, file_data, [:binary])
end
defp put_emoji(pack, shortcode, filename) do defp put_emoji(pack, shortcode, filename) do
files = Map.put(pack.files, shortcode, filename) files = Map.put(pack.files, shortcode, filename)
%{pack | files: files, files_count: length(Map.keys(files))} %{pack | files: files, files_count: length(Map.keys(files))}
@ -493,8 +539,8 @@ defp delete_emoji(pack, shortcode) do
end end
defp rename_file(pack, filename, new_filename) do defp rename_file(pack, filename, new_filename) do
old_path = Path.join(pack.path, filename) old_path = path_join_safe(pack.path, filename)
new_path = Path.join(pack.path, new_filename) new_path = path_join_safe(pack.path, new_filename)
create_subdirs(new_path) create_subdirs(new_path)
with :ok <- File.rename(old_path, new_path) do with :ok <- File.rename(old_path, new_path) do
@ -512,7 +558,7 @@ defp create_subdirs(file_path) do
defp remove_file(pack, shortcode) do defp remove_file(pack, shortcode) do
with {:ok, filename} <- get_filename(pack, shortcode), with {:ok, filename} <- get_filename(pack, shortcode),
emoji <- Path.join(pack.path, filename), emoji <- path_join_safe(pack.path, filename),
:ok <- File.rm(emoji) do :ok <- File.rm(emoji) do
remove_dir_if_empty(emoji, filename) remove_dir_if_empty(emoji, filename)
end end
@ -530,7 +576,7 @@ defp remove_dir_if_empty(emoji, filename) do
defp get_filename(pack, shortcode) do defp get_filename(pack, shortcode) do
with %{^shortcode => filename} when is_binary(filename) <- pack.files, with %{^shortcode => filename} when is_binary(filename) <- pack.files,
file_path <- Path.join(pack.path, filename), file_path <- path_join_safe(pack.path, filename),
{:ok, _} <- File.stat(file_path) do {:ok, _} <- File.stat(file_path) do
{:ok, filename} {:ok, filename}
else else
@ -568,7 +614,7 @@ defp validate_downloadable(pack) do
end end
defp copy_as(remote_pack, local_name) do defp copy_as(remote_pack, local_name) do
path = Path.join(emoji_path(), local_name) path = path_join_name_safe(emoji_path(), local_name)
%__MODULE__{ %__MODULE__{
name: local_name, name: local_name,

View file

@ -15,8 +15,19 @@ def start_link(_) do
@impl true @impl true
def init(state) do def init(state) do
:telemetry.attach("oban-monitor-failure", [:oban, :job, :exception], &handle_event/4, nil) :telemetry.attach(
:telemetry.attach("oban-monitor-success", [:oban, :job, :stop], &handle_event/4, nil) "oban-monitor-failure",
[:oban, :job, :exception],
&Pleroma.JobQueueMonitor.handle_event/4,
nil
)
:telemetry.attach(
"oban-monitor-success",
[:oban, :job, :stop],
&Pleroma.JobQueueMonitor.handle_event/4,
nil
)
{:ok, state} {:ok, state}
end end

View file

@ -11,6 +11,9 @@ defmodule Pleroma.Object.Containment do
Object containment is an important step in validating remote objects to prevent Object containment is an important step in validating remote objects to prevent
spoofing, therefore removal of object containment functions is NOT recommended. spoofing, therefore removal of object containment functions is NOT recommended.
""" """
alias Pleroma.Web.ActivityPub.Transmogrifier
def get_actor(%{"actor" => actor}) when is_binary(actor) do def get_actor(%{"actor" => actor}) when is_binary(actor) do
actor actor
end end
@ -47,6 +50,31 @@ def get_object(_) do
defp compare_uris(%URI{host: host} = _id_uri, %URI{host: host} = _other_uri), do: :ok defp compare_uris(%URI{host: host} = _id_uri, %URI{host: host} = _other_uri), do: :ok
defp compare_uris(_id_uri, _other_uri), do: :error defp compare_uris(_id_uri, _other_uri), do: :error
defp compare_uris_exact(uri, uri), do: :ok
defp compare_uris_exact(%URI{} = id, %URI{} = other),
do: compare_uris_exact(URI.to_string(id), URI.to_string(other))
defp compare_uris_exact(id_uri, other_uri)
when is_binary(id_uri) and is_binary(other_uri) do
norm_id = String.replace_suffix(id_uri, "/", "")
norm_other = String.replace_suffix(other_uri, "/", "")
if norm_id == norm_other, do: :ok, else: :error
end
@doc """
Checks whether an URL to fetch from is from the local server.
We never want to fetch from ourselves; if its not in the database
it cant be authentic and must be a counterfeit.
"""
def contain_local_fetch(id) do
case compare_uris(URI.parse(id), Pleroma.Web.Endpoint.struct_url()) do
:ok -> :error
_ -> :ok
end
end
@doc """ @doc """
Checks that an imported AP object's actor matches the host it came from. Checks that an imported AP object's actor matches the host it came from.
""" """
@ -62,8 +90,31 @@ def contain_origin(id, %{"actor" => _actor} = params) do
def contain_origin(id, %{"attributedTo" => actor} = params), def contain_origin(id, %{"attributedTo" => actor} = params),
do: contain_origin(id, Map.put(params, "actor", actor)) do: contain_origin(id, Map.put(params, "actor", actor))
def contain_origin(_id, _data), do: :error def contain_origin(_id, _data), do: :ok
@doc """
Check whether the fetch URL (after redirects) exactly (sans tralining slash) matches either
the canonical ActivityPub id or the objects url field (for display URLs from *key and Mastodon)
Since this is meant to be used for fetches, anonymous or transient objects are not accepted here.
"""
def contain_id_to_fetch(url, %{"id" => id} = data) when is_binary(id) do
with {:id, :error} <- {:id, compare_uris_exact(id, url)},
# "url" can be a "Link" object and this is checked before full normalisation
display_url <- Transmogrifier.fix_url(data)["url"],
true <- display_url != nil do
compare_uris_exact(display_url, url)
else
{:id, :ok} -> :ok
_ -> :error
end
end
def contain_id_to_fetch(_url, _data), do: :error
@doc """
Check whether the object id is from the same host as another id
"""
def contain_origin_from_id(id, %{"id" => other_id} = _params) when is_binary(other_id) do def contain_origin_from_id(id, %{"id" => other_id} = _params) when is_binary(other_id) do
id_uri = URI.parse(id) id_uri = URI.parse(id)
other_uri = URI.parse(other_id) other_uri = URI.parse(other_id)
@ -85,4 +136,12 @@ def contain_child(%{"object" => %{"id" => id, "attributedTo" => _} = object}),
do: contain_origin(id, object) do: contain_origin(id, object)
def contain_child(_), do: :ok def contain_child(_), do: :ok
@doc "Checks whether two URIs belong to the same domain"
def same_origin(id1, id2) do
uri1 = URI.parse(id1)
uri2 = URI.parse(id2)
compare_uris(uri1, uri2)
end
end end

View file

@ -18,6 +18,16 @@ defmodule Pleroma.Object.Fetcher do
require Logger require Logger
require Pleroma.Constants require Pleroma.Constants
@moduledoc """
This module deals with correctly fetching Acitivity Pub objects in a safe way.
The core function is `fetch_and_contain_remote_object_from_id/1` which performs
the actual fetch and common safety and authenticity checks. Other `fetch_*`
function use the former and perform some additional tasks
"""
@mix_env Mix.env()
defp touch_changeset(changeset) do defp touch_changeset(changeset) do
updated_at = updated_at =
NaiveDateTime.utc_now() NaiveDateTime.utc_now()
@ -103,18 +113,26 @@ defp reinject_object(%Object{} = object, new_data) do
end end
end end
@doc "Assumes object already is in our database and refetches from remote to update (e.g. for polls)"
def refetch_object(%Object{data: %{"id" => id}} = object) do def refetch_object(%Object{data: %{"id" => id}} = object) do
with {:local, false} <- {:local, Object.local?(object)}, with {:local, false} <- {:local, Object.local?(object)},
{:ok, new_data} <- fetch_and_contain_remote_object_from_id(id), {:ok, new_data} <- fetch_and_contain_remote_object_from_id(id),
{:id, true} <- {:id, new_data["id"] == id},
{:ok, object} <- reinject_object(object, new_data) do {:ok, object} <- reinject_object(object, new_data) do
{:ok, object} {:ok, object}
else else
{:local, true} -> {:ok, object} {:local, true} -> {:ok, object}
{:id, false} -> {:error, "Object id changed on refetch"}
e -> {:error, e} e -> {:error, e}
end end
end end
# Note: will create a Create activity, which we need internally at the moment. @doc """
Fetches a new object and puts it through the processing pipeline for inbound objects
Note: will also insert a fake Create activity, since atm we internally
need everything to be traced back to a Create activity.
"""
def fetch_object_from_id(id, options \\ []) do def fetch_object_from_id(id, options \\ []) do
with %URI{} = uri <- URI.parse(id), with %URI{} = uri <- URI.parse(id),
# let's check the URI is even vaguely valid first # let's check the URI is even vaguely valid first
@ -127,7 +145,6 @@ def fetch_object_from_id(id, options \\ []) do
{_, {:ok, data}} <- {:fetch, fetch_and_contain_remote_object_from_id(id)}, {_, {:ok, data}} <- {:fetch, fetch_and_contain_remote_object_from_id(id)},
{_, nil} <- {:normalize, Object.normalize(data, fetch: false)}, {_, nil} <- {:normalize, Object.normalize(data, fetch: false)},
params <- prepare_activity_params(data), params <- prepare_activity_params(data),
{_, :ok} <- {:containment, Containment.contain_origin(id, params)},
{_, {:ok, activity}} <- {_, {:ok, activity}} <-
{:transmogrifier, Transmogrifier.handle_incoming(params, options)}, {:transmogrifier, Transmogrifier.handle_incoming(params, options)},
{_, _data, %Object{} = object} <- {_, _data, %Object{} = object} <-
@ -140,9 +157,6 @@ def fetch_object_from_id(id, options \\ []) do
{:scheme, false} -> {:scheme, false} ->
{:error, "URI Scheme Invalid"} {:error, "URI Scheme Invalid"}
{:containment, _} ->
{:error, "Object containment failed."}
{:transmogrifier, {:error, {:reject, e}}} -> {:transmogrifier, {:error, {:reject, e}}} ->
{:reject, e} {:reject, e}
@ -185,6 +199,7 @@ defp prepare_activity_params(data) do
|> Maps.put_if_present("bcc", data["bcc"]) |> Maps.put_if_present("bcc", data["bcc"])
end end
@doc "Identical to `fetch_object_from_id/2` but just directly returns the object or on error `nil`"
def fetch_object_from_id!(id, options \\ []) do def fetch_object_from_id!(id, options \\ []) do
with {:ok, object} <- fetch_object_from_id(id, options) do with {:ok, object} <- fetch_object_from_id(id, options) do
object object
@ -235,6 +250,7 @@ defp maybe_date_fetch(headers, date) do
end end
end end
@doc "Fetches arbitrary remote object and performs basic safety and authenticity checks"
def fetch_and_contain_remote_object_from_id(id) def fetch_and_contain_remote_object_from_id(id)
def fetch_and_contain_remote_object_from_id(%{"id" => id}), def fetch_and_contain_remote_object_from_id(%{"id" => id}),
@ -244,18 +260,29 @@ def fetch_and_contain_remote_object_from_id(id) when is_binary(id) do
Logger.debug("Fetching object #{id} via AP") Logger.debug("Fetching object #{id} via AP")
with {:scheme, true} <- {:scheme, String.starts_with?(id, "http")}, with {:scheme, true} <- {:scheme, String.starts_with?(id, "http")},
{:ok, body} <- get_object(id), {_, :ok} <- {:local_fetch, Containment.contain_local_fetch(id)},
{:ok, final_id, body} <- get_object(id),
{:ok, data} <- safe_json_decode(body), {:ok, data} <- safe_json_decode(body),
:ok <- Containment.contain_origin_from_id(id, data) do {_, :ok} <- {:strict_id, Containment.contain_id_to_fetch(final_id, data)},
unless Instances.reachable?(id) do {_, :ok} <- {:containment, Containment.contain_origin(final_id, data)} do
Instances.set_reachable(id) unless Instances.reachable?(final_id) do
Instances.set_reachable(final_id)
end end
{:ok, data} {:ok, data}
else else
{:strict_id, _} ->
{:error, "Object's ActivityPub id/url does not match final fetch URL"}
{:scheme, _} -> {:scheme, _} ->
{:error, "Unsupported URI scheme"} {:error, "Unsupported URI scheme"}
{:local_fetch, _} ->
{:error, "Trying to fetch local resource"}
{:containment, _} ->
{:error, "Object containment failed."}
{:error, e} -> {:error, e} ->
{:error, e} {:error, e}
@ -267,6 +294,32 @@ def fetch_and_contain_remote_object_from_id(id) when is_binary(id) do
def fetch_and_contain_remote_object_from_id(_id), def fetch_and_contain_remote_object_from_id(_id),
do: {:error, "id must be a string"} do: {:error, "id must be a string"}
defp check_crossdomain_redirect(final_host, original_url)
# HOPEFULLY TEMPORARY
# Basically none of our Tesla mocks in tests set the (supposed to
# exist for Tesla proper) url parameter for their responses
# causing almost every fetch in test to fail otherwise
if @mix_env == :test do
defp check_crossdomain_redirect(nil, _) do
{:cross_domain_redirect, false}
end
end
defp check_crossdomain_redirect(final_host, original_url) do
{:cross_domain_redirect, final_host != URI.parse(original_url).host}
end
if @mix_env == :test do
defp get_final_id(nil, initial_url), do: initial_url
defp get_final_id("", initial_url), do: initial_url
end
defp get_final_id(final_url, _intial_url) do
final_url
end
@doc "Do NOT use; only public for use in tests"
def get_object(id) do def get_object(id) do
date = Pleroma.Signature.signed_date() date = Pleroma.Signature.signed_date()
@ -275,37 +328,42 @@ def get_object(id) do
|> maybe_date_fetch(date) |> maybe_date_fetch(date)
|> sign_fetch(id, date) |> sign_fetch(id, date)
case HTTP.get(id, headers) do with {:ok, %{body: body, status: code, headers: headers, url: final_url}}
{:ok, %{body: body, status: code, headers: headers}} when code in 200..299 -> when code in 200..299 <-
case List.keyfind(headers, "content-type", 0) do HTTP.get(id, headers),
{_, content_type} -> remote_host <-
case Plug.Conn.Utils.media_type(content_type) do URI.parse(final_url).host,
{:ok, "application", "activity+json", _} -> {:cross_domain_redirect, false} <-
{:ok, body} check_crossdomain_redirect(remote_host, id),
{:has_content_type, {_, content_type}} <-
{:has_content_type, List.keyfind(headers, "content-type", 0)},
{:parse_content_type, {:ok, "application", subtype, type_params}} <-
{:parse_content_type, Plug.Conn.Utils.media_type(content_type)} do
final_id = get_final_id(final_url, id)
{:ok, "application", "ld+json", case {subtype, type_params} do
%{"profile" => "https://www.w3.org/ns/activitystreams"}} -> {"activity+json", _} ->
{:ok, body} {:ok, final_id, body}
# pixelfed sometimes (and only sometimes) responds with http instead of https {"ld+json", %{"profile" => "https://www.w3.org/ns/activitystreams"}} ->
{:ok, "application", "ld+json", {:ok, final_id, body}
%{"profile" => "http://www.w3.org/ns/activitystreams"}} ->
{:ok, body}
_ ->
{:error, {:content_type, content_type}}
end
_ ->
{:error, {:content_type, nil}}
end
_ ->
{:error, {:content_type, content_type}}
end
else
{:ok, %{status: code}} when code in [404, 410] -> {:ok, %{status: code}} when code in [404, 410] ->
{:error, {"Object has been deleted", id, code}} {:error, {"Object has been deleted", id, code}}
{:error, e} -> {:error, e} ->
{:error, e} {:error, e}
{:has_content_type, _} ->
{:error, {:content_type, nil}}
{:parse_content_type, e} ->
{:error, {:content_type, e}}
e -> e ->
{:error, e} {:error, e}
end end

View file

@ -17,6 +17,8 @@ defmodule Pleroma.ReverseProxy do
@failed_request_ttl :timer.seconds(60) @failed_request_ttl :timer.seconds(60)
@methods ~w(GET HEAD) @methods ~w(GET HEAD)
@allowed_mime_types Pleroma.Config.get([Pleroma.Upload, :allowed_mime_types], [])
@cachex Pleroma.Config.get([:cachex, :provider], Cachex) @cachex Pleroma.Config.get([:cachex, :provider], Cachex)
def max_read_duration_default, do: @max_read_duration def max_read_duration_default, do: @max_read_duration
@ -61,11 +63,14 @@ def default_cache_control_header, do: @default_cache_control_header
""" """
@inline_content_types [ @inline_content_types [
"image/avif",
"image/gif", "image/gif",
"image/jpeg", "image/jpeg",
"image/jpg", "image/jpg",
"image/jxl",
"image/png", "image/png",
"image/svg+xml", "image/svg+xml",
"image/webp",
"audio/mpeg", "audio/mpeg",
"audio/mp3", "audio/mp3",
"video/webm", "video/webm",
@ -250,6 +255,7 @@ defp build_resp_headers(headers, opts) do
headers headers
|> Enum.filter(fn {k, _} -> k in @keep_resp_headers end) |> Enum.filter(fn {k, _} -> k in @keep_resp_headers end)
|> build_resp_cache_headers(opts) |> build_resp_cache_headers(opts)
|> sanitise_content_type()
|> build_resp_content_disposition_header(opts) |> build_resp_content_disposition_header(opts)
|> build_csp_headers() |> build_csp_headers()
|> Keyword.merge(Keyword.get(opts, :resp_headers, [])) |> Keyword.merge(Keyword.get(opts, :resp_headers, []))
@ -279,6 +285,21 @@ defp build_resp_cache_headers(headers, _opts) do
end end
end end
defp sanitise_content_type(headers) do
original_ct = get_content_type(headers)
safe_ct =
Pleroma.Web.Plugs.Utils.get_safe_mime_type(
%{allowed_mime_types: @allowed_mime_types},
original_ct
)
[
{"content-type", safe_ct}
| Enum.filter(headers, fn {k, _v} -> k != "content-type" end)
]
end
defp build_resp_content_disposition_header(headers, opts) do defp build_resp_content_disposition_header(headers, opts) do
opt = Keyword.get(opts, :inline_content_types, @inline_content_types) opt = Keyword.get(opts, :inline_content_types, @inline_content_types)

View file

@ -76,6 +76,6 @@ def sign(%User{keys: keys} = user, headers) do
def signed_date, do: signed_date(NaiveDateTime.utc_now()) def signed_date, do: signed_date(NaiveDateTime.utc_now())
def signed_date(%NaiveDateTime{} = date) do def signed_date(%NaiveDateTime{} = date) do
Timex.format!(date, "{WDshort}, {0D} {Mshort} {YYYY} {h24}:{m}:{s} GMT") Timex.lformat!(date, "{WDshort}, {0D} {Mshort} {YYYY} {h24}:{m}:{s} GMT", "en")
end end
end end

View file

@ -39,6 +39,8 @@ defmodule Pleroma.Upload do
alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Utils
require Logger require Logger
@mix_env Mix.env()
@type source :: @type source ::
Plug.Upload.t() Plug.Upload.t()
| (data_uri_string :: String.t()) | (data_uri_string :: String.t())
@ -64,7 +66,7 @@ defmodule Pleroma.Upload do
path: String.t() path: String.t()
} }
@always_enabled_filters [Pleroma.Upload.Filter.AnonymizeFilename] @always_enabled_filters [Pleroma.Upload.Filter.Dedupe]
defstruct [:id, :name, :tempfile, :content_type, :width, :height, :blurhash, :path] defstruct [:id, :name, :tempfile, :content_type, :width, :height, :blurhash, :path]
@ -228,6 +230,13 @@ defp url_from_spec(%__MODULE__{name: name}, base_url, {:file, path}) do
defp url_from_spec(_upload, _base_url, {:url, url}), do: url defp url_from_spec(_upload, _base_url, {:url, url}), do: url
if @mix_env == :test do
defp choose_base_url(prim, sec \\ nil),
do: prim || sec || Pleroma.Web.Endpoint.url() <> "/media/"
else
defp choose_base_url(prim, sec \\ nil), do: prim || sec
end
def base_url do def base_url do
uploader = Config.get([Pleroma.Upload, :uploader]) uploader = Config.get([Pleroma.Upload, :uploader])
upload_base_url = Config.get([Pleroma.Upload, :base_url]) upload_base_url = Config.get([Pleroma.Upload, :base_url])
@ -235,7 +244,7 @@ def base_url do
case uploader do case uploader do
Pleroma.Uploaders.Local -> Pleroma.Uploaders.Local ->
upload_base_url || Pleroma.Web.Endpoint.url() <> "/media/" choose_base_url(upload_base_url)
Pleroma.Uploaders.S3 -> Pleroma.Uploaders.S3 ->
bucket = Config.get([Pleroma.Uploaders.S3, :bucket]) bucket = Config.get([Pleroma.Uploaders.S3, :bucket])
@ -261,7 +270,7 @@ def base_url do
end end
_ -> _ ->
public_endpoint || upload_base_url || Pleroma.Web.Endpoint.url() <> "/media/" choose_base_url(public_endpoint, upload_base_url)
end end
end end
end end

View file

@ -44,6 +44,8 @@ defmodule Pleroma.User do
alias Pleroma.Web.RelMe alias Pleroma.Web.RelMe
alias Pleroma.Workers.BackgroundWorker alias Pleroma.Workers.BackgroundWorker
use Pleroma.Web, :verified_routes
require Logger require Logger
@type t :: %__MODULE__{} @type t :: %__MODULE__{}
@ -158,6 +160,7 @@ defmodule Pleroma.User do
field(:last_status_at, :naive_datetime) field(:last_status_at, :naive_datetime)
field(:language, :string) field(:language, :string)
field(:status_ttl_days, :integer, default: nil) field(:status_ttl_days, :integer, default: nil)
field(:permit_followback, :boolean, default: false)
field(:accepts_direct_messages_from, Ecto.Enum, field(:accepts_direct_messages_from, Ecto.Enum,
values: [:everybody, :people_i_follow, :nobody], values: [:everybody, :people_i_follow, :nobody],
@ -379,6 +382,10 @@ def banner_url(user, options \\ []) do
do_optional_url(user.banner, "#{Endpoint.url()}/images/banner.png", options) do_optional_url(user.banner, "#{Endpoint.url()}/images/banner.png", options)
end end
def background_url(user) do
do_optional_url(user.background, nil, no_default: true)
end
defp do_optional_url(field, default, options) do defp do_optional_url(field, default, options) do
case field do case field do
%{"url" => [%{"href" => href} | _]} when is_binary(href) -> %{"url" => [%{"href" => href} | _]} when is_binary(href) ->
@ -463,6 +470,7 @@ def remote_user_changeset(struct \\ %User{local: false}, params) do
:avatar, :avatar,
:ap_enabled, :ap_enabled,
:banner, :banner,
:background,
:is_locked, :is_locked,
:last_refreshed_at, :last_refreshed_at,
:uri, :uri,
@ -542,6 +550,7 @@ def update_changeset(struct, params \\ %{}) do
:actor_type, :actor_type,
:disclose_client, :disclose_client,
:status_ttl_days, :status_ttl_days,
:permit_followback,
:accepts_direct_messages_from :accepts_direct_messages_from
] ]
) )
@ -970,16 +979,21 @@ def needs_update?(%User{local: false} = user) do
def needs_update?(_), do: true def needs_update?(_), do: true
# "Locked" (self-locked) users demand explicit authorization of follow requests
@spec can_direct_follow_local(User.t(), User.t()) :: true | false
def can_direct_follow_local(%User{} = follower, %User{local: true} = followed) do
!followed.is_locked || (followed.permit_followback and is_friend_of(follower, followed))
end
@spec maybe_direct_follow(User.t(), User.t()) :: @spec maybe_direct_follow(User.t(), User.t()) ::
{:ok, User.t(), User.t()} | {:error, String.t()} {:ok, User.t(), User.t()} | {:error, String.t()}
# "Locked" (self-locked) users demand explicit authorization of follow requests
def maybe_direct_follow(%User{} = follower, %User{local: true, is_locked: true} = followed) do
follow(follower, followed, :follow_pending)
end
def maybe_direct_follow(%User{} = follower, %User{local: true} = followed) do def maybe_direct_follow(%User{} = follower, %User{local: true} = followed) do
follow(follower, followed) if can_direct_follow_local(follower, followed) do
follow(follower, followed)
else
follow(follower, followed, :follow_pending)
end
end end
def maybe_direct_follow(%User{} = follower, %User{} = followed) do def maybe_direct_follow(%User{} = follower, %User{} = followed) do
@ -1329,6 +1343,13 @@ def get_friends_ids(%User{} = user, page \\ nil) do
|> Repo.all() |> Repo.all()
end end
def is_friend_of(%User{} = potential_friend, %User{local: true} = user) do
user
|> get_friends_query()
|> where(id: ^potential_friend.id)
|> Repo.exists?()
end
def increase_note_count(%User{} = user) do def increase_note_count(%User{} = user) do
User User
|> where(id: ^user.id) |> where(id: ^user.id)
@ -1603,9 +1624,13 @@ def blocks_user?(%User{} = user, %User{} = target) do
def blocks_user?(_, _), do: false def blocks_user?(_, _), do: false
def blocks_domain?(%User{} = user, %User{} = target) do def blocks_domain?(%User{} = user, %User{} = target) do
domain_blocks = Pleroma.Web.ActivityPub.MRF.subdomains_regex(user.domain_blocks)
%{host: host} = URI.parse(target.ap_id) %{host: host} = URI.parse(target.ap_id)
Pleroma.Web.ActivityPub.MRF.subdomain_match?(domain_blocks, host) Enum.member?(user.domain_blocks, host)
# TODO: functionality should probably be changed such that subdomains block as well,
# but as it stands, this just hecks up the relationships endpoint
# domain_blocks = Pleroma.Web.ActivityPub.MRF.subdomains_regex(user.domain_blocks)
# %{host: host} = URI.parse(target.ap_id)
# Pleroma.Web.ActivityPub.MRF.subdomain_match?(domain_blocks, host)
end end
def blocks_domain?(_, _), do: false def blocks_domain?(_, _), do: false
@ -2447,12 +2472,7 @@ defp validate_rel_me_field(changeset, fields, raw_fields, %User{
end end
if is_url(raw_value) do if is_url(raw_value) do
frontend_url = frontend_url = url(~p[/#{nickname}])
Pleroma.Web.Router.Helpers.redirect_url(
Pleroma.Web.Endpoint,
:redirector_with_meta,
nickname
)
possible_urls = [ap_id, frontend_url] possible_urls = [ap_id, frontend_url]

View file

@ -27,6 +27,7 @@ defmodule Pleroma.Web do
alias Pleroma.Web.Plugs.ExpectPublicOrAuthenticatedCheckPlug alias Pleroma.Web.Plugs.ExpectPublicOrAuthenticatedCheckPlug
alias Pleroma.Web.Plugs.OAuthScopesPlug alias Pleroma.Web.Plugs.OAuthScopesPlug
alias Pleroma.Web.Plugs.PlugHelper alias Pleroma.Web.Plugs.PlugHelper
require Pleroma.Constants
def controller do def controller do
quote do quote do
@ -37,7 +38,7 @@ def controller do
import Pleroma.Web.Gettext import Pleroma.Web.Gettext
import Pleroma.Web.TranslationHelpers import Pleroma.Web.TranslationHelpers
alias Pleroma.Web.Router.Helpers, as: Routes unquote(verified_routes())
plug(:set_put_layout) plug(:set_put_layout)
@ -184,7 +185,10 @@ def view do
# Import convenience functions from controllers # Import convenience functions from controllers
import Phoenix.Controller, import Phoenix.Controller,
only: [get_flash: 1, get_flash: 2, view_module: 1, view_template: 1] only: [view_module: 1, view_template: 1]
import Phoenix.Flash
alias Phoenix.Flash
# Include shared imports and aliases for views # Include shared imports and aliases for views
unquote(view_helpers()) unquote(view_helpers())
@ -218,7 +222,7 @@ def component do
def router do def router do
quote do quote do
use Phoenix.Router use Phoenix.Router, helpers: false
import Plug.Conn import Plug.Conn
import Phoenix.Controller import Phoenix.Controller
@ -246,7 +250,24 @@ defp view_helpers do
import Pleroma.Web.ErrorHelpers import Pleroma.Web.ErrorHelpers
import Pleroma.Web.Gettext import Pleroma.Web.Gettext
alias Pleroma.Web.Router.Helpers, as: Routes unquote(verified_routes())
end
end
def static_paths, do: Pleroma.Constants.static_only_files()
def verified_routes do
quote do
use Phoenix.VerifiedRoutes,
endpoint: Pleroma.Web.Endpoint,
router: Pleroma.Web.Router,
statics: Pleroma.Web.static_paths()
end
end
def mailer do
quote do
unquote(verified_routes())
end end
end end

View file

@ -22,6 +22,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
alias Pleroma.Upload alias Pleroma.Upload
alias Pleroma.User alias Pleroma.User
alias Pleroma.Web.ActivityPub.MRF alias Pleroma.Web.ActivityPub.MRF
alias Pleroma.Web.ActivityPub.ObjectValidators.UserValidator
alias Pleroma.Web.ActivityPub.Transmogrifier alias Pleroma.Web.ActivityPub.Transmogrifier
alias Pleroma.Web.Streamer alias Pleroma.Web.Streamer
alias Pleroma.Web.WebFinger alias Pleroma.Web.WebFinger
@ -1603,6 +1604,7 @@ defp object_to_user_data(data, additional) do
uri: get_actor_url(data["url"]), uri: get_actor_url(data["url"]),
ap_enabled: true, ap_enabled: true,
banner: normalize_image(data["image"]), banner: normalize_image(data["image"]),
background: normalize_image(data["backgroundUrl"]),
fields: fields, fields: fields,
emoji: emojis, emoji: emojis,
is_locked: is_locked, is_locked: is_locked,
@ -1721,6 +1723,7 @@ def user_data_from_user_object(data, additional \\ []) do
def fetch_and_prepare_user_from_ap_id(ap_id, additional \\ []) do def fetch_and_prepare_user_from_ap_id(ap_id, additional \\ []) do
with {:ok, data} <- Fetcher.fetch_and_contain_remote_object_from_id(ap_id), with {:ok, data} <- Fetcher.fetch_and_contain_remote_object_from_id(ap_id),
{:valid, {:ok, _, _}} <- {:valid, UserValidator.validate(data, [])},
{:ok, data} <- user_data_from_user_object(data, additional) do {:ok, data} <- user_data_from_user_object(data, additional) do
{:ok, maybe_update_follow_information(data)} {:ok, maybe_update_follow_information(data)}
else else
@ -1733,6 +1736,10 @@ def fetch_and_prepare_user_from_ap_id(ap_id, additional \\ []) do
Logger.debug("Rejected user #{ap_id}: #{inspect(reason)}") Logger.debug("Rejected user #{ap_id}: #{inspect(reason)}")
{:error, e} {:error, e}
{:valid, reason} ->
Logger.debug("Data is not a valid user #{ap_id}: #{inspect(reason)}")
{:error, "Not a user"}
{:error, e} -> {:error, e} ->
Logger.error("Could not decode user at fetch #{ap_id}, #{inspect(e)}") Logger.error("Could not decode user at fetch #{ap_id}, #{inspect(e)}")
{:error, e} {:error, e}
@ -1792,6 +1799,11 @@ def pin_data_from_featured_collection(
end) end)
end end
def pin_data_from_featured_collection(obj) do
Logger.error("Could not parse featured collection #{inspect(obj)}")
%{}
end
def fetch_and_prepare_featured_from_ap_id(nil) do def fetch_and_prepare_featured_from_ap_id(nil) do
{:ok, %{}} {:ok, %{}}
end end
@ -1828,6 +1840,13 @@ def make_user_from_ap_id(ap_id, additional \\ []) do
with {:ok, data} <- fetch_and_prepare_user_from_ap_id(ap_id, additional) do with {:ok, data} <- fetch_and_prepare_user_from_ap_id(ap_id, additional) do
{:ok, _pid} = Task.start(fn -> pinned_fetch_task(data) end) {:ok, _pid} = Task.start(fn -> pinned_fetch_task(data) end)
user =
if data.ap_id != ap_id do
User.get_cached_by_ap_id(data.ap_id)
else
user
end
if user do if user do
user user
|> User.remote_user_changeset(data) |> User.remote_user_changeset(data)

View file

@ -18,6 +18,8 @@ defmodule Pleroma.Web.ActivityPub.Builder do
alias Pleroma.Web.CommonAPI.ActivityDraft alias Pleroma.Web.CommonAPI.ActivityDraft
alias Pleroma.Web.Endpoint alias Pleroma.Web.Endpoint
use Pleroma.Web, :verified_routes
require Pleroma.Constants require Pleroma.Constants
def accept_or_reject(actor, activity, type) do def accept_or_reject(actor, activity, type) do
@ -402,6 +404,6 @@ def unpin(%User{} = user, object) do
end end
defp pinned_url(nickname) when is_binary(nickname) do defp pinned_url(nickname) when is_binary(nickname) do
Pleroma.Web.Router.Helpers.activity_pub_url(Pleroma.Web.Endpoint, :pinned, nickname) url(~p[/users/#{nickname}/collections/featured])
end end
end end

View file

@ -178,6 +178,23 @@ defp check_banner_removal(%{host: actor_host} = _actor_info, %{"image" => _image
defp check_banner_removal(_actor_info, object), do: {:ok, object} defp check_banner_removal(_actor_info, object), do: {:ok, object}
defp check_background_removal(
%{host: actor_host} = _actor_info,
%{"backgroundUrl" => _bg} = object
) do
background_removal =
instance_list(:background_removal)
|> MRF.subdomains_regex()
if MRF.subdomain_match?(background_removal, actor_host) do
{:ok, Map.delete(object, "backgroundUrl")}
else
{:ok, object}
end
end
defp check_background_removal(_actor_info, object), do: {:ok, object}
defp extract_context_uri(%{"conversation" => "tag:" <> rest}) do defp extract_context_uri(%{"conversation" => "tag:" <> rest}) do
rest rest
|> String.split(",", parts: 2, trim: true) |> String.split(",", parts: 2, trim: true)
@ -283,7 +300,8 @@ def filter(%{"id" => actor, "type" => obj_type} = object)
with {:ok, _} <- check_accept(actor_info), with {:ok, _} <- check_accept(actor_info),
{:ok, _} <- check_reject(actor_info), {:ok, _} <- check_reject(actor_info),
{:ok, object} <- check_avatar_removal(actor_info, object), {:ok, object} <- check_avatar_removal(actor_info, object),
{:ok, object} <- check_banner_removal(actor_info, object) do {:ok, object} <- check_banner_removal(actor_info, object),
{:ok, object} <- check_background_removal(actor_info, object) do
{:ok, object} {:ok, object}
else else
{:reject, nil} -> {:reject, "[SimplePolicy]"} {:reject, nil} -> {:reject, "[SimplePolicy]"}
@ -314,6 +332,20 @@ def filter(object) when is_binary(object) do
def filter(object), do: {:ok, object} def filter(object), do: {:ok, object}
defp obfuscate(string) when is_binary(string) do defp obfuscate(string) when is_binary(string) do
# Want to strip at least two neighbouring chars
# to ensure at least one non-dot char is in the obfuscation area
stripped = String.length(string) - 6
{keepstart, keepend} =
if stripped > 1 do
{3, 3}
else
{
2 - div(1 - stripped, 2),
2 + div(stripped, 2)
}
end
string string
|> to_charlist() |> to_charlist()
|> Enum.with_index() |> Enum.with_index()
@ -322,7 +354,7 @@ defp obfuscate(string) when is_binary(string) do
?. ?.
{char, index} -> {char, index} ->
if 3 <= index && index < String.length(string) - 3, do: ?*, else: char if keepstart <= index && index < String.length(string) - keepend, do: ?*, else: char
end) end)
|> to_string() |> to_string()
end end
@ -433,6 +465,11 @@ def config_description do
key: :banner_removal, key: :banner_removal,
description: "List of instances to strip banners from and the reason for doing so" description: "List of instances to strip banners from and the reason for doing so"
}, },
%{
key: :background_removal,
description:
"List of instances to strip user backgrounds from and the reason for doing so"
},
%{ %{
key: :reject_deletes, key: :reject_deletes,
description: "List of instances to reject deletions from and the reason for doing so" description: "List of instances to reject deletions from and the reason for doing so"

View file

@ -6,10 +6,54 @@ defmodule Pleroma.Web.ActivityPub.MRF.StealEmojiPolicy do
require Logger require Logger
alias Pleroma.Config alias Pleroma.Config
alias Pleroma.Emoji.Pack
@moduledoc "Detect new emojis by their shortcode and steals them" @moduledoc "Detect new emojis by their shortcode and steals them"
@behaviour Pleroma.Web.ActivityPub.MRF.Policy @behaviour Pleroma.Web.ActivityPub.MRF.Policy
@pack_name "stolen"
# Config defaults
@size_limit 50_000
@download_unknown_size false
defp create_pack() do
with {:ok, pack} = Pack.create(@pack_name) do
Pack.save_metadata(
%{
"description" => "Collection of emoji auto-stolen from other instances",
"homepage" => Pleroma.Web.Endpoint.url(),
"can-download" => false,
"share-files" => false
},
pack
)
end
end
defp load_or_create_pack() do
case Pack.load_pack(@pack_name) do
{:ok, pack} -> {:ok, pack}
{:error, :enoent} -> create_pack()
e -> e
end
end
defp add_emoji(shortcode, extension, filedata) do
{:ok, pack} = load_or_create_pack()
# Make final path infeasible to predict to thwart certain kinds of attacks
# (48 bits is slighty more than 8 base62 chars, thus 9 chars)
salt =
:crypto.strong_rand_bytes(6)
|> :crypto.bytes_to_integer()
|> Base62.encode()
|> String.pad_leading(9, "0")
filename = shortcode <> "-" <> salt <> "." <> extension
Pack.add_file(pack, shortcode, filename, filedata)
end
defp accept_host?(host), do: host in Config.get([:mrf_steal_emoji, :hosts], []) defp accept_host?(host), do: host in Config.get([:mrf_steal_emoji, :hosts], [])
defp shortcode_matches?(shortcode, pattern) when is_binary(pattern) do defp shortcode_matches?(shortcode, pattern) when is_binary(pattern) do
@ -20,30 +64,69 @@ defp shortcode_matches?(shortcode, pattern) do
String.match?(shortcode, pattern) String.match?(shortcode, pattern)
end end
defp steal_emoji({shortcode, url}, emoji_dir_path) do defp reject_emoji?({shortcode, _url}, installed_emoji) do
valid_shortcode? = String.match?(shortcode, ~r/^[a-zA-Z0-9_-]+$/)
rejected_shortcode? =
[:mrf_steal_emoji, :rejected_shortcodes]
|> Config.get([])
|> Enum.any?(fn pattern -> shortcode_matches?(shortcode, pattern) end)
emoji_installed? = Enum.member?(installed_emoji, shortcode)
!valid_shortcode? or rejected_shortcode? or emoji_installed?
end
defp steal_emoji(%{} = response, {shortcode, extension}) do
case add_emoji(shortcode, extension, response.body) do
{:ok, _} ->
shortcode
e ->
Logger.warning(
"MRF.StealEmojiPolicy: Failed to add #{shortcode} as #{extension}: #{inspect(e)}"
)
nil
end
end
defp get_extension_if_safe(response) do
content_type =
:proplists.get_value("content-type", response.headers, MIME.from_path(response.url))
case content_type do
"image/" <> _ -> List.first(MIME.extensions(content_type))
_ -> nil
end
end
defp is_remote_size_within_limit?(url) do
with {:ok, %{status: status, headers: headers} = _response} when status in 200..299 <-
Pleroma.HTTP.request(:head, url, nil, [], []) do
content_length = :proplists.get_value("content-length", headers, nil)
size_limit = Config.get([:mrf_steal_emoji, :size_limit], @size_limit)
accept_unknown =
Config.get([:mrf_steal_emoji, :download_unknown_size], @download_unknown_size)
content_length <= size_limit or
(content_length == nil and accept_unknown)
else
_ -> false
end
end
defp maybe_steal_emoji({shortcode, url}) do
url = Pleroma.Web.MediaProxy.url(url) url = Pleroma.Web.MediaProxy.url(url)
with {:ok, %{status: status} = response} when status in 200..299 <- Pleroma.HTTP.get(url) do with {:remote_size, true} <- {:remote_size, is_remote_size_within_limit?(url)},
size_limit = Config.get([:mrf_steal_emoji, :size_limit], 50_000) {:ok, %{status: status} = response} when status in 200..299 <- Pleroma.HTTP.get(url) do
size_limit = Config.get([:mrf_steal_emoji, :size_limit], @size_limit)
extension = get_extension_if_safe(response)
if byte_size(response.body) <= size_limit do if byte_size(response.body) <= size_limit and extension do
extension = steal_emoji(response, {shortcode, extension})
url
|> URI.parse()
|> Map.get(:path)
|> Path.basename()
|> Path.extname()
file_path = Path.join(emoji_dir_path, shortcode <> (extension || ".png"))
case File.write(file_path, response.body) do
:ok ->
shortcode
e ->
Logger.warning("MRF.StealEmojiPolicy: Failed to write to #{file_path}: #{inspect(e)}")
nil
end
else else
Logger.debug( Logger.debug(
"MRF.StealEmojiPolicy: :#{shortcode}: at #{url} (#{byte_size(response.body)} B) over size limit (#{size_limit} B)" "MRF.StealEmojiPolicy: :#{shortcode}: at #{url} (#{byte_size(response.body)} B) over size limit (#{size_limit} B)"
@ -65,26 +148,10 @@ def filter(%{"object" => %{"emoji" => foreign_emojis, "actor" => actor}} = messa
if host != Pleroma.Web.Endpoint.host() and accept_host?(host) do if host != Pleroma.Web.Endpoint.host() and accept_host?(host) do
installed_emoji = Pleroma.Emoji.get_all() |> Enum.map(fn {k, _} -> k end) installed_emoji = Pleroma.Emoji.get_all() |> Enum.map(fn {k, _} -> k end)
emoji_dir_path =
Config.get(
[:mrf_steal_emoji, :path],
Path.join(Config.get([:instance, :static_dir]), "emoji/stolen")
)
File.mkdir_p(emoji_dir_path)
new_emojis = new_emojis =
foreign_emojis foreign_emojis
|> Enum.reject(fn {shortcode, _url} -> shortcode in installed_emoji end) |> Enum.reject(&reject_emoji?(&1, installed_emoji))
|> Enum.filter(fn {shortcode, _url} -> |> Enum.map(&maybe_steal_emoji(&1))
reject_emoji? =
[:mrf_steal_emoji, :rejected_shortcodes]
|> Config.get([])
|> Enum.find(false, fn pattern -> shortcode_matches?(shortcode, pattern) end)
!reject_emoji?
end)
|> Enum.map(&steal_emoji(&1, emoji_dir_path))
|> Enum.filter(& &1) |> Enum.filter(& &1)
if !Enum.empty?(new_emojis) do if !Enum.empty?(new_emojis) do

View file

@ -0,0 +1,92 @@
# Akkoma: Magically expressive social media
# Copyright © 2024 Akkoma Authors <https://akkoma.dev/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.ObjectValidators.UserValidator do
@moduledoc """
Checks whether ActivityPub data represents a valid user
Users don't go through the same ingest pipeline like activities or other objects.
To ensure this can only match a user and no users match in the other pipeline,
this is a separate from the generic ObjectValidator.
"""
@behaviour Pleroma.Web.ActivityPub.ObjectValidator.Validating
alias Pleroma.Object.Containment
alias Pleroma.Signature
@impl true
def validate(object, meta)
def validate(%{"type" => type, "id" => _id} = data, meta)
when type in ["Person", "Organization", "Group", "Application"] do
with :ok <- validate_pubkey(data),
:ok <- validate_inbox(data),
:ok <- contain_collection_origin(data) do
{:ok, data, meta}
else
{:error, e} -> {:error, e}
e -> {:error, e}
end
end
def validate(_, _), do: {:error, "Not a user object"}
defp mabye_validate_owner(nil, _actor), do: :ok
defp mabye_validate_owner(actor, actor), do: :ok
defp mabye_validate_owner(_owner, _actor), do: :error
defp validate_pubkey(
%{"id" => id, "publicKey" => %{"id" => pk_id, "publicKeyPem" => _key}} = data
)
when id != nil do
with {_, {:ok, kactor}} <- {:key, Signature.key_id_to_actor_id(pk_id)},
true <- id == kactor,
:ok <- mabye_validate_owner(Map.get(data, "owner"), id) do
:ok
else
{:key, _} ->
{:error, "Unable to determine actor id from key id"}
false ->
{:error, "Key id does not relate to user id"}
_ ->
{:error, "Actor does not own its public key"}
end
end
# pubkey is optional atm
defp validate_pubkey(_data), do: :ok
defp validate_inbox(%{"id" => id, "inbox" => inbox}) do
case Containment.same_origin(id, inbox) do
:ok -> :ok
:error -> {:error, "Inbox on different doamin"}
end
end
defp validate_inbox(_), do: {:error, "No inbox"}
defp check_field_value(%{"id" => id} = _data, value) do
Containment.same_origin(id, value)
end
defp maybe_check_field(data, field) do
with val when val != nil <- data[field],
:ok <- check_field_value(data, val) do
:ok
else
nil -> :ok
_ -> {:error, "#{field} on different domain"}
end
end
defp contain_collection_origin(data) do
Enum.reduce(["followers", "following", "featured"], :ok, fn
field, :ok -> maybe_check_field(data, field)
_, error -> error
end)
end
end

View file

@ -109,7 +109,7 @@ def handle(
%User{} = followed <- User.get_cached_by_ap_id(followed_user), %User{} = followed <- User.get_cached_by_ap_id(followed_user),
{_, {:ok, _, _}, _, _} <- {_, {:ok, _, _}, _, _} <-
{:following, User.follow(follower, followed, :follow_pending), follower, followed} do {:following, User.follow(follower, followed, :follow_pending), follower, followed} do
if followed.local && !followed.is_locked do if followed.local && User.can_direct_follow_local(follower, followed) do
{:ok, accept_data, _} = Builder.accept(followed, object) {:ok, accept_data, _} = Builder.accept(followed, object)
{:ok, _activity, _} = Pipeline.common_pipeline(accept_data, local: true) {:ok, _activity, _} = Pipeline.common_pipeline(accept_data, local: true)
end end

View file

@ -16,10 +16,11 @@ defmodule Pleroma.Web.ActivityPub.Utils do
alias Pleroma.Web.ActivityPub.Visibility alias Pleroma.Web.ActivityPub.Visibility
alias Pleroma.Web.AdminAPI.AccountView alias Pleroma.Web.AdminAPI.AccountView
alias Pleroma.Web.Endpoint alias Pleroma.Web.Endpoint
alias Pleroma.Web.Router.Helpers
import Ecto.Query import Ecto.Query
use Pleroma.Web, :verified_routes
require Logger require Logger
require Pleroma.Constants require Pleroma.Constants
@ -124,19 +125,15 @@ def make_date do
end end
def generate_activity_id do def generate_activity_id do
generate_id("activities") url(~p[/activities/#{UUID.generate()}])
end end
def generate_context_id do def generate_context_id do
generate_id("contexts") url(~p[/contexts/#{UUID.generate()}])
end end
def generate_object_id do def generate_object_id do
Helpers.o_status_url(Endpoint, :object, UUID.generate()) url(~p[/objects/#{UUID.generate()}])
end
def generate_id(type) do
"#{Endpoint.url()}/#{type}/#{UUID.generate()}"
end end
def get_notified_from_object(%{"type" => type} = object) when type in @supported_object_types do def get_notified_from_object(%{"type" => type} = object) when type in @supported_object_types do
@ -154,7 +151,7 @@ def get_notified_from_object(object) do
Notification.get_notified_from_activity(%Activity{data: object}, false) Notification.get_notified_from_activity(%Activity{data: object}, false)
end end
def maybe_create_context(context), do: context || generate_id("contexts") def maybe_create_context(context), do: context || generate_context_id()
@doc """ @doc """
Enqueues an activity for federation if it's local Enqueues an activity for federation if it's local

View file

@ -12,24 +12,22 @@ defmodule Pleroma.Web.ActivityPub.UserView do
alias Pleroma.Web.ActivityPub.ObjectView alias Pleroma.Web.ActivityPub.ObjectView
alias Pleroma.Web.ActivityPub.Transmogrifier alias Pleroma.Web.ActivityPub.Transmogrifier
alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Utils
alias Pleroma.Web.Endpoint
alias Pleroma.Web.Router.Helpers
require Pleroma.Web.ActivityPub.Transmogrifier require Pleroma.Web.ActivityPub.Transmogrifier
import Ecto.Query import Ecto.Query
def render("endpoints.json", %{user: %User{nickname: nil, local: true} = _user}) do def render("endpoints.json", %{user: %User{nickname: nil, local: true} = _user}) do
%{"sharedInbox" => Helpers.activity_pub_url(Endpoint, :inbox)} %{"sharedInbox" => url(~p"/inbox")}
end end
def render("endpoints.json", %{user: %User{local: true} = _user}) do def render("endpoints.json", %{user: %User{local: true} = _user}) do
%{ %{
"oauthAuthorizationEndpoint" => Helpers.o_auth_url(Endpoint, :authorize), "oauthAuthorizationEndpoint" => url(~p"/oauth/authorize"),
"oauthRegistrationEndpoint" => Helpers.app_url(Endpoint, :create), "oauthRegistrationEndpoint" => url(~p"/api/v1/apps"),
"oauthTokenEndpoint" => Helpers.o_auth_url(Endpoint, :token_exchange), "oauthTokenEndpoint" => url(~p"/oauth/token"),
"sharedInbox" => Helpers.activity_pub_url(Endpoint, :inbox), "sharedInbox" => url(~p"/inbox"),
"uploadMedia" => Helpers.activity_pub_url(Endpoint, :upload_media) "uploadMedia" => url(~p"/api/ap/upload_media")
} }
end end
@ -48,6 +46,7 @@ def render("service.json", %{user: user}) do
"following" => "#{user.ap_id}/following", "following" => "#{user.ap_id}/following",
"followers" => "#{user.ap_id}/followers", "followers" => "#{user.ap_id}/followers",
"inbox" => "#{user.ap_id}/inbox", "inbox" => "#{user.ap_id}/inbox",
"outbox" => "#{user.ap_id}/outbox",
"name" => "Pleroma", "name" => "Pleroma",
"summary" => "summary" =>
"An internal service actor for this Pleroma instance. No user-serviceable parts inside.", "An internal service actor for this Pleroma instance. No user-serviceable parts inside.",
@ -113,6 +112,8 @@ def render("user.json", %{user: user}) do
} }
|> Map.merge(maybe_make_image(&User.avatar_url/2, "icon", user)) |> Map.merge(maybe_make_image(&User.avatar_url/2, "icon", user))
|> Map.merge(maybe_make_image(&User.banner_url/2, "image", user)) |> Map.merge(maybe_make_image(&User.banner_url/2, "image", user))
# Yes, the key is named ...Url eventhough it is a whole 'Image' object
|> Map.merge(maybe_insert_image("backgroundUrl", User.background_url(user)))
|> Map.merge(Utils.make_json_ld_header()) |> Map.merge(Utils.make_json_ld_header())
end end
@ -288,7 +289,12 @@ def collection(collection, iri, page, show_items \\ true, total \\ nil) do
end end
defp maybe_make_image(func, key, user) do defp maybe_make_image(func, key, user) do
if image = func.(user, no_default: true) do image = func.(user, no_default: true)
maybe_insert_image(key, image)
end
defp maybe_insert_image(key, image) do
if image do
%{ %{
key => %{ key => %{
"type" => "Image", "type" => "Image",

View file

@ -17,9 +17,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do
alias Pleroma.Web.AdminAPI alias Pleroma.Web.AdminAPI
alias Pleroma.Web.AdminAPI.AccountView alias Pleroma.Web.AdminAPI.AccountView
alias Pleroma.Web.AdminAPI.ModerationLogView alias Pleroma.Web.AdminAPI.ModerationLogView
alias Pleroma.Web.Endpoint
alias Pleroma.Web.Plugs.OAuthScopesPlug alias Pleroma.Web.Plugs.OAuthScopesPlug
alias Pleroma.Web.Router
@users_page_size 50 @users_page_size 50
@ -256,7 +254,7 @@ def get_password_reset(conn, %{"nickname" => nickname}) do
conn conn
|> json(%{ |> json(%{
token: token.token, token: token.token,
link: Router.Helpers.reset_password_url(Endpoint, :reset, token.token) link: url(~p[/api/v1/pleroma/password_reset/#{token.token}])
}) })
end end

View file

@ -451,6 +451,20 @@ def endorsements_operation do
} }
end end
def preferences_operation do
%Operation{
tags: ["Account Preferences"],
description: "Preferences defined by the user in their account settings.",
summary: "Preferred common behaviors to be shared across clients.",
operationId: "AccountController.preferences",
security: [%{"oAuth" => ["read:accounts"]}],
responses: %{
200 => Operation.response("Preferences", "application/json", Account),
401 => Operation.response("Error", "application/json", ApiError)
}
}
end
def identity_proofs_operation do def identity_proofs_operation do
%Operation{ %Operation{
tags: ["Retrieve account information"], tags: ["Retrieve account information"],
@ -709,6 +723,12 @@ defp update_credentials_request do
description: description:
"Number of days after which statuses will be deleted. Set to -1 to disable." "Number of days after which statuses will be deleted. Set to -1 to disable."
}, },
permit_followback: %Schema{
allOf: [BooleanLike],
nullable: true,
description:
"Whether follow requests from accounts the user is already following are auto-approved (when locked)."
},
accepts_direct_messages_from: %Schema{ accepts_direct_messages_from: %Schema{
type: :string, type: :string,
enum: [ enum: [
@ -740,6 +760,7 @@ defp update_credentials_request do
discoverable: false, discoverable: false,
actor_type: "Person", actor_type: "Person",
status_ttl_days: 30, status_ttl_days: 30,
permit_followback: true,
accepts_direct_messages_from: "everybody" accepts_direct_messages_from: "everybody"
} }
} }

View file

@ -111,9 +111,9 @@ def available_frontends_operation() do
def update_preferred_frontend_operation() do def update_preferred_frontend_operation() do
%Operation{ %Operation{
tags: ["Frontends"], tags: ["Frontends"],
summary: "Frontend Settings Profiles", summary: "Update preferred frontend setting",
description: "List frontend setting profiles", description: "Store preferred frontend in cookies",
operationId: "AkkomaAPI.FrontendSettingsController.available_frontends", operationId: "AkkomaAPI.FrontendSettingsController.update_preferred_frontend",
requestBody: requestBody:
request_body( request_body(
"Frontend", "Frontend",
@ -132,9 +132,11 @@ def update_preferred_frontend_operation() do
responses: %{ responses: %{
200 => 200 =>
Operation.response("Frontends", "application/json", %Schema{ Operation.response("Frontends", "application/json", %Schema{
type: :array, type: :object,
items: %Schema{ properties: %{
type: :string frontend_name: %Schema{
type: :string
}
} }
}) })
} }

View file

@ -137,7 +137,7 @@ defp instance do
"background_upload_limit" => 4_000_000, "background_upload_limit" => 4_000_000,
"background_image" => "/static/image.png", "background_image" => "/static/image.png",
"banner_upload_limit" => 4_000_000, "banner_upload_limit" => 4_000_000,
"description" => "Pleroma: An efficient and flexible fediverse server", "description" => "Akkoma: The cooler fediverse server",
"email" => "lain@lain.com", "email" => "lain@lain.com",
"languages" => ["en"], "languages" => ["en"],
"max_toot_chars" => 5000, "max_toot_chars" => 5000,
@ -160,7 +160,7 @@ defp instance do
"urls" => %{ "urls" => %{
"streaming_api" => "wss://lain.com" "streaming_api" => "wss://lain.com"
}, },
"version" => "2.7.2 (compatible; Pleroma 2.0.50-536-g25eec6d7-develop)" "version" => "2.7.2 (compatible; Akkoma 3.9.3-232-g6fde75e1-develop)"
} }
} }
end end

View file

@ -112,7 +112,18 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Account do
akkoma: %Schema{ akkoma: %Schema{
type: :object, type: :object,
properties: %{ properties: %{
note_ttl_days: %Schema{type: :integer} instance: %Schema{
type: :object,
nullable: true,
properties: %{
name: %Schema{type: :string},
favicon: %Schema{type: :string, format: :uri, nullable: true},
# XXX: proper nodeinfo schema
nodeinfo: %Schema{type: :object, nullable: true}
}
},
status_ttl_days: %Schema{type: :integer, nullable: true},
permit_followback: %Schema{type: :boolean}
} }
}, },
source: %Schema{ source: %Schema{
@ -205,6 +216,18 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Account do
"pleroma-fe" => %{} "pleroma-fe" => %{}
} }
}, },
"akkoma" => %{
"instance" => %{
"name" => "ihatebeinga.live",
"favicon" => "https://ihatebeinga.live/favicon.png",
"nodeinfo" =>
%{
# XXX: nodeinfo schema
}
},
"status_ttl_days" => nil,
"permit_followback" => true
},
"source" => %{ "source" => %{
"fields" => [], "fields" => [],
"note" => "foobar", "note" => "foobar",

View file

@ -97,7 +97,11 @@ defmodule Pleroma.Web.Endpoint do
Plug.Static, Plug.Static,
at: "/", at: "/",
from: :pleroma, from: :pleroma,
only: Pleroma.Constants.static_only_files(), only: Pleroma.Web.static_paths(),
# JSON-LD is accepted by some servers for AP objects and activities,
# thus only enable it here instead of a global extension mapping
# (it's our only *.jsonld file anyway)
content_types: %{"litepub-0.1.jsonld" => "application/ld+json"},
# credo:disable-for-previous-line Credo.Check.Readability.MaxLineLength # credo:disable-for-previous-line Credo.Check.Readability.MaxLineLength
gzip: true, gzip: true,
cache_control_for_etags: @static_cache_control, cache_control_for_etags: @static_cache_control,

View file

@ -30,7 +30,7 @@ def feed_redirect(%{assigns: %{format: format}} = conn, _params)
def feed_redirect(conn, %{"nickname" => nickname}) do def feed_redirect(conn, %{"nickname" => nickname}) do
with {_, %User{} = user} <- {:fetch_user, User.get_cached_by_nickname(nickname)} do with {_, %User{} = user} <- {:fetch_user, User.get_cached_by_nickname(nickname)} do
redirect(conn, external: "#{Routes.user_feed_url(conn, :feed, user.nickname)}.atom") redirect(conn, external: "#{url(~p"/users/#{user.nickname}/feed")}.atom")
end end
end end

View file

@ -34,9 +34,9 @@ def index(conn, _params) do
index = index =
if flavour == "fedibird-fe" do if flavour == "fedibird-fe" do
"fedibird.index.html" "fedibird.html"
else else
"glitchsoc.index.html" "glitchsoc.html"
end end
conn conn

View file

@ -51,7 +51,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do
plug( plug(
OAuthScopesPlug, OAuthScopesPlug,
%{scopes: ["read:accounts"]} %{scopes: ["read:accounts"]}
when action in [:verify_credentials, :endorsements, :identity_proofs] when action in [:verify_credentials, :endorsements, :identity_proofs, :preferences]
) )
plug( plug(
@ -222,6 +222,7 @@ def update_credentials(%{assigns: %{user: user}, body_params: params} = conn, _p
|> Maps.put_if_present(:language, Pleroma.Web.Gettext.normalize_locale(params[:language])) |> Maps.put_if_present(:language, Pleroma.Web.Gettext.normalize_locale(params[:language]))
|> Maps.put_if_present(:status_ttl_days, params[:status_ttl_days], status_ttl_days_value) |> Maps.put_if_present(:status_ttl_days, params[:status_ttl_days], status_ttl_days_value)
|> Maps.put_if_present(:accepts_direct_messages_from, params[:accepts_direct_messages_from]) |> Maps.put_if_present(:accepts_direct_messages_from, params[:accepts_direct_messages_from])
|> Maps.put_if_present(:permit_followback, params[:permit_followback])
# What happens here: # What happens here:
# #
@ -544,4 +545,9 @@ def endorsements(conn, params), do: MastodonAPIController.empty_array(conn, para
@doc "GET /api/v1/identity_proofs" @doc "GET /api/v1/identity_proofs"
def identity_proofs(conn, params), do: MastodonAPIController.empty_array(conn, params) def identity_proofs(conn, params), do: MastodonAPIController.empty_array(conn, params)
@doc "GET /api/v1/preferences"
def preferences(%{assigns: %{user: user}} = conn, _params) do
render(conn, "preferences.json", user: user)
end
end end

View file

@ -54,12 +54,7 @@ def login(conn, params) do
defp redirect_to_oauth_form(conn, _params) do defp redirect_to_oauth_form(conn, _params) do
with {:ok, app} <- local_mastofe_app() do with {:ok, app} <- local_mastofe_app() do
path = path =
Routes.o_auth_path(conn, :authorize, ~p[/oauth/authorize?#{[response_type: "code", client_id: app.client_id, redirect_uri: ".", scope: Enum.join(app.scopes, " ")]}]
response_type: "code",
client_id: app.client_id,
redirect_uri: ".",
scope: Enum.join(app.scopes, " ")
)
redirect(conn, to: path) redirect(conn, to: path)
end end
@ -91,7 +86,7 @@ def password_reset(conn, params) do
defp local_mastodon_post_login_path(conn) do defp local_mastodon_post_login_path(conn) do
case get_session(conn, :return_to) do case get_session(conn, :return_to) do
nil -> nil ->
Routes.masto_fe_path(conn, :index, ["getting-started"]) ~p"/web/getting-started"
return_to -> return_to ->
delete_session(conn, :return_to) delete_session(conn, :return_to)

View file

@ -190,6 +190,17 @@ def render("instance.json", %{instance: %Pleroma.Instances.Instance{} = instance
def render("instance.json", _), do: nil def render("instance.json", _), do: nil
def render("preferences.json", %{user: user} = _opts) do
# TODO: Do we expose more settings that make sense to plug in here?
%{
"posting:default:visibility": user.default_scope,
"posting:default:sensitive": false,
"posting:default:language": nil,
"reading:expand:media": "default",
"reading:expand:spoilers": false
}
end
defp do_render("show.json", %{user: user} = opts) do defp do_render("show.json", %{user: user} = opts) do
user = User.sanitize_html(user, User.html_filter_policy(opts[:for])) user = User.sanitize_html(user, User.html_filter_policy(opts[:for]))
display_name = user.name || user.nickname display_name = user.name || user.nickname
@ -250,6 +261,9 @@ defp do_render("show.json", %{user: user} = opts) do
|> MediaProxy.url() |> MediaProxy.url()
end end
last_status_at =
if is_nil(user.last_status_at), do: nil, else: NaiveDateTime.to_date(user.last_status_at)
%{ %{
id: to_string(user.id), id: to_string(user.id),
username: username_from_nickname(user.nickname), username: username_from_nickname(user.nickname),
@ -278,10 +292,11 @@ defp do_render("show.json", %{user: user} = opts) do
actor_type: user.actor_type actor_type: user.actor_type
} }
}, },
last_status_at: user.last_status_at, last_status_at: last_status_at,
akkoma: %{ akkoma: %{
instance: render("instance.json", %{instance: instance}), instance: render("instance.json", %{instance: instance}),
status_ttl_days: user.status_ttl_days status_ttl_days: user.status_ttl_days,
permit_followback: user.permit_followback
}, },
# Pleroma extensions # Pleroma extensions
# Note: it's insecure to output :email but fully-qualified nickname may serve as safe stub # Note: it's insecure to output :email but fully-qualified nickname may serve as safe stub

View file

@ -322,7 +322,7 @@ def render("show.json", %{activity: %{id: id, data: %{"object" => _object}} = ac
url = url =
if user.local do if user.local do
Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity) url(~p[/notice/#{activity}])
else else
object.data["url"] || object.data["external_url"] || object.data["id"] object.data["url"] || object.data["external_url"] || object.data["id"]
end end

View file

@ -1,7 +1,6 @@
defmodule Pleroma.Web.MastodonAPI.TagView do defmodule Pleroma.Web.MastodonAPI.TagView do
use Pleroma.Web, :view use Pleroma.Web, :view
alias Pleroma.User alias Pleroma.User
alias Pleroma.Web.Router.Helpers
def render("index.json", %{tags: tags, for_user: user}) do def render("index.json", %{tags: tags, for_user: user}) do
render_many(tags, __MODULE__, "show.json", %{for_user: user}) render_many(tags, __MODULE__, "show.json", %{for_user: user})
@ -17,7 +16,7 @@ def render("show.json", %{tag: tag, for_user: user}) do
%{ %{
name: tag.name, name: tag.name,
url: Helpers.tag_feed_url(Pleroma.Web.Endpoint, :feed, tag.name), url: url(~p[/tags/#{tag.name}]),
history: [], history: [],
following: following following: following
} }

View file

@ -14,6 +14,8 @@ defmodule Pleroma.Web.MediaProxy do
@cachex Pleroma.Config.get([:cachex, :provider], Cachex) @cachex Pleroma.Config.get([:cachex, :provider], Cachex)
@mix_env Mix.env()
def cache_table, do: @cache_table def cache_table, do: @cache_table
@spec in_banned_urls(String.t()) :: boolean() @spec in_banned_urls(String.t()) :: boolean()
@ -144,8 +146,14 @@ def filename(url_or_path) do
if path = URI.parse(url_or_path).path, do: Path.basename(path) if path = URI.parse(url_or_path).path, do: Path.basename(path)
end end
def base_url do if @mix_env == :test do
Config.get([:media_proxy, :base_url], Endpoint.url()) def base_url do
Config.get([:media_proxy, :base_url], Endpoint.url())
end
else
def base_url do
Config.get!([:media_proxy, :base_url])
end
end end
defp proxy_url(path, sig_base64, url_base64, filename) do defp proxy_url(path, sig_base64, url_base64, filename) do

View file

@ -3,9 +3,9 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Metadata.Providers.Feed do defmodule Pleroma.Web.Metadata.Providers.Feed do
alias Pleroma.Web.Endpoint
alias Pleroma.Web.Metadata.Providers.Provider alias Pleroma.Web.Metadata.Providers.Provider
alias Pleroma.Web.Router.Helpers
use Pleroma.Web, :verified_routes
@behaviour Provider @behaviour Provider
@ -16,7 +16,7 @@ def build_tags(%{user: user}) do
[ [
rel: "alternate", rel: "alternate",
type: "application/atom+xml", type: "application/atom+xml",
href: Helpers.user_feed_path(Endpoint, :feed, user.nickname) <> ".atom" href: ~p[/users/#{user.nickname}/feed.atom]
], []} ], []}
] ]
end end

View file

@ -10,6 +10,8 @@ defmodule Pleroma.Web.Metadata.Providers.TwitterCard do
alias Pleroma.Web.Metadata.Providers.Provider alias Pleroma.Web.Metadata.Providers.Provider
alias Pleroma.Web.Metadata.Utils alias Pleroma.Web.Metadata.Utils
use Pleroma.Web, :verified_routes
@behaviour Provider @behaviour Provider
@media_types ["image", "audio", "video"] @media_types ["image", "audio", "video"]
@ -112,7 +114,7 @@ defp build_attachments(id, %{data: %{"attachment" => attachments}}) do
defp build_attachments(_id, _object), do: [] defp build_attachments(_id, _object), do: []
defp player_url(id) do defp player_url(id) do
Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice_player, id) url(~p[/notice/#{id}/embed_player])
end end
# Videos have problems without dimensions, but we used to not provide WxH for images. # Videos have problems without dimensions, but we used to not provide WxH for images.

View file

@ -39,6 +39,7 @@ defmodule Pleroma.Web.OAuth.OAuthController do
action_fallback(Pleroma.Web.OAuth.FallbackController) action_fallback(Pleroma.Web.OAuth.FallbackController)
@oob_token_redirect_uri "urn:ietf:wg:oauth:2.0:oob" @oob_token_redirect_uri "urn:ietf:wg:oauth:2.0:oob"
@state_cookie_name "akkoma_oauth_state"
# Note: this definition is only called from error-handling methods with `conn.params` as 2nd arg # Note: this definition is only called from error-handling methods with `conn.params` as 2nd arg
def authorize(%Plug.Conn{} = conn, %{"authorization" => _} = params) do def authorize(%Plug.Conn{} = conn, %{"authorization" => _} = params) do
@ -443,13 +444,10 @@ def prepare_request(%Plug.Conn{} = conn, %{
|> Map.put("scope", scope) |> Map.put("scope", scope)
|> Jason.encode!() |> Jason.encode!()
params =
auth_attrs
|> Map.drop(~w(scope scopes client_id redirect_uri))
|> Map.put("state", state)
# Handing the request to Ueberauth # Handing the request to Ueberauth
redirect(conn, to: Routes.o_auth_path(conn, :request, provider, params)) conn
|> put_resp_cookie(@state_cookie_name, state)
|> redirect(to: ~p"/oauth/#{provider}")
end end
def request(%Plug.Conn{} = conn, params) do def request(%Plug.Conn{} = conn, params) do
@ -468,20 +466,26 @@ def request(%Plug.Conn{} = conn, params) do
end end
def callback(%Plug.Conn{assigns: %{ueberauth_failure: failure}} = conn, params) do def callback(%Plug.Conn{assigns: %{ueberauth_failure: failure}} = conn, params) do
params = callback_params(params) params = callback_params(conn, params)
messages = for e <- Map.get(failure, :errors, []), do: e.message messages = for e <- Map.get(failure, :errors, []), do: e.message
message = Enum.join(messages, "; ") message = Enum.join(messages, "; ")
conn error_message = dgettext("errors", "Failed to authenticate: %{message}.", message: message)
|> put_flash(
:error, if params["redirect_uri"] do
dgettext("errors", "Failed to authenticate: %{message}.", message: message) conn
) |> put_flash(
|> redirect(external: redirect_uri(conn, params["redirect_uri"])) :error,
error_message
)
|> redirect(external: redirect_uri(conn, params["redirect_uri"]))
else
send_resp(conn, :bad_request, error_message)
end
end end
def callback(%Plug.Conn{} = conn, params) do def callback(%Plug.Conn{} = conn, params) do
params = callback_params(params) params = callback_params(conn, params)
with {:ok, registration} <- Authenticator.get_registration(conn) do with {:ok, registration} <- Authenticator.get_registration(conn) do
auth_attrs = Map.take(params, ~w(client_id redirect_uri scope scopes state)) auth_attrs = Map.take(params, ~w(client_id redirect_uri scope scopes state))
@ -511,8 +515,9 @@ def callback(%Plug.Conn{} = conn, params) do
end end
end end
defp callback_params(%{"state" => state} = params) do defp callback_params(%Plug.Conn{} = conn, params) do
Map.merge(params, Jason.decode!(state)) fetch_cookies(conn)
Map.merge(params, Jason.decode!(Map.get(conn.req_cookies, @state_cookie_name, "{}")))
end end
def registration_details(%Plug.Conn{} = conn, %{"authorization" => auth_attrs}) do def registration_details(%Plug.Conn{} = conn, %{"authorization" => auth_attrs}) do
@ -623,7 +628,7 @@ def login(%User{} = user, %App{} = app, requested_scopes) when is_list(requested
end end
# Special case: Local MastodonFE # Special case: Local MastodonFE
defp redirect_uri(%Plug.Conn{} = conn, "."), do: Routes.auth_url(conn, :login) defp redirect_uri(_, "."), do: url(~p"/web/login")
defp redirect_uri(%Plug.Conn{}, redirect_uri), do: redirect_uri defp redirect_uri(%Plug.Conn{}, redirect_uri), do: redirect_uri

View file

@ -14,7 +14,6 @@ defmodule Pleroma.Web.OStatus.OStatusController do
alias Pleroma.Web.Fallback.RedirectController alias Pleroma.Web.Fallback.RedirectController
alias Pleroma.Web.Metadata.PlayerView alias Pleroma.Web.Metadata.PlayerView
alias Pleroma.Web.Plugs.RateLimiter alias Pleroma.Web.Plugs.RateLimiter
alias Pleroma.Web.Router
plug( plug(
RateLimiter, RateLimiter,
@ -87,7 +86,7 @@ def notice(%{assigns: %{format: format}} = conn, %{"id" => id}) do
%{ %{
activity_id: activity.id, activity_id: activity.id,
object: object, object: object,
url: Router.Helpers.o_status_url(Endpoint, :notice, activity.id), url: url(~p[/notice/#{activity.id}]),
user: user user: user
} }
) )

View file

@ -5,8 +5,9 @@
defmodule Pleroma.Web.Plugs.HTTPSignaturePlug do defmodule Pleroma.Web.Plugs.HTTPSignaturePlug do
import Plug.Conn import Plug.Conn
import Phoenix.Controller, only: [get_format: 1] import Phoenix.Controller, only: [get_format: 1]
use Pleroma.Web, :verified_routes
alias Pleroma.Activity alias Pleroma.Activity
alias Pleroma.Web.Router
alias Pleroma.Signature alias Pleroma.Signature
alias Pleroma.Instances alias Pleroma.Instances
require Logger require Logger
@ -32,10 +33,10 @@ def call(conn, _opts) do
end end
def route_aliases(%{path_info: ["objects", id], query_string: query_string}) 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) ap_id = url(~p[/objects/#{id}])
with %Activity{} = activity <- Activity.get_by_object_ap_id_with_object(ap_id) do with %Activity{} = activity <- Activity.get_by_object_ap_id_with_object(ap_id) do
["/notice/#{activity.id}", "/notice/#{activity.id}?#{query_string}"] [~p"/notice/#{activity.id}", "/notice/#{activity.id}?#{query_string}"]
else else
_ -> [] _ -> []
end end

View file

@ -3,8 +3,12 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Plugs.InstanceStatic do defmodule Pleroma.Web.Plugs.InstanceStatic do
import Plug.Conn
require Pleroma.Constants require Pleroma.Constants
alias Pleroma.Web.Plugs.Utils
@moduledoc """ @moduledoc """
This is a shim to call `Plug.Static` but with runtime `from` configuration. This is a shim to call `Plug.Static` but with runtime `from` configuration.
@ -43,11 +47,25 @@ def call(conn, _) do
conn conn
end end
defp call_static(conn, opts, from) do defp set_static_content_type(conn, "/emoji/" <> _ = request_path) do
real_mime = MIME.from_path(request_path)
safe_mime = Utils.get_safe_mime_type(%{allowed_mime_types: ["image"]}, real_mime)
put_resp_header(conn, "content-type", safe_mime)
end
defp set_static_content_type(conn, request_path) do
put_resp_header(conn, "content-type", MIME.from_path(request_path))
end
defp call_static(%{request_path: request_path} = conn, opts, from) do
opts = opts =
opts opts
|> Map.put(:from, from) |> Map.put(:from, from)
|> Map.put(:set_content_type, false)
Plug.Static.call(conn, opts) conn
|> set_static_content_type(request_path)
|> Pleroma.Web.Plugs.StaticNoCT.call(opts)
end end
end end

View file

@ -0,0 +1,469 @@
# This is almost identical to Plug.Static from Plug 1.15.3 (2024-01-16)
# It being copied is a temporary measure to fix an urgent bug without
# needing to wait for merge of a suitable patch upstream
# The differences are:
# - this leading comment
# - renaming of the module from 'Plug.Static' to 'Pleroma.Web.Plugs.StaticNoCT'
# - additon of set_content_type option
defmodule Pleroma.Web.Plugs.StaticNoCT do
@moduledoc """
A plug for serving static assets.
It requires two options:
* `:at` - the request path to reach for static assets.
It must be a string.
* `:from` - the file system path to read static assets from.
It can be either: a string containing a file system path, an
atom representing the application name (where assets will
be served from `priv/static`), a tuple containing the
application name and the directory to serve assets from (besides
`priv/static`), or an MFA tuple.
The preferred form is to use `:from` with an atom or tuple, since
it will make your application independent from the starting directory.
For example, if you pass:
plug Plug.Static, from: "priv/app/path"
Plug.Static will be unable to serve assets if you build releases
or if you change the current directory. Instead do:
plug Plug.Static, from: {:app_name, "priv/app/path"}
If a static asset cannot be found, `Plug.Static` simply forwards
the connection to the rest of the pipeline.
## Cache mechanisms
`Plug.Static` uses etags for HTTP caching. This means browsers/clients
should cache assets on the first request and validate the cache on
following requests, not downloading the static asset once again if it
has not changed. The cache-control for etags is specified by the
`cache_control_for_etags` option and defaults to `"public"`.
However, `Plug.Static` also supports direct cache control by using
versioned query strings. If the request query string starts with
"?vsn=", `Plug.Static` assumes the application is versioning assets
and does not set the `ETag` header, meaning the cache behaviour will
be specified solely by the `cache_control_for_vsn_requests` config,
which defaults to `"public, max-age=31536000"`.
## Options
* `:encodings` - list of 2-ary tuples where first value is value of
the `Accept-Encoding` header and second is extension of the file to
be served if given encoding is accepted by client. Entries will be tested
in order in list, so entries higher in list will be preferred. Defaults
to: `[]`.
In addition to setting this value directly it supports 2 additional
options for compatibility reasons:
+ `:brotli` - will append `{"br", ".br"}` to the encodings list.
+ `:gzip` - will append `{"gzip", ".gz"}` to the encodings list.
Additional options will be added in the above order (Brotli takes
preference over Gzip) to reflect older behaviour which was set due
to fact that Brotli in general provides better compression ratio than
Gzip.
* `:cache_control_for_etags` - sets the cache header for requests
that use etags. Defaults to `"public"`.
* `:etag_generation` - specify a `{module, function, args}` to be used
to generate an etag. The `path` of the resource will be passed to
the function, as well as the `args`. If this option is not supplied,
etags will be generated based off of file size and modification time.
Note it is [recommended for the etag value to be quoted](https://tools.ietf.org/html/rfc7232#section-2.3),
which Plug won't do automatically.
* `:cache_control_for_vsn_requests` - sets the cache header for
requests starting with "?vsn=" in the query string. Defaults to
`"public, max-age=31536000"`.
* `:only` - filters which requests to serve. This is useful to avoid
file system access on every request when this plug is mounted
at `"/"`. For example, if `only: ["images", "favicon.ico"]` is
specified, only files in the "images" directory and the
"favicon.ico" file will be served by `Plug.Static`.
Note that `Plug.Static` matches these filters against request
uri and not against the filesystem. When requesting
a file with name containing non-ascii or special characters,
you should use urlencoded form. For example, you should write
`only: ["file%20name"]` instead of `only: ["file name"]`.
Defaults to `nil` (no filtering).
* `:only_matching` - a relaxed version of `:only` that will
serve any request as long as one of the given values matches the
given path. For example, `only_matching: ["images", "favicon"]`
will match any request that starts at "images" or "favicon",
be it "/images/foo.png", "/images-high/foo.png", "/favicon.ico"
or "/favicon-high.ico". Such matches are useful when serving
digested files at the root. Defaults to `nil` (no filtering).
* `:headers` - other headers to be set when serving static assets. Specify either
an enum of key-value pairs or a `{module, function, args}` to return an enum. The
`conn` will be passed to the function, as well as the `args`.
* `:content_types` - custom MIME type mapping. As a map with filename as key
and content type as value. For example:
`content_types: %{"apple-app-site-association" => "application/json"}`.
* `:set_content_type` - by default Plug.Static (re)sets the content type header
using auto-detection and the `:content_types` map. But when set to `false`
no content-type header will be inserted instead retaining the original
value or lack thereof. This can be useful when custom logic for appropiate
content types is needed which cannot be reasonably expressed as a static
filename map.
## Examples
This plug can be mounted in a `Plug.Builder` pipeline as follows:
defmodule MyPlug do
use Plug.Builder
plug Plug.Static,
at: "/public",
from: :my_app,
only: ~w(images robots.txt)
plug :not_found
def not_found(conn, _) do
send_resp(conn, 404, "not found")
end
end
"""
@behaviour Plug
@allowed_methods ~w(GET HEAD)
import Plug.Conn
alias Plug.Conn
# In this module, the `:prim_file` Erlang module along with the `:file_info`
# record are used instead of the more common and Elixir-y `File` module and
# `File.Stat` struct, respectively. The reason behind this is performance: all
# the `File` operations pass through a single process in order to support node
# operations that we simply don't need when serving assets.
require Record
Record.defrecordp(:file_info, Record.extract(:file_info, from_lib: "kernel/include/file.hrl"))
defmodule InvalidPathError do
defexception message: "invalid path for static asset", plug_status: 400
end
@impl true
def init(opts) do
from =
case Keyword.fetch!(opts, :from) do
{_, _} = from -> from
{_, _, _} = from -> from
from when is_atom(from) -> {from, "priv/static"}
from when is_binary(from) -> from
_ -> raise ArgumentError, ":from must be an atom, a binary or a tuple"
end
encodings =
opts
|> Keyword.get(:encodings, [])
|> maybe_add("br", ".br", Keyword.get(opts, :brotli, false))
|> maybe_add("gzip", ".gz", Keyword.get(opts, :gzip, false))
%{
encodings: encodings,
only_rules: {Keyword.get(opts, :only, []), Keyword.get(opts, :only_matching, [])},
qs_cache: Keyword.get(opts, :cache_control_for_vsn_requests, "public, max-age=31536000"),
et_cache: Keyword.get(opts, :cache_control_for_etags, "public"),
et_generation: Keyword.get(opts, :etag_generation, nil),
headers: Keyword.get(opts, :headers, %{}),
content_types: Keyword.get(opts, :content_types, %{}),
set_content_type: Keyword.get(opts, :set_content_type, true),
from: from,
at: opts |> Keyword.fetch!(:at) |> Plug.Router.Utils.split()
}
end
@impl true
def call(
conn = %Conn{method: meth},
%{at: at, only_rules: only_rules, from: from, encodings: encodings} = options
)
when meth in @allowed_methods do
segments = subset(at, conn.path_info)
if allowed?(only_rules, segments) do
segments = Enum.map(segments, &uri_decode/1)
if invalid_path?(segments) do
raise InvalidPathError, "invalid path for static asset: #{conn.request_path}"
end
path = path(from, segments)
range = get_req_header(conn, "range")
encoding = file_encoding(conn, path, range, encodings)
serve_static(encoding, conn, segments, range, options)
else
conn
end
end
def call(conn, _options) do
conn
end
defp uri_decode(path) do
# TODO: Remove rescue as this can't fail from Elixir v1.13
try do
URI.decode(path)
rescue
ArgumentError ->
raise InvalidPathError
end
end
defp allowed?(_only_rules, []), do: false
defp allowed?({[], []}, _list), do: true
defp allowed?({full, prefix}, [h | _]) do
h in full or (prefix != [] and match?({0, _}, :binary.match(h, prefix)))
end
defp maybe_put_content_type(conn, false, _, _), do: conn
defp maybe_put_content_type(conn, _, types, filename) do
content_type = Map.get(types, filename) || MIME.from_path(filename)
conn
|> put_resp_header("content-type", content_type)
end
defp serve_static({content_encoding, file_info, path}, conn, segments, range, options) do
%{
qs_cache: qs_cache,
et_cache: et_cache,
et_generation: et_generation,
headers: headers,
content_types: types,
set_content_type: set_content_type
} = options
case put_cache_header(conn, qs_cache, et_cache, et_generation, file_info, path) do
{:stale, conn} ->
filename = List.last(segments)
conn
|> maybe_put_content_type(set_content_type, types, filename)
|> put_resp_header("accept-ranges", "bytes")
|> maybe_add_encoding(content_encoding)
|> merge_headers(headers)
|> serve_range(file_info, path, range, options)
{:fresh, conn} ->
conn
|> maybe_add_vary(options)
|> send_resp(304, "")
|> halt()
end
end
defp serve_static(:error, conn, _segments, _range, _options) do
conn
end
defp serve_range(conn, file_info, path, [range], options) do
file_info(size: file_size) = file_info
with %{"bytes" => bytes} <- Plug.Conn.Utils.params(range),
{range_start, range_end} <- start_and_end(bytes, file_size) do
send_range(conn, path, range_start, range_end, file_size, options)
else
_ -> send_entire_file(conn, path, options)
end
end
defp serve_range(conn, _file_info, path, _range, options) do
send_entire_file(conn, path, options)
end
defp start_and_end("-" <> rest, file_size) do
case Integer.parse(rest) do
{last, ""} when last > 0 and last <= file_size -> {file_size - last, file_size - 1}
_ -> :error
end
end
defp start_and_end(range, file_size) do
case Integer.parse(range) do
{first, "-"} when first >= 0 ->
{first, file_size - 1}
{first, "-" <> rest} when first >= 0 ->
case Integer.parse(rest) do
{last, ""} when last >= first -> {first, min(last, file_size - 1)}
_ -> :error
end
_ ->
:error
end
end
defp send_range(conn, path, 0, range_end, file_size, options) when range_end == file_size - 1 do
send_entire_file(conn, path, options)
end
defp send_range(conn, path, range_start, range_end, file_size, _options) do
length = range_end - range_start + 1
conn
|> put_resp_header("content-range", "bytes #{range_start}-#{range_end}/#{file_size}")
|> send_file(206, path, range_start, length)
|> halt()
end
defp send_entire_file(conn, path, options) do
conn
|> maybe_add_vary(options)
|> send_file(200, path)
|> halt()
end
defp maybe_add_encoding(conn, nil), do: conn
defp maybe_add_encoding(conn, ce), do: put_resp_header(conn, "content-encoding", ce)
defp maybe_add_vary(conn, %{encodings: encodings}) do
# If we serve gzip or brotli at any moment, we need to set the proper vary
# header regardless of whether we are serving gzip content right now.
# See: http://www.fastly.com/blog/best-practices-for-using-the-vary-header/
if encodings != [] do
update_in(conn.resp_headers, &[{"vary", "Accept-Encoding"} | &1])
else
conn
end
end
defp put_cache_header(
%Conn{query_string: "vsn=" <> _} = conn,
qs_cache,
_et_cache,
_et_generation,
_file_info,
_path
)
when is_binary(qs_cache) do
{:stale, put_resp_header(conn, "cache-control", qs_cache)}
end
defp put_cache_header(conn, _qs_cache, et_cache, et_generation, file_info, path)
when is_binary(et_cache) do
etag = etag_for_path(file_info, et_generation, path)
conn =
conn
|> put_resp_header("cache-control", et_cache)
|> put_resp_header("etag", etag)
if etag in get_req_header(conn, "if-none-match") do
{:fresh, conn}
else
{:stale, conn}
end
end
defp put_cache_header(conn, _, _, _, _, _) do
{:stale, conn}
end
defp etag_for_path(file_info, et_generation, path) do
case et_generation do
{module, function, args} ->
apply(module, function, [path | args])
nil ->
file_info(size: size, mtime: mtime) = file_info
<<?", {size, mtime} |> :erlang.phash2() |> Integer.to_string(16)::binary, ?">>
end
end
defp file_encoding(conn, path, [_range], _encodings) do
# We do not support compression for range queries.
file_encoding(conn, path, nil, [])
end
defp file_encoding(conn, path, _range, encodings) do
encoded =
Enum.find_value(encodings, fn {encoding, ext} ->
if file_info = accept_encoding?(conn, encoding) && regular_file_info(path <> ext) do
{encoding, file_info, path <> ext}
end
end)
cond do
not is_nil(encoded) ->
encoded
file_info = regular_file_info(path) ->
{nil, file_info, path}
true ->
:error
end
end
defp regular_file_info(path) do
case :prim_file.read_file_info(path) do
{:ok, file_info(type: :regular) = file_info} ->
file_info
_ ->
nil
end
end
defp accept_encoding?(conn, encoding) do
encoding? = &String.contains?(&1, [encoding, "*"])
Enum.any?(get_req_header(conn, "accept-encoding"), fn accept ->
accept |> Plug.Conn.Utils.list() |> Enum.any?(encoding?)
end)
end
defp maybe_add(list, key, value, true), do: list ++ [{key, value}]
defp maybe_add(list, _key, _value, false), do: list
defp path({module, function, arguments}, segments)
when is_atom(module) and is_atom(function) and is_list(arguments),
do: Enum.join([apply(module, function, arguments) | segments], "/")
defp path({app, from}, segments) when is_atom(app) and is_binary(from),
do: Enum.join([Application.app_dir(app), from | segments], "/")
defp path(from, segments),
do: Enum.join([from | segments], "/")
defp subset([h | expected], [h | actual]), do: subset(expected, actual)
defp subset([], actual), do: actual
defp subset(_, _), do: []
defp invalid_path?(list) do
invalid_path?(list, :binary.compile_pattern(["/", "\\", ":", "\0"]))
end
defp invalid_path?([h | _], _match) when h in [".", "..", ""], do: true
defp invalid_path?([h | t], match), do: String.contains?(h, match) or invalid_path?(t)
defp invalid_path?([], _match), do: false
defp merge_headers(conn, {module, function, args}) do
merge_headers(conn, apply(module, function, [conn | args]))
end
defp merge_headers(conn, headers) do
merge_resp_headers(conn, headers)
end
end

View file

@ -11,6 +11,7 @@ defmodule Pleroma.Web.Plugs.UploadedMedia do
require Logger require Logger
alias Pleroma.Web.MediaProxy alias Pleroma.Web.MediaProxy
alias Pleroma.Web.Plugs.Utils
@behaviour Plug @behaviour Plug
# no slashes # no slashes
@ -28,10 +29,21 @@ def init(_opts) do
|> Keyword.put(:at, "/__unconfigured_media_plug") |> Keyword.put(:at, "/__unconfigured_media_plug")
|> Plug.Static.init() |> Plug.Static.init()
%{static_plug_opts: static_plug_opts} config = Pleroma.Config.get(Pleroma.Upload)
allowed_mime_types = Keyword.fetch!(config, :allowed_mime_types)
uploader = Keyword.fetch!(config, :uploader)
%{
static_plug_opts: static_plug_opts,
allowed_mime_types: allowed_mime_types,
uploader: uploader
}
end end
def call(%{request_path: <<"/", @path, "/", file::binary>>} = conn, opts) do def call(
%{request_path: <<"/", @path, "/", file::binary>>} = conn,
%{uploader: uploader} = opts
) do
conn = conn =
case fetch_query_params(conn) do case fetch_query_params(conn) do
%{query_params: %{"name" => name}} = conn -> %{query_params: %{"name" => name}} = conn ->
@ -44,10 +56,7 @@ def call(%{request_path: <<"/", @path, "/", file::binary>>} = conn, opts) do
end end
|> merge_resp_headers([{"content-security-policy", "sandbox"}]) |> merge_resp_headers([{"content-security-policy", "sandbox"}])
config = Pleroma.Config.get(Pleroma.Upload) with {:ok, get_method} <- uploader.get_file(file),
with uploader <- Keyword.fetch!(config, :uploader),
{:ok, get_method} <- uploader.get_file(file),
false <- media_is_banned(conn, get_method) do false <- media_is_banned(conn, get_method) do
get_media(conn, get_method, opts) get_media(conn, get_method, opts)
else else
@ -68,13 +77,23 @@ defp media_is_banned(_, {:url, url}), do: MediaProxy.in_banned_urls(url)
defp media_is_banned(_, _), do: false defp media_is_banned(_, _), do: false
defp set_content_type(conn, opts, filepath) do
real_mime = MIME.from_path(filepath)
clean_mime = Utils.get_safe_mime_type(opts, real_mime)
put_resp_header(conn, "content-type", clean_mime)
end
defp get_media(conn, {:static_dir, directory}, opts) do defp get_media(conn, {:static_dir, directory}, opts) do
static_opts = static_opts =
Map.get(opts, :static_plug_opts) Map.get(opts, :static_plug_opts)
|> Map.put(:at, [@path]) |> Map.put(:at, [@path])
|> Map.put(:from, directory) |> Map.put(:from, directory)
|> Map.put(:set_content_type, false)
conn = Plug.Static.call(conn, static_opts) conn =
conn
|> set_content_type(opts, conn.request_path)
|> Pleroma.Web.Plugs.StaticNoCT.call(static_opts)
if conn.halted do if conn.halted do
conn conn

View file

@ -0,0 +1,14 @@
# Akkoma: Magically expressive social media
# Copyright © 2024 Akkoma Authors <https://akkoma.dev>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Plugs.Utils do
@moduledoc """
Some helper functions shared across several plugs
"""
def get_safe_mime_type(%{allowed_mime_types: allowed_mime_types} = _opts, mime) do
[maintype | _] = String.split(mime, "/", parts: 2)
if maintype in allowed_mime_types, do: mime, else: "application/octet-stream"
end
end

View file

@ -630,6 +630,8 @@ defmodule Pleroma.Web.Router do
post("/tags/:id/follow", TagController, :follow) post("/tags/:id/follow", TagController, :follow)
post("/tags/:id/unfollow", TagController, :unfollow) post("/tags/:id/unfollow", TagController, :unfollow)
get("/followed_tags", TagController, :show_followed) get("/followed_tags", TagController, :show_followed)
get("/preferences", AccountController, :preferences)
end end
scope "/api/web", Pleroma.Web do scope "/api/web", Pleroma.Web do

View file

@ -11,7 +11,6 @@ defmodule Pleroma.Web.StaticFE.StaticFEController do
alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.ActivityPub.Visibility alias Pleroma.Web.ActivityPub.Visibility
alias Pleroma.Web.Metadata alias Pleroma.Web.Metadata
alias Pleroma.Web.Router.Helpers
plug(:put_layout, :static_fe) plug(:put_layout, :static_fe)
plug(:assign_id) plug(:assign_id)
@ -25,7 +24,13 @@ def show(%{assigns: %{notice_id: notice_id}} = conn, _params) do
true <- Visibility.is_public?(activity.object), true <- Visibility.is_public?(activity.object),
{_, true} <- {:visible?, Visibility.visible_for_user?(activity, _reading_user = nil)}, {_, true} <- {:visible?, Visibility.visible_for_user?(activity, _reading_user = nil)},
%User{} = user <- User.get_by_ap_id(activity.object.data["actor"]) do %User{} = user <- User.get_by_ap_id(activity.object.data["actor"]) do
meta = Metadata.build_tags(%{url: activity.data["id"], object: activity.object, user: user}) meta =
Metadata.build_tags(%{
activity_id: notice_id,
url: activity.data["id"],
object: activity.object,
user: user
})
timeline = timeline =
activity.object.data["context"] activity.object.data["context"]
@ -111,11 +116,11 @@ def show(%{assigns: %{username_or_id: username_or_id, tab: tab}} = conn, params)
end end
def show(%{assigns: %{object_id: _}} = conn, _params) do def show(%{assigns: %{object_id: _}} = conn, _params) do
url = Helpers.url(conn) <> conn.request_path url = unverified_url(conn, conn.request_path)
case Activity.get_create_by_object_ap_id_with_object(url) do case Activity.get_create_by_object_ap_id_with_object(url) do
%Activity{} = activity -> %Activity{} = activity ->
to = Helpers.o_status_path(Pleroma.Web.Endpoint, :notice, activity) to = ~p[/notice/#{activity}]
redirect(conn, to: to) redirect(conn, to: to)
_ -> _ ->
@ -124,11 +129,11 @@ def show(%{assigns: %{object_id: _}} = conn, _params) do
end end
def show(%{assigns: %{activity_id: _}} = conn, _params) do def show(%{assigns: %{activity_id: _}} = conn, _params) do
url = Helpers.url(conn) <> conn.request_path url = unverified_url(conn, conn.request_path)
case Activity.get_by_ap_id(url) do case Activity.get_by_ap_id(url) do
%Activity{} = activity -> %Activity{} = activity ->
to = Helpers.o_status_path(Pleroma.Web.Endpoint, :notice, activity) to = ~p[/notice/#{activity}]
redirect(conn, to: to) redirect(conn, to: to)
_ -> _ ->
@ -167,7 +172,7 @@ defp represent(%Activity{object: %Object{data: data}} = activity, selected) do
link = link =
case user.local do case user.local do
true -> Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity) true -> ~p[/notice/#{activity}]
_ -> data["url"] || data["external_url"] || data["id"] _ -> data["url"] || data["external_url"] || data["id"]
end end

View file

@ -11,7 +11,6 @@ defmodule Pleroma.Web.StaticFE.StaticFEView do
alias Pleroma.Web.Gettext alias Pleroma.Web.Gettext
alias Pleroma.Web.MediaProxy alias Pleroma.Web.MediaProxy
alias Pleroma.Web.Metadata.Utils alias Pleroma.Web.Metadata.Utils
alias Pleroma.Web.Router.Helpers
use Phoenix.HTML use Phoenix.HTML

View file

@ -101,7 +101,8 @@ defp distribution_metrics do
] ]
end end
defp summary_metrics do # Summary metrics are currently not (yet) supported by the prometheus exporter
defp summary_metrics(byte_unit) do
[ [
# Phoenix Metrics # Phoenix Metrics
summary("phoenix.endpoint.stop.duration", summary("phoenix.endpoint.stop.duration",
@ -118,10 +119,98 @@ defp summary_metrics do
summary("pleroma.repo.query.idle_time", unit: {:native, :millisecond}), summary("pleroma.repo.query.idle_time", unit: {:native, :millisecond}),
# VM Metrics # VM Metrics
summary("vm.memory.total", unit: {:byte, :kilobyte}), summary("vm.memory.total", unit: {:byte, byte_unit}),
summary("vm.total_run_queue_lengths.total"), summary("vm.total_run_queue_lengths.total"),
summary("vm.total_run_queue_lengths.cpu"), summary("vm.total_run_queue_lengths.cpu"),
summary("vm.total_run_queue_lengths.io"), summary("vm.total_run_queue_lengths.io")
]
end
defp sum_counter_pair(basename, opts) do
[
sum(basename <> ".psum", opts),
counter(basename <> ".pcount", opts)
]
end
# Prometheus exporter doesn't support summaries, so provide fallbacks
defp summary_fallback_metrics(byte_unit \\ :byte) do
# Summary metrics are not supported by the Prometheus exporter
# https://github.com/beam-telemetry/telemetry_metrics_prometheus_core/issues/11
# and sum metrics currently only work with integers
# https://github.com/beam-telemetry/telemetry_metrics_prometheus_core/issues/35
#
# For VM metrics this is kindof ok as they appear to always be integers
# and we can use sum + counter to get the average between polls from their change
# But for repo query times we need to use a full distribution
simple_buckets = [0, 1, 2, 4, 8, 16]
simple_buckets_quick = for t <- simple_buckets, do: t / 100.0
# Already included in distribution metrics anyway:
# phoenix.router_dispatch.stop.duration
# pleroma.repo.query.total_time
# pleroma.repo.query.queue_time
dist_metrics =
[
distribution("phoenix.endpoint.stop.duration.fdist",
event_name: [:phoenix, :endpoint, :stop],
measurement: :duration,
unit: {:native, :millisecond},
reporter_options: [
buckets: simple_buckets
]
),
distribution("pleroma.repo.query.decode_time.fdist",
event_name: [:pleroma, :repo, :query],
measurement: :decode_time,
unit: {:native, :millisecond},
reporter_options: [
buckets: simple_buckets_quick
]
),
distribution("pleroma.repo.query.query_time.fdist",
event_name: [:pleroma, :repo, :query],
measurement: :query_time,
unit: {:native, :millisecond},
reporter_options: [
buckets: simple_buckets
]
),
distribution("pleroma.repo.query.idle_time.fdist",
event_name: [:pleroma, :repo, :query],
measurement: :idle_time,
unit: {:native, :millisecond},
reporter_options: [
buckets: simple_buckets
]
)
]
vm_metrics =
sum_counter_pair("vm.memory.total",
event_name: [:vm, :memory],
measurement: :total,
unit: {:byte, byte_unit}
) ++
sum_counter_pair("vm.total_run_queue_lengths.total",
event_name: [:vm, :total_run_queue_lengths],
measurement: :total
) ++
sum_counter_pair("vm.total_run_queue_lengths.cpu",
event_name: [:vm, :total_run_queue_lengths],
measurement: :cpu
) ++
sum_counter_pair("vm.total_run_queue_lengths.io.fsum",
event_name: [:vm, :total_run_queue_lengths],
measurement: :io
)
dist_metrics ++ vm_metrics
end
defp common_metrics do
[
last_value("pleroma.local_users.total"), last_value("pleroma.local_users.total"),
last_value("pleroma.domains.total"), last_value("pleroma.domains.total"),
last_value("pleroma.local_statuses.total"), last_value("pleroma.local_statuses.total"),
@ -129,8 +218,10 @@ defp summary_metrics do
] ]
end end
def prometheus_metrics, do: summary_metrics() ++ distribution_metrics() def prometheus_metrics,
def live_dashboard_metrics, do: summary_metrics() do: common_metrics() ++ distribution_metrics() ++ summary_fallback_metrics()
def live_dashboard_metrics, do: common_metrics() ++ summary_metrics(:megabyte)
defp periodic_measurements do defp periodic_measurements do
[ [

View file

@ -2,7 +2,7 @@
<h3>After you submit, you will need to refresh manually to get your new frontend!</h3> <h3>After you submit, you will need to refresh manually to get your new frontend!</h3>
<%= form_for @conn, Routes.frontend_switcher_path(@conn, :do_switch), fn f -> %> <%= form_for @conn, ~p"/akkoma/frontend", fn f -> %>
<%= select(f, :frontend, @choices) %> <%= select(f, :frontend, @choices) %>
<%= submit do: "submit" %> <%= submit do: "submit" %>

View file

@ -9,13 +9,13 @@
xmlns:ostatus="http://ostatus.org/schema/1.0" xmlns:ostatus="http://ostatus.org/schema/1.0"
xmlns:statusnet="http://status.net/schema/api/1/"> xmlns:statusnet="http://status.net/schema/api/1/">
<id><%= '#{Routes.tag_feed_url(@conn, :feed, @tag)}.rss' %></id> <id><%= '#{url(~p"/tags/#{@tag}")}.rss' %></id>
<title>#<%= @tag %></title> <title>#<%= @tag %></title>
<subtitle><%= Gettext.dpgettext("static_pages", "tag feed description", "These are public toots tagged with #%{tag}. You can interact with them if you have an account anywhere in the fediverse.", tag: @tag) %></subtitle> <subtitle><%= Gettext.dpgettext("static_pages", "tag feed description", "These are public toots tagged with #%{tag}. You can interact with them if you have an account anywhere in the fediverse.", tag: @tag) %></subtitle>
<logo><%= feed_logo() %></logo> <logo><%= feed_logo() %></logo>
<updated><%= most_recent_update(@activities) %></updated> <updated><%= most_recent_update(@activities) %></updated>
<link rel="self" href="<%= '#{Routes.tag_feed_url(@conn, :feed, @tag)}.atom' %>" type="application/atom+xml"/> <link rel="self" href="<%= '#{url(~p"/tags/#{@tag}")}.atom' %>" type="application/atom+xml"/>
<%= for activity <- @activities do %> <%= for activity <- @activities do %>
<%= render @view_module, "_tag_activity.atom", Map.merge(assigns, prepare_activity(activity, actor: true)) %> <%= render @view_module, "_tag_activity.atom", Map.merge(assigns, prepare_activity(activity, actor: true)) %>
<% end %> <% end %>

View file

@ -5,7 +5,7 @@
<title>#<%= @tag %></title> <title>#<%= @tag %></title>
<description><%= Gettext.dpgettext("static_pages", "tag feed description", "These are public toots tagged with #%{tag}. You can interact with them if you have an account anywhere in the fediverse.", tag: @tag) %></description> <description><%= Gettext.dpgettext("static_pages", "tag feed description", "These are public toots tagged with #%{tag}. You can interact with them if you have an account anywhere in the fediverse.", tag: @tag) %></description>
<link><%= '#{Routes.tag_feed_url(@conn, :feed, @tag)}.rss' %></link> <link><%= '#{url(~p"/tags/#{@tag}")}.rss' %></link>
<webfeeds:logo><%= feed_logo() %></webfeeds:logo> <webfeeds:logo><%= feed_logo() %></webfeeds:logo>
<webfeeds:accentColor>2b90d9</webfeeds:accentColor> <webfeeds:accentColor>2b90d9</webfeeds:accentColor>
<%= for activity <- @activities do %> <%= for activity <- @activities do %>

View file

@ -6,16 +6,16 @@
xmlns:poco="http://portablecontacts.net/spec/1.0" xmlns:poco="http://portablecontacts.net/spec/1.0"
xmlns:ostatus="http://ostatus.org/schema/1.0"> xmlns:ostatus="http://ostatus.org/schema/1.0">
<id><%= Routes.user_feed_url(@conn, :feed, @user.nickname) <> ".atom" %></id> <id><%= url(~p"/users/#{@user.nickname}/feed") <> ".atom" %></id>
<title><%= @user.nickname <> "'s timeline" %></title> <title><%= @user.nickname <> "'s timeline" %></title>
<updated><%= most_recent_update(@activities, @user) %></updated> <updated><%= most_recent_update(@activities, @user) %></updated>
<logo><%= logo(@user) %></logo> <logo><%= logo(@user) %></logo>
<link rel="self" href="<%= '#{Routes.user_feed_url(@conn, :feed, @user.nickname)}.atom' %>" type="application/atom+xml"/> <link rel="self" href="<%= '#{url(~p"/users/#{@user.nickname}/feed")}.atom' %>" type="application/atom+xml"/>
<%= render @view_module, "_author.atom", assigns %> <%= render @view_module, "_author.atom", assigns %>
<%= if last_activity(@activities) do %> <%= if last_activity(@activities) do %>
<link rel="next" href="<%= '#{Routes.user_feed_url(@conn, :feed, @user.nickname)}.atom?max_id=#{last_activity(@activities).id}' %>" type="application/atom+xml"/> <link rel="next" href="<%= '#{url(~p"/users/#{@user.nickname}/feed")}.atom?max_id=#{last_activity(@activities).id}' %>" type="application/atom+xml"/>
<% end %> <% end %>
<%= for activity <- @activities do %> <%= for activity <- @activities do %>

View file

@ -1,16 +1,16 @@
<?xml version="1.0" encoding="UTF-8" ?> <?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0"> <rss version="2.0">
<channel> <channel>
<guid><%= Routes.user_feed_url(@conn, :feed, @user.nickname) <> ".rss" %></guid> <guid><%= url(~p"/users/#{@user.nickname}/feed") <> ".rss" %></guid>
<title><%= @user.nickname <> "'s timeline" %></title> <title><%= @user.nickname <> "'s timeline" %></title>
<updated><%= most_recent_update(@activities, @user) %></updated> <updated><%= most_recent_update(@activities, @user) %></updated>
<image><%= logo(@user) %></image> <image><%= logo(@user) %></image>
<link><%= '#{Routes.user_feed_url(@conn, :feed, @user.nickname)}.rss' %></link> <link><%= '#{url(~p"/users/#{@user.nickname}/feed")}.rss' %></link>
<%= render @view_module, "_author.rss", assigns %> <%= render @view_module, "_author.rss", assigns %>
<%= if last_activity(@activities) do %> <%= if last_activity(@activities) do %>
<link rel="next"><%= '#{Routes.user_feed_url(@conn, :feed, @user.nickname)}.rss?max_id=#{last_activity(@activities).id}' %></link> <link rel="next"><%= '#{url(~p"/users/#{@user.nickname}/feed")}.rss?max_id=#{last_activity(@activities).id}' %></link>
<% end %> <% end %>
<%= for activity <- @activities do %> <%= for activity <- @activities do %>

View file

@ -0,0 +1,58 @@
<!DOCTYPE html>
<!-- FEDIBIRD -->
<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: ~p"/web/manifest.json"}} />
<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/public_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="{&quot;locale&quot;:&quot;en&quot;}" id="mastodon"></div>
</body>
</html>

View file

@ -1,35 +0,0 @@
<!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/public_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='{&quot;locale&quot;:&quot;en&quot;}' id='mastodon'>
</div>
</body>
</html>

View file

@ -0,0 +1,57 @@
<!DOCTYPE html>
<!-- GLITCHSOC -->
<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: ~p"/web/manifest.json"}} />
<meta name="theme-color" {%{content: Config.get([:manifest, :theme_color])}} />
<script crossorigin="anonymous" src="/packs/js/locales.js">
</script>
<script crossorigin="anonymous" src="/packs/js/locales/glitch/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 id="initial-state" type="application/json">
<%= initial_state(@token, @user, @custom_emojis) %>
</script>
<script src="/packs/js/core/common.js">
</script>
<link rel="stylesheet" media="all" href="/packs/css/core/common.css" />
<script src="/packs/js/flavours/glitch/common.js">
</script>
<link rel="stylesheet" media="all" href="/packs/css/flavours/glitch/common.css" />
<script src="/packs/js/flavours/glitch/home.js">
</script>
</head>
<body class="app-body no-reduce-motion system-font">
<div class="app-holder" data-props="{&quot;locale&quot;:&quot;en&quot;}" id="mastodon"></div>
</body>
</html>

View file

@ -1,35 +0,0 @@
<!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 crossorigin='anonymous' src="/packs/js/locales.js"></script>
<script crossorigin='anonymous' src="/packs/js/locales/glitch/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 id='initial-state' type='application/json'><%= initial_state(@token, @user, @custom_emojis) %></script>
<script src="/packs/js/core/common.js"></script>
<link rel="stylesheet" media="all" href="/packs/css/core/common.css" />
<script src="/packs/js/flavours/glitch/common.js"></script>
<link rel="stylesheet" media="all" href="/packs/css/flavours/glitch/common.css" />
<script src="/packs/js/flavours/glitch/home.js"></script>
</head>
<body class='app-body no-reduce-motion system-font'>
<div class='app-holder' data-props='{&quot;locale&quot;:&quot;en&quot;}' id='mastodon'>
</div>
</body>
</html>

View file

@ -1,15 +1,15 @@
<div> <div>
<%= if get_flash(@conn, :info) do %> <%= if Flash.get(@flash, :info) do %>
<p class="alert alert-info" role="alert"><%= get_flash(@conn, :info) %></p> <p class="alert alert-info" role="alert"><%= Flash.get(@flash, :info) %></p>
<% end %> <% end %>
<%= if get_flash(@conn, :error) do %> <%= if Flash.get(@flash, :error) do %>
<p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p> <p class="alert alert-danger" role="alert"><%= Flash.get(@flash, :error) %></p>
<% end %> <% end %>
<div class="panel-heading"> <div class="panel-heading">
<%= Gettext.dpgettext("static_pages", "mfa recover page title", "Two-factor recovery") %> <%= Gettext.dpgettext("static_pages", "mfa recover page title", "Two-factor recovery") %>
</div> </div>
<div class="panel-content"> <div class="panel-content">
<%= form_for @conn, Routes.mfa_verify_path(@conn, :verify), [as: "mfa"], fn f -> %> <%= form_for @conn, ~p"/oauth/mfa/verify", [as: "mfa"], fn f -> %>
<div class="input"> <div class="input">
<%= label f, :code, Gettext.dpgettext("static_pages", "mfa recover recovery code prompt", "Recovery code") %> <%= label f, :code, Gettext.dpgettext("static_pages", "mfa recover recovery code prompt", "Recovery code") %>
<%= text_input f, :code, [autocomplete: false, autocorrect: "off", autocapitalize: "off", autofocus: true, spellcheck: false] %> <%= text_input f, :code, [autocomplete: false, autocorrect: "off", autocapitalize: "off", autofocus: true, spellcheck: false] %>
@ -21,7 +21,7 @@
<%= submit Gettext.dpgettext("static_pages", "mfa recover verify recovery code button", "Verify") %> <%= submit Gettext.dpgettext("static_pages", "mfa recover verify recovery code button", "Verify") %>
<% end %> <% end %>
<a href="<%= Routes.mfa_path(@conn, :show, %{challenge_type: "totp", mfa_token: @mfa_token, state: @state, redirect_uri: @redirect_uri}) %>"> <a href="<%= ~p"/oauth/mfa?#{[challenge_type: "totp", mfa_token: @mfa_token, state: @state, redirect_uri: @redirect_uri]}" %>">
<%= Gettext.dpgettext("static_pages", "mfa recover use 2fa code link", "Enter a two-factor code") %> <%= Gettext.dpgettext("static_pages", "mfa recover use 2fa code link", "Enter a two-factor code") %>
</a> </a>

View file

@ -1,15 +1,15 @@
<div> <div>
<%= if get_flash(@conn, :info) do %> <%= if Flash.get(@flash, :info) do %>
<p class="alert alert-info" role="alert"><%= get_flash(@conn, :info) %></p> <p class="alert alert-info" role="alert"><%= Flash.get(@flash, :info) %></p>
<% end %> <% end %>
<%= if get_flash(@conn, :error) do %> <%= if Flash.get(@flash, :error) do %>
<p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p> <p class="alert alert-danger" role="alert"><%= Flash.get(@flash, :error) %></p>
<% end %> <% end %>
<div class="panel-heading"> <div class="panel-heading">
<%= Gettext.dpgettext("static_pages", "mfa auth page title", "Two-factor authentication") %> <%= Gettext.dpgettext("static_pages", "mfa auth page title", "Two-factor authentication") %>
</div> </div>
<div class="panel-content"> <div class="panel-content">
<%= form_for @conn, Routes.mfa_verify_path(@conn, :verify), [as: "mfa"], fn f -> %> <%= form_for @conn, ~p"/oauth/mfa/verify", [as: "mfa"], fn f -> %>
<div class="input"> <div class="input">
<%= label f, :code, Gettext.dpgettext("static_pages", "mfa auth code prompt", "Authentication code") %> <%= label f, :code, Gettext.dpgettext("static_pages", "mfa auth code prompt", "Authentication code") %>
<%= text_input f, :code, [autocomplete: "one-time-code", autocorrect: "off", autocapitalize: "off", autofocus: true, pattern: "[0-9]*", spellcheck: false] %> <%= text_input f, :code, [autocomplete: "one-time-code", autocorrect: "off", autocapitalize: "off", autofocus: true, pattern: "[0-9]*", spellcheck: false] %>
@ -21,7 +21,7 @@
<%= submit Gettext.dpgettext("static_pages", "mfa auth verify code button", "Verify") %> <%= submit Gettext.dpgettext("static_pages", "mfa auth verify code button", "Verify") %>
<% end %> <% end %>
<a href="<%= Routes.mfa_path(@conn, :show, %{challenge_type: "recovery", mfa_token: @mfa_token, state: @state, redirect_uri: @redirect_uri}) %>"> <a href="<%= ~p"/oauth/mfa?#{[challenge_type: "recovery", mfa_token: @mfa_token, state: @state, redirect_uri: @redirect_uri]}" %>">
<%= Gettext.dpgettext("static_pages", "mfa auth page use recovery code link", "Enter a two-factor recovery code") %> <%= Gettext.dpgettext("static_pages", "mfa auth page use recovery code link", "Enter a two-factor recovery code") %>
</a> </a>
</div> </div>

View file

@ -1,6 +1,6 @@
<h2><%= Gettext.dpgettext("static_pages", "oauth external provider page title", "Sign in with external provider") %></h2> <h2><%= Gettext.dpgettext("static_pages", "oauth external provider page title", "Sign in with external provider") %></h2>
<%= form_for @conn, Routes.o_auth_path(@conn, :prepare_request), [as: "authorization", method: "get"], fn f -> %> <%= form_for @conn, ~p"/oauth/prepare_request", [as: "authorization", method: "get"], fn f -> %>
<div style="display: none"> <div style="display: none">
<%= render @view_module, "_scopes.html", Map.merge(assigns, %{form: f}) %> <%= render @view_module, "_scopes.html", Map.merge(assigns, %{form: f}) %>
</div> </div>

View file

@ -1,14 +1,14 @@
<%= if get_flash(@conn, :info) do %> <%= if Flash.get(@flash, :info) do %>
<p class="alert alert-info" role="alert"><%= get_flash(@conn, :info) %></p> <p class="alert alert-info" role="alert"><%= Flash.get(@flash, :info) %></p>
<% end %> <% end %>
<%= if get_flash(@conn, :error) do %> <%= if Flash.get(@flash, :error) do %>
<p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p> <p class="alert alert-danger" role="alert"><%= Flash.get(@flash, :error) %></p>
<% end %> <% end %>
<h2><%= Gettext.dpgettext("static_pages", "oauth register page title", "Registration Details") %></h2> <h2><%= Gettext.dpgettext("static_pages", "oauth register page title", "Registration Details") %></h2>
<p><%= Gettext.dpgettext("static_pages", "oauth register page fill form prompt", "If you'd like to register a new account, please provide the details below.") %></p> <p><%= Gettext.dpgettext("static_pages", "oauth register page fill form prompt", "If you'd like to register a new account, please provide the details below.") %></p>
<%= form_for @conn, Routes.o_auth_path(@conn, :register), [as: "authorization"], fn f -> %> <%= form_for @conn, ~p"/oauth/register", [as: "authorization"], fn f -> %>
<div class="input"> <div class="input">
<%= label f, :nickname, Gettext.dpgettext("static_pages", "oauth register page nickname prompt", "Nickname") %> <%= label f, :nickname, Gettext.dpgettext("static_pages", "oauth register page nickname prompt", "Nickname") %>

View file

@ -1,11 +1,11 @@
<%= if get_flash(@conn, :info) do %> <%= if Flash.get(@flash, :info) do %>
<p class="alert alert-info" role="alert"><%= get_flash(@conn, :info) %></p> <p class="alert alert-info" role="alert"><%= Flash.get(@flash, :info) %></p>
<% end %> <% end %>
<%= if get_flash(@conn, :error) do %> <%= if Flash.get(@flash, :error) do %>
<p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p> <p class="alert alert-danger" role="alert"><%= Flash.get(@flash, :error) %></p>
<% end %> <% end %>
<%= form_for @conn, Routes.o_auth_path(@conn, :authorize), [as: "authorization"], fn f -> %> <%= form_for @conn, ~p"/oauth/authorize", [as: "authorization"], fn f -> %>
<%= if @user do %> <%= if @user do %>
<div class="account-header"> <div class="account-header">

View file

@ -36,7 +36,7 @@
</div> </div>
</div> </div>
<div class="remote-follow"> <div class="remote-follow">
<form method="POST" action="<%= Helpers.util_path(@conn, :remote_subscribe) %>"> <form method="POST" action="<%= ~p"/main/ostatus" %>">
<input type="hidden" name="nickname" value="<%= @user.nickname %>"> <input type="hidden" name="nickname" value="<%= @user.nickname %>">
<input type="hidden" name="profile" value=""> <input type="hidden" name="profile" value="">
<button type="submit" class="button-default"><%= Gettext.dpgettext("static_pages", "static fe profile page remote follow button", "Remote follow") %></button> <button type="submit" class="button-default"><%= Gettext.dpgettext("static_pages", "static fe profile page remote follow button", "Remote follow") %></button>

View file

@ -1,5 +1,5 @@
<h2>Password Reset for <%= @user.nickname %></h2> <h2>Password Reset for <%= @user.nickname %></h2>
<%= form_for @conn, Routes.reset_password_path(@conn, :do_reset), [as: "data"], fn f -> %> <%= form_for @conn, ~p"/api/v1/pleroma/password_reset", [as: "data"], fn f -> %>
<div class="form-row"> <div class="form-row">
<%= label f, :password, Gettext.dpgettext("static_pages", "password reset form password prompt", "Password") %> <%= label f, :password, Gettext.dpgettext("static_pages", "password reset form password prompt", "Password") %>
<%= password_input f, :password %> <%= password_input f, :password %>

View file

@ -4,7 +4,7 @@
<h2><%= Gettext.dpgettext("static_pages", "remote follow header", "Remote follow") %></h2> <h2><%= Gettext.dpgettext("static_pages", "remote follow header", "Remote follow") %></h2>
<img height="128" width="128" src="<%= avatar_url(@followee) %>"> <img height="128" width="128" src="<%= avatar_url(@followee) %>">
<p><%= @followee.nickname %></p> <p><%= @followee.nickname %></p>
<%= form_for @conn, Routes.remote_follow_path(@conn, :do_follow), [as: "user"], fn f -> %> <%= form_for @conn, ~p"/ostatus_subscribe", [as: "user"], fn f -> %>
<%= hidden_input f, :id, value: @followee.id %> <%= hidden_input f, :id, value: @followee.id %>
<%= submit Gettext.dpgettext("static_pages", "remote follow authorization button", "Authorize") %> <%= submit Gettext.dpgettext("static_pages", "remote follow authorization button", "Authorize") %>
<% end %> <% end %>

Some files were not shown because too many files have changed in this diff Show more