Merge branch 'develop' into audit-mix-audit-report

This commit is contained in:
Phantasm 2026-08-19 22:20:55 +02:00
commit 45459c8398
No known key found for this signature in database
GPG key ID: 2669E588BCC634C8
156 changed files with 8723 additions and 731 deletions

View file

@ -0,0 +1,64 @@
when:
- event: pull_request
path:
- ".woodpecker/**"
- "lib/pleroma/search/**"
- "test/pleroma/search/**"
- "mix.exs"
- "mix.lock"
evaluate: 'CI_COMMIT_SOURCE_BRANCH != "weblate"'
- event: push
branch: ${CI_REPO_DEFAULT_BRANCH}
path:
- ".woodpecker/**"
- "lib/pleroma/search/**"
- "test/pleroma/search/**"
- "mix.exs"
- "mix.lock"
depends_on:
- lint
labels:
platform: linux/amd64
variables:
script_file_entrypoint: &script_file_entrypoint
- /bin/sh
- -c
- 'printf "%s" "$CI_SCRIPT" | base64 -d > /tmp/ci-script.sh && /bin/sh -xe /tmp/ci-script.sh'
steps:
paradedb-integration:
image: docker.io/elixir:1.19-otp-28-alpine
entrypoint: *script_file_entrypoint
environment:
MIX_ENV: test
VIX_COMPILATION_MODE: PLATFORM_PROVIDED_LIBVIPS
DB_HOST: postgres
DB_PORT: 5432
PARADEDB_TEST_URL: postgres://postgres:postgres@paradedb:5432/paradedb
commands:
- apk add --no-cache build-base cmake exiftool ffmpeg vips-dev file-dev git openssl postgresql-client
- mix local.hex --force
- mix local.rebar --force
- mix deps.get
- timeout 60 sh -c 'until pg_isready -h postgres -U postgres -d pleroma_test; do sleep 1; done' || { echo "PostgreSQL did not become ready" >&2; exit 1; }
- timeout 60 sh -c 'until pg_isready -h paradedb -U postgres -d paradedb; do sleep 1; done' || { echo "ParadeDB did not become ready" >&2; exit 1; }
- mix ecto.create
- mix ecto.migrate
- mix test --include integration test/pleroma/search/parade_db_integration_test.exs
services:
postgres:
image: docker.io/postgres:13-alpine
environment:
POSTGRES_DB: pleroma_test
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
paradedb:
image: docker.io/paradedb/paradedb:0.24.3-pg15@sha256:997d62f6c2605becb7c45878af3f8c8c698f355006b675b3ce212d4eb2d14f2e
environment:
POSTGRES_DB: paradedb
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres

View file

@ -1,4 +1,4 @@
<img src="https://git.pleroma.social/attachments/06a95f5a-7cac-42ad-8b1d-1483f1739f38" width="300px" />
<img src="https://git.pleroma.social/pleroma/pleroma/raw/branch/develop/logo-with-name.png" width="300px" />
## About

View file

@ -0,0 +1 @@
Reject unsupported or ambiguous inbox signatures instead of queueing a doomed failed-signature retry, allowing clients to fall back to legacy HTTP Signatures.

View file

@ -0,0 +1 @@
Allow creating accounts without a password in the AdminAPI

View file

@ -0,0 +1 @@
Docs: Fixed OTP install links in documentation, broke up wall of install commands into smaller chunks, fixed sudo commands execution

View file

@ -0,0 +1 @@
Accept more real-world ActivityPub object shapes collected by fedidev.fun

View file

View file

@ -0,0 +1 @@
Update Debian and Ubuntu installation requirements and OTP dependencies

View file

@ -0,0 +1 @@
Implement basics of Mastodon admin API

View file

@ -0,0 +1 @@
Abort incomplete media proxy responses instead of caching partial bodies.

View file

@ -1 +0,0 @@
Detect image content types for extensionless MediaProxy responses.

View file

@ -0,0 +1 @@
Oban: Enabled the Reindexer plugin to reduce oban_jobs index DB bloat

View file

@ -0,0 +1 @@
- OpenRC: Automatically run migrations before starting pleroma

View file

@ -0,0 +1 @@
- Optimize orphaned OAuth apps cleanup by deleting records in batches instead of one by one.

View file

@ -0,0 +1 @@
Add ParadeDB (pg_search) as an optional external search backend, indexing status content, content warnings, and attachment descriptions.

View file

@ -0,0 +1 @@
Status: Fill all MastoAPI required values in preview cards

View file

@ -0,0 +1 @@
Add optional migrations for rollback from Rebased

View file

@ -0,0 +1 @@
Fixed account status and media views exposing activities restricted from unauthenticated users.

View file

View file

@ -0,0 +1 @@
Fix intermittent failure to compile syslog package

1
changelog.d/typo.skip Normal file
View file

@ -0,0 +1 @@
Fix typo in spec

1
changelog.d/webhooks.add Normal file
View file

@ -0,0 +1 @@
Add support for Mastodon-compatible webhooks

View file

@ -607,7 +607,11 @@ config :pleroma, Oban,
search_indexing: [limit: 10, paused: true],
slow: 5
],
plugins: [Oban.Plugins.Lazarus, {Oban.Plugins.Pruner, max_age: 900}],
plugins: [
Oban.Plugins.Lazarus,
{Oban.Plugins.Pruner, max_age: 900},
{Oban.Plugins.Reindexer, schedule: "@weekly"}
],
crontab: [
{"0 0 * * 0", Pleroma.Workers.Cron.DigestEmailsWorker},
{"0 0 * * *", Pleroma.Workers.Cron.NewUsersDigestWorker},
@ -932,7 +936,8 @@ config :pleroma, Pleroma.User.Backup,
timeout: :timer.minutes(30)
config :pleroma, ConcurrentLimiter, [
{Pleroma.Search, [max_running: 30, max_waiting: 50]}
{Pleroma.Search, [max_running: 30, max_waiting: 50]},
{Pleroma.Webhook.Notify, [max_running: 5, max_waiting: 200]}
]
config :pleroma, Pleroma.Web.WebFinger, domain: nil, update_nickname_on_user_fetch: true
@ -944,6 +949,15 @@ config :pleroma, Pleroma.Search.Meilisearch,
private_key: nil,
initial_indexing_chunk_size: 100_000
config :pleroma, Pleroma.Search.ParadeDB,
url: nil,
table: "pleroma_search_documents",
fuzzy_distance: 0
config :pleroma, Pleroma.Search.ParadeDB.Repo,
pool_size: 2,
prepare: :unnamed
config :pleroma, Pleroma.Application,
background_migrators: true,
internal_fetch: true,

View file

@ -3523,6 +3523,26 @@ config :pleroma, :config_description, [
suggestion: [5]
}
]
},
%{
key: Pleroma.Webhook.Notify,
type: :keyword,
description: "Concurrent limits configuration for webhooks.",
suggestions: [max_running: 5, max_waiting: 200],
children: [
%{
key: :max_running,
type: :integer,
description: "Max running concurrently jobs.",
suggestion: [5]
},
%{
key: :max_waiting,
type: :integer,
description: "Max waiting jobs.",
suggestion: [200]
}
]
}
]
},
@ -3536,7 +3556,12 @@ config :pleroma, :config_description, [
key: :module,
type: :keyword,
description: "Selected search module.",
suggestion: [Pleroma.Search.DatabaseSearch, Pleroma.Search.Meilisearch]
suggestion: [
Pleroma.Search.DatabaseSearch,
Pleroma.Search.Meilisearch,
Pleroma.Search.QdrantSearch,
Pleroma.Search.ParadeDB
]
}
]
},
@ -3569,6 +3594,34 @@ config :pleroma, :config_description, [
}
]
},
%{
group: :pleroma,
key: Pleroma.Search.ParadeDB,
type: :group,
description: "ParadeDB settings.",
children: [
%{
key: :url,
type: :string,
description:
"ParadeDB database URL (separate Postgres instance). " <>
"Can also be set via `PARADEDB_DATABASE_URL`.",
suggestion: ["postgres://postgres:postgres@127.0.0.1:5432/paradedb"]
},
%{
key: :table,
type: :string,
description: "Table name used for search documents.",
suggestion: ["pleroma_search_documents"]
},
%{
key: :fuzzy_distance,
type: :integer,
description: "Edit-distance used for fuzzy token matching (0 disables fuzziness, max 2).",
suggestion: [0, 1, 2]
}
]
},
%{
group: :pleroma,
key: Pleroma.Language.LanguageDetector,

View file

@ -85,7 +85,7 @@ You will see a "Default Admin API Key", this is the key you actually put into yo
### Initial indexing
After setting up the configuration, you'll want to index all of your already existing posts. Only public posts are indexed. You'll only
After setting up the configuration, you'll want to index all of your already existing posts. Only public/unlisted posts are indexed. You'll only
have to do it one time, but it might take a while, depending on the amount of posts your instance has seen. This is also a fairly RAM
consuming process for `meilisearch`, and it will take a lot of RAM when running if you have a lot of posts (seems to be around 5G for ~1.2
million posts while idle and up to 7G while indexing initially, but your experience may be different).
@ -145,3 +145,155 @@ This will clear **all** the posts from the search index. Note, that deleted post
there is no need to actually clear the whole index, unless you want **all** of it gone. That said, the index does not hold any information
that cannot be re-created from the database, it should also generally be a lot smaller than the size of your database. Still, the size
depends on the amount of text in posts.
## ParadeDB
[ParadeDB](https://www.paradedb.com/) is a Postgres extension that provides BM25 full-text search. In Pleroma, it can be used as an
external search backend by pointing Pleroma at a separate Postgres instance with the `pg_search` extension installed.
Pleroma's integration suite tests `pg_search` 0.24.3 on PostgreSQL 15.
Pleroma will maintain a small `pleroma_search_documents` table in that database (via the existing search indexing queue) and run search
queries against it.
The indexed text includes status content, content warnings, and attachment descriptions.
### Configuration
Set the search module to `Pleroma.Search.ParadeDB` and configure the ParadeDB database URL. These settings must be in static/runtime config
(or `PARADEDB_DATABASE_URL`) so the dedicated ParadeDB Repo is started with the application. They cannot be migrated to or changed through
ConfigDB.
> config :pleroma, Pleroma.Search, module: Pleroma.Search.ParadeDB
>
> config :pleroma, Pleroma.Search.ParadeDB,
> url: System.get_env("PARADEDB_DATABASE_URL"),
> table: "pleroma_search_documents",
> fuzzy_distance: 0
>
> config :pleroma, Pleroma.Search.ParadeDB.Repo,
> pool_size: 2,
> prepare: :unnamed
When connecting over an untrusted network, enable certificate verification in the Repo configuration. For example:
> config :pleroma, Pleroma.Search.ParadeDB.Repo,
> ssl: [
> verify: :verify_peer,
> cacertfile: "/etc/pleroma/paradedb-ca.pem",
> server_name_indication: ~c"paradedb.example.com"
> ]
### Initial rollout
The `pg_search` extension must already be available in the database. Creating the extension normally requires an administrative database
role. The Pleroma runtime role requires `CONNECT` on the database, `USAGE` on the table's schema and the `pdb` schema, and `SELECT`, `INSERT`, `UPDATE`,
and `DELETE` on the generated table. For example, after creating the table as its owner:
```sql
GRANT CONNECT ON DATABASE paradedb TO pleroma_search;
GRANT USAGE ON SCHEMA public, pdb TO pleroma_search;
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE pleroma_search_documents TO pleroma_search;
```
Use this rollout order to prevent jobs from being handled by the previous backend:
1. Install `pg_search` 0.24.3 on PostgreSQL 15 or another deliberately tested combination.
2. Stop Pleroma so no search indexing jobs are running.
3. Put the ParadeDB backend in static configuration.
4. Create the table and BM25 index in a one-shot offline process using `PARADEDB_DATABASE_URL` with the table owner/setup credential.
5. Apply the runtime grants shown above, set the service's `PARADEDB_DATABASE_URL` to the restricted runtime credential, and start Pleroma.
6. Start the initial backfill and monitor the `search_indexing` queue.
Create the table and BM25 index once with:
=== "OTP"
```sh
PARADEDB_DATABASE_URL='OWNER_URL' PLEROMA_CTL_RPC_DISABLED=true \
./bin/pleroma_ctl search.indexer create_index
```
=== "From Source"
```sh
PARADEDB_DATABASE_URL='OWNER_URL' mix pleroma.search.indexer create_index
```
The task currently prints an error without returning a failing process status. Verify that it prints `Index created` before continuing.
### Backfill
Run an initial indexing pass to enqueue existing posts:
=== "OTP"
```sh
./bin/pleroma_ctl search.indexer index
```
=== "From Source"
```sh
PARADEDB_DATABASE_URL='RUNTIME_URL' mix pleroma.search.indexer index
```
The command enqueues up to 100,000 Create activities by default. It uses keyset pagination, so new activities arriving during the backfill
do not shift the remaining pages. The defaults and accepted options are:
* `--limit`: maximum activities to enqueue in this run; defaults to `100000` and must be positive
* `--step`: activities fetched from Pleroma's database per page; defaults to `1000` and must be positive
* `--chunk`: jobs inserted into Oban per batch; defaults to `100` and must be positive
* `--before`: resume before the checkpoint activity ID printed by an earlier run; the cursor is exclusive
The checkpoint is the last activity enqueued, not confirmation that its job succeeded. Repeat the command with its last `--before`
checkpoint only when it reports that more activities remain. Inspect failed or discarded Oban jobs as well as waiting jobs before declaring
the backfill complete.
The same operation can be run from an attached console while Pleroma is online:
```elixir
Pleroma.Search.Backfill.run(limit: 10_000)
```
The returned `next_cursor` can be supplied as `before` when `exhausted` is `false`. The attached API also accepts `limit: :infinity`, but this
can create an unbounded number of Oban rows; bounded runs are safer on large instances. An `on_page` callback may be supplied for progress
reporting.
Note: Like the other external search backends, only public/unlisted Notes are indexed.
Set `fuzzy_distance` to `1` or `2` to allow typo-tolerant matching (`2` is the maximum ParadeDB supports).
Indexing runs via the Oban `search_indexing` queue. Its stock configuration starts paused so the search health monitor can control it; verify
that the queue is running before backfilling. A manually managed queue can be enabled with `search_indexing: 10` in the Oban `queues`
configuration instead of a `paused: true` entry.
Monitor that queue until the backfill jobs have drained before evaluating search completeness.
### Rebuilding
Stop Pleroma, or otherwise ensure no search indexing jobs are running, before rebuilding. While ParadeDB is still the configured backend,
run `drop_index`, then `create_index` with the table owner/setup credential. Reapply runtime grants after recreating the table, restart
Pleroma, and begin a new backfill. The DDL tasks print failures without returning a failing process status, so verify the exact `Index
dropped` and `Index created` success messages. Dropping the table removes only derived search data; Pleroma activities remain in the primary
database.
### Rolling back
To retain the ParadeDB table for a later retry, configure `Pleroma.Search.DatabaseSearch` (or the previous external backend) in static config
and restart Pleroma.
Before switching backends, inspect the `search_indexing` queue. Drain or cancel pending backfill jobs, or explicitly accept that they will run
against the newly configured backend after restart.
To remove the table during rollback, first stop Pleroma and run `drop_index` with the table owner/setup credential while ParadeDB is still
configured:
=== "OTP"
```sh
PARADEDB_DATABASE_URL='OWNER_URL' PLEROMA_CTL_RPC_DISABLED=true \
./bin/pleroma_ctl search.indexer drop_index
```
=== "From Source"
```sh
PARADEDB_DATABASE_URL='OWNER_URL' mix pleroma.search.indexer drop_index
```
Verify that the task prints `Index dropped`. Then change the backend in static config and restart. The ParadeDB environment variable may be
removed after removing the ParadeDB stanza or switching to a backend that does not start its Repo. After switching away from ParadeDB, the
generic `drop_index` task targets the newly configured backend and will not remove the ParadeDB table; remove it directly with the database
owner instead.

View file

@ -80,10 +80,24 @@ The `/api/v1/pleroma/admin/*` path is backwards compatible with `/api/pleroma/ad
{
`nickname`,
`email`,
`password`
`password` (optional; when provided, must not be empty)
}
]
- Response: Array of user objects
- Response: Array of user objects. When a user is created without a `password`, `data`
object contains a `password_reset_link` the user can follow to set their own password.
```json
[
{
"data": {
"nickname": string,
"email": string,
"password_reset_link": string (optional)
},
"type": "success"
}
]
```
## `POST /api/v1/pleroma/admin/users/follow`
@ -1839,3 +1853,92 @@ Note that this differs from the Mastodon API variant: Mastodon API only returns
```json
{}
```
## `GET /api/v1/pleroma/admin/webhooks`
### List webhooks
- Method: `GET`
- Response:
```json
[
{
"enabled": true,
"id": "2",
"events": ["account.created"],
"url": "https://webhook.example/",
"secret": "eb85d4ccd8510e78f912743949dc354e8146987d",
"updated_at": "2022-10-29T17:44:16.000Z",
"created_at": "2022-10-29T17:44:13.000Z"
}
]
```
## `GET /api/v1/pleroma/admin/webhooks/:id`
### Get an individual webhook
- Method: `GET`
- Params:
- `id`: **string** Webhook ID
- Response: A webhook
## `POST /api/v1/pleroma/admin/webhooks`
### Create a webhook
- Method: `POST`
- Params:
- `url`: **string** Webhook URL
- `events`: **[string]** Types of events to trigger on (`account.created`, `report.created`)
- *optional* `enabled`: **boolean** Whether webhook is enabled
- Response: A webhook
## `PATCH /api/v1/pleroma/admin/webhooks/:id`
### Update a webhook
- Method: `PATCH`
- Params:
- `id`: **string** Webhook ID
- *optional* `url`: **string** Webhook URL
- *optional* `events`: **[string]** Types of events to trigger on (`account.created`, `report.created`)
- *optional* `enabled`: **boolean** Whether webhook is enabled
- Response: A webhook
## `DELETE /api/v1/pleroma/admin/webhooks/:id`
### Delete a webhook
- Method: `DELETE`
- Params:
- `id`: **string** Webhook ID
- Response: A webhook
## `POST /api/v1/pleroma/admin/webhooks/:id/enable`
### Activate a webhook
- Method: `POST`
- Params:
- `id`: **string** Webhook ID
- Response: A webhook
## `POST /api/v1/pleroma/admin/webhooks/:id/disable`
### Deactivate a webhook
- Method: `POST`
- Params:
- `id`: **string** Webhook ID
- Response: A webhook
## `POST /api/v1/pleroma/admin/webhooks/:id/rotate_secret`
### Rotate webhook signing secret
- Method: `POST`
- Params:
- `id`: **string** Webhook ID
- Response: A webhook

View file

@ -4,7 +4,7 @@
## Installation
This guide will assume you are on Debian 12 (“bookworm”) or later. This guide should also work with Ubuntu 22.04 (“jammy”) and later. It also assumes that you have administrative rights, either as root or a user with [sudo permissions](https://www.digitalocean.com/community/tutorials/how-to-add-delete-and-grant-sudo-privileges-to-users-on-a-debian-vps). If you want to run this guide with root, ignore the `sudo` at the beginning of the lines, unless it calls a user like `sudo -Hu pleroma`; in this case, use `su <username> -s $SHELL -c 'command'` instead.
This guide will assume you are on Debian 13 (“Trixie”) or later. This guide should also work with Ubuntu 26.04 (“resolute”) and later. It also assumes that you have administrative rights, either as root or a user with [sudo permissions](https://www.digitalocean.com/community/tutorials/how-to-add-delete-and-grant-sudo-privileges-to-users-on-a-debian-vps). If you want to run this guide with root, ignore the `sudo` at the beginning of the lines, unless it calls a user like `sudo -Hu pleroma`; in this case, use `su <username> -s $SHELL -c 'command'` instead.
{! backend/installation/generic_dependencies.include !}

View file

@ -0,0 +1,130 @@
# Migrating from Rebased
Rebased and Pleroma currently have compatible database schemas, so removing
Rebased-specific database objects is optional. The cleanup described below is
destructive and can take a long time on large databases.
This guide requires a Pleroma release that contains this document and native
webhook support. If you are reading the development version of this guide
before those changes reach `stable`, wait for the corresponding stable release.
## Prepare and back up
Plan a maintenance window and read the [backup guide](../administration/backup.md)
before starting.
Stop Rebased and confirm it is stopped before making the backup. Omit the
backup guide's final restart step and leave Rebased stopped:
```bash
sudo systemctl stop pleroma
sudo systemctl is-active pleroma
```
Make a verified database backup and a copy of the Rebased installation,
including its configuration, uploads, and `instance` directory. Also save the
current systemd unit if you plan to replace it.
## Install Pleroma
Do not use `git pull --rebase` to change an existing Rebased checkout into a
Pleroma checkout. The forks use different branches, and rebasing can replay
Rebased-specific commits over Pleroma. Move the old checkout aside and clone a
clean copy of Pleroma instead. The paths below assume the default source
installation in `/opt/pleroma`:
```bash
sudo mv /opt/pleroma /opt/pleroma.rebased
sudo mkdir /opt/pleroma
sudo chown pleroma:pleroma /opt/pleroma
sudo -Hu pleroma git clone -b stable https://git.pleroma.social/pleroma/pleroma /opt/pleroma
```
Restore the instance-specific files from the old checkout. This usually
includes `config/prod.secret.exs`, `config/prod.exported_from_db.secret.exs` if
present, `uploads`, and `instance`. Diff and restore any other non-stock
configuration files, preserve their ownership, and review them before starting
Pleroma.
Pleroma requires Elixir 1.15 or newer. Install supported Erlang and Elixir
versions as described in the installation guide for your distribution before
running any Mix command. Do not reuse Rebased's `.tool-versions`. If you keep
using [`asdf`](https://asdf-vm.com/), select supported versions in the
`pleroma` user's home and use the asdf Mix shim for every command below.
Set `MIX` to the appropriate binary, verify the selected runtime, then install
the dependencies and compile Pleroma:
```bash
cd /opt/pleroma
MIX=/usr/bin/mix
# If using asdf instead:
# MIX=/var/lib/pleroma/.asdf/shims/mix
sudo -Hu pleroma "$MIX" --version
sudo -Hu pleroma MIX_ENV=prod "$MIX" deps.get
sudo -Hu pleroma MIX_ENV=prod "$MIX" compile
```
Compare your existing systemd unit with `installation/pleroma.service`. If you
replace it, review its user, paths, and environment first. Reload systemd after
making changes:
```bash
sudo systemctl daemon-reload
```
Rebased does not bundle a frontend, so `instance/static` may contain an
installed frontend. Pleroma includes a bundled frontend. You can remove the
separately installed frontend after the migration, but do not remove other
instance files such as custom emojis.
## Database migration
Rebased's extra columns, index, and enum labels do not prevent Pleroma from
starting. Existing notifications with Rebased-only event types are not
supported by Pleroma, however, and can cause notification API errors. Run the
cleanup below, or archive and remove those notification rows separately before
starting Pleroma. Skipping the cleanup preserves them if you need to restore
Rebased.
Pleroma supports Rebased's webhook schema. The cleanup does not roll back the
shared webhook migrations, so webhook configuration is preserved.
To perform the optional cleanup, run the rollback while the service is stopped:
```bash
MIX=/usr/bin/mix
# If using asdf instead:
# MIX=/var/lib/pleroma/.asdf/shims/mix
sudo -Hu pleroma MIX_ENV=prod "$MIX" ecto.rollback --migrations-path priv/repo/optional_migrations/rebased_rollbacks --all
```
This deletes the Rebased-only `accepts_email_list`, `location`, and
`last_move_at` user fields and the `pleroma:participation_accepted`,
`pleroma:participation_request`, `pleroma:event_reminder`, and
`pleroma:event_update` notifications. It also rewrites the notifications table
and can require substantial time, free disk space, and write-ahead log capacity
on a large instance.
Apply all pending Pleroma migrations:
```bash
sudo -Hu pleroma MIX_ENV=prod "$MIX" ecto.migrate
```
## Start and verify Pleroma
Start Pleroma and inspect its status and logs:
```bash
sudo systemctl start pleroma
sudo systemctl status pleroma
sudo journalctl -u pleroma -n 100 --no-pager
```
Verify that the instance, uploads, frontend, and webhook configuration work as
expected before deleting `/opt/pleroma.rebased` or the database backup. If the
migration fails, keep the service stopped and restore both the database backup
and the previous checkout before restarting Rebased. If you changed the
systemd unit or runtime setup, restore those too and run
`systemctl daemon-reload` before restarting the service.

View file

@ -76,35 +76,51 @@ $EDITOR /etc/pleroma/config.exs
```
## Installing the release
Before proceeding, get the flavour from [Detecting flavour](otp_en.md#detecting-flavour) section in OTP installation guide.
Delete all files in pleroma user's directory:
```sh
# Delete all files in pleroma user's directory
rm -r ~pleroma/*
```
# Set the flavour environment variable to the string you got in Detecting flavour section.
# For example if the flavour is `amd64-musl` the command will be
Set the flavour environment variable to the string you got in Detecting flavour section.
For example if the flavour is `amd64-musl` the command will be:
```sh
export FLAVOUR="amd64-musl"
```
# Clone the release build into a temporary directory and unpack it
# Replace `stable` with `unstable` if you want to run the unstable branch
sudo -Hu pleroma "
curl 'https://git.pleroma.social/api/v4/projects/2/jobs/artifacts/stable/download?job=$FLAVOUR' -o /tmp/pleroma.zip
unzip /tmp/pleroma.zip -d /tmp/
"
Clone the release build into a temporary directory and unpack it.
Replace `stable` with `develop`, if you want to run the develop branch.
# Move the release to the home directory and delete temporary files
sudo -Hu pleroma "
mv /tmp/release/* ~pleroma/
rmdir /tmp/release
rm /tmp/pleroma.zip
"
```sh
sudo -Hu pleroma curl "https://git.pleroma.social/api/packages/pleroma/generic/pleroma-otp-stable-$FLAVOUR/latest/pleroma.zip" -o /tmp/pleroma.zip
sudo -Hu pleroma unzip /tmp/pleroma.zip -d /tmp/
```
# Start the instance to verify that everything is working as expected
Move the release to the home directory and delete temporary files:
```sh
sudo -Hu pleroma mv /tmp/release/* ~pleroma/
sudo -Hu pleroma rmdir /tmp/release
sudo -Hu pleroma rm /tmp/pleroma.zip
```
Start the instance to verify that everything is working as expected:
```sh
sudo -Hu pleroma "./bin/pleroma daemon"
```
# Wait for about 20 seconds and query the instance endpoint, if it shows your uri, name and email correctly, you are configured correctly
Wait for about 20 seconds and query the instance endpoint, if it shows your uri, name and email correctly, you are configured correctly:
```sh
sleep 20 && curl http://localhost:4000/api/v1/instance
```
# Stop the instance
Stop the instance:
```sh
sudo -Hu pleroma "./bin/pleroma stop"
```

View file

@ -32,6 +32,7 @@ Other than things bundled in the OTP release Pleroma depends on:
* curl (to download the release build)
* unzip (needed to unpack release builds)
* ncurses (ERTS won't run without it)
* libvips (for image processing)
* PostgreSQL (also utilizes extensions in postgresql-contrib)
* nginx (could be swapped with another reverse proxy but this guide covers only it)
* certbot (for Let's Encrypt certificates, could be swapped with another ACME client, but this guide covers only it)
@ -46,7 +47,13 @@ Other than things bundled in the OTP release Pleroma depends on:
=== "Debian/Ubuntu"
```
apt install curl unzip libncurses5 postgresql postgresql-contrib nginx certbot libmagic-dev
apt install curl unzip libncurses6 libvips42t64 postgresql postgresql-contrib nginx certbot libmagic-dev
```
On Debian 12 and Ubuntu 22.04, replace `libvips42t64` with `libvips42`:
```
apt install curl unzip libncurses6 libvips42 postgresql postgresql-contrib nginx certbot libmagic-dev
```
### Installing optional packages
@ -88,9 +95,23 @@ RUM indexes are an alternative indexing scheme that is not included in PostgreSQ
```
=== "Debian/Ubuntu"
Install the package matching your distribution's PostgreSQL version:
```
# Available only on Buster/19.04
apt install postgresql-11-rum
# Debian 13
apt install postgresql-17-rum
# Debian 12
apt install postgresql-15-rum
# Ubuntu 26.04
apt install postgresql-18-rum
# Ubuntu 24.04
apt install postgresql-16-rum
# Ubuntu 22.04
apt install postgresql-14-rum
```
#### (Optional) Performance configuration
@ -109,60 +130,90 @@ Restart PostgreSQL to apply configuration changes:
```
### Installing Pleroma
Create a Pleroma user:
```sh
# Create a Pleroma user
adduser --system --shell /bin/false --home /opt/pleroma pleroma
```
# Set the flavour environment variable to the string you got in Detecting flavour section.
# For example if the flavour is `amd64-musl` the command will be
Set the flavour environment variable to the string you got in Detecting flavour section. For example if the flavour is `amd64-musl` the command will be:
```sh
export FLAVOUR="amd64-musl"
```
# Clone the release build into a temporary directory and unpack it
sudo -Hu pleroma "
curl 'https://git.pleroma.social/api/v4/projects/2/jobs/artifacts/stable/download?job=$FLAVOUR' -o /tmp/pleroma.zip
unzip /tmp/pleroma.zip -d /tmp/
"
Clone the release build into a temporary directory and unpack it:
# Move the release to the home directory and delete temporary files
sudo -Hu pleroma "
mv /tmp/release/* /opt/pleroma
rmdir /tmp/release
rm /tmp/pleroma.zip
"
# Create uploads directory and set proper permissions (skip if planning to use a remote uploader)
# Note: It does not have to be `/var/lib/pleroma/uploads`, the config generator will ask about the upload directory later
```sh
sudo -Hu pleroma curl "https://git.pleroma.social/api/packages/pleroma/generic/pleroma-otp-stable-$FLAVOUR/latest/pleroma.zip" -o /tmp/pleroma.zip
sudo -Hu pleroma unzip /tmp/pleroma.zip -d /tmp/
```
Move the release to the home directory and delete temporary files:
```sh
sudo -Hu pleroma mv /tmp/release/* /opt/pleroma
sudo -Hu pleroma rmdir /tmp/release
sudo -Hu pleroma rm /tmp/pleroma.zip
```
Create uploads directory and set proper permissions (skip if planning to use a remote uploader).
Note: It does not have to be `/var/lib/pleroma/uploads`, the config generator will ask about the upload directory later.
```sh
mkdir -p /var/lib/pleroma/uploads
chown -R pleroma /var/lib/pleroma
```
# Create custom public files directory (custom emojis, frontend bundle overrides, robots.txt, etc.)
# Note: It does not have to be `/var/lib/pleroma/static`, the config generator will ask about the custom public files directory later
Create custom public files directory (custom emojis, frontend bundle overrides, robots.txt, etc.)
Note: It does not have to be `/var/lib/pleroma/static`, the config generator will ask about the custom public files directory later.
```sh
mkdir -p /var/lib/pleroma/static
chown -R pleroma /var/lib/pleroma
```
# Create a config directory
Create a config directory:
```sh
mkdir -p /etc/pleroma
chown -R pleroma /etc/pleroma
```
# Run the config generator
Run the config generator:
```sh
sudo -Hu pleroma "./bin/pleroma_ctl instance gen --output /etc/pleroma/config.exs --output-psql /tmp/setup_db.psql"
```
# Create the postgres database
Create the postgres database and schema:
```sh
sudo -u postgres -s $SHELL -lc "psql -f /tmp/setup_db.psql"
# Create the database schema
sudo -Hu pleroma "./bin/pleroma_ctl migrate"
```
# If you have installed RUM indexes uncommend and run
# sudo -Hu pleroma "./bin/pleroma_ctl migrate --migrations-path priv/repo/optional_migrations/rum_indexing/"
(Optional) If you have installed RUM indexes:
# Start the instance to verify that everything is working as expected
```sh
sudo -Hu pleroma "./bin/pleroma_ctl migrate --migrations-path priv/repo/optional_migrations/rum_indexing/"
```
Start the instance to verify that everything is working as expected:
```sh
sudo -Hu pleroma "./bin/pleroma daemon"
```
# Wait for about 20 seconds and query the instance endpoint, if it shows your uri, name and email correctly, you are configured correctly
Wait for about 20 seconds and query the instance endpoint, if it shows your uri, name and email correctly, you are configured correctly:
```sh
sleep 20 && curl http://localhost:4000/api/v1/instance
```
# Stop the instance
Stop the instance:
```sh
sudo -Hu pleroma "./bin/pleroma stop"
```

View file

@ -37,6 +37,13 @@ depend() {
need nginx postgresql
}
start_pre() {
ebegin "running migrations if any"
su -s /bin/sh -c "cd \"${directory}\" && exec /usr/bin/mix ecto.migrate" \
"${command_user%%:*}"
eend "$?"
}
healthcheck() {
# put pleroma_health=YES in /etc/conf.d/pleroma if you want healthchecking
# and make sure you have curl installed

View file

@ -62,7 +62,8 @@ defmodule Mix.Pleroma do
{Majic.Pool,
[name: Pleroma.MajicPool, pool_size: Pleroma.Config.get([:majic_pool, :size], 2)]}
] ++
http_children(adapter)
http_children(adapter) ++
search_children()
cachex_children = Enum.map(@cachex_children, &Pleroma.Application.build_cachex(&1, []))
@ -141,4 +142,14 @@ defmodule Mix.Pleroma do
end
defp http_children(_), do: []
defp search_children do
case Pleroma.Config.get([Pleroma.Search, :module]) do
Pleroma.Search.ParadeDB ->
[Pleroma.Search.ParadeDB.Repo]
_ ->
[]
end
end
end

View file

@ -335,9 +335,12 @@ defmodule Mix.Tasks.Pleroma.Config do
group
|> Pleroma.Config.Loader.filter_group(settings)
|> Enum.each(fn {key, value} ->
{:ok, _} = ConfigDB.update_or_create(%{group: group, key: key, value: value})
shell_info("Settings for key #{key} migrated.")
if ConfigDB.static?(group, key) do
shell_info("Settings for key #{key} must remain in static configuration; skipping.")
else
{:ok, _} = ConfigDB.update_or_create(%{group: group, key: key, value: value})
shell_info("Settings for key #{key} migrated.")
end
end)
shell_info("Settings for group #{inspect(group)} migrated.")

View file

@ -4,9 +4,6 @@
defmodule Mix.Tasks.Pleroma.Search.Indexer do
import Mix.Pleroma
import Ecto.Query
alias Pleroma.Workers.SearchIndexingWorker
def run(["create_index"]) do
start_pleroma()
@ -35,49 +32,30 @@ defmodule Mix.Tasks.Pleroma.Search.Indexer do
strict: [
chunk: :integer,
limit: :integer,
step: :integer
step: :integer,
before: :string
]
)
start_pleroma()
chunk_size = Keyword.get(options, :chunk, 100)
limit = Keyword.get(options, :limit, 100_000)
per_step = Keyword.get(options, :step, 1000)
result =
Pleroma.Search.Backfill.run(
options ++
[
on_page: fn page ->
IO.puts(
"Queued #{page.count} activities (#{page.enqueued} total), " <>
"checkpoint: #{page.next_cursor}"
)
end
]
)
chunks = max(div(limit, per_step), 1)
IO.puts("Queued #{result.enqueued} activities")
1..chunks
|> Enum.each(fn step ->
q =
from(a in Pleroma.Activity,
limit: ^per_step,
offset: ^per_step * (^step - 1),
select: [:id],
order_by: [desc: :id]
)
{:ok, ids} =
Pleroma.Repo.transaction(fn ->
Pleroma.Repo.stream(q, timeout: :infinity)
|> Enum.map(fn a ->
a.id
end)
end)
IO.puts("Got #{length(ids)} activities, adding to indexer")
ids
|> Enum.chunk_every(chunk_size)
|> Enum.each(fn chunk ->
IO.puts("Adding #{length(chunk)} activities to indexing queue")
chunk
|> Enum.map(fn id ->
SearchIndexingWorker.new(%{"op" => "add_to_index", "activity" => id})
end)
|> Oban.insert_all()
end)
end)
unless result.exhausted do
IO.puts("Resume with --before #{result.next_cursor}")
end
end
end

View file

@ -81,12 +81,15 @@ defmodule Pleroma.Application do
[
Pleroma.PromEx,
Pleroma.LDAP,
Pleroma.Repo,
Config.TransferTask,
Pleroma.Emoji,
Pleroma.Web.Plugs.RateLimiter.Supervisor,
{Task.Supervisor, name: Pleroma.TaskSupervisor}
Pleroma.Repo
] ++
search_children() ++
[
Config.TransferTask,
Pleroma.Emoji,
Pleroma.Web.Plugs.RateLimiter.Supervisor,
{Task.Supervisor, name: Pleroma.TaskSupervisor}
] ++
cachex_children() ++
http_children(adapter) ++
[
@ -198,6 +201,16 @@ defmodule Pleroma.Application do
]
end
defp search_children do
case Config.get([Pleroma.Search, :module]) do
Pleroma.Search.ParadeDB ->
[Pleroma.Search.ParadeDB.Repo]
_ ->
[]
end
end
defp emoji_packs_expiration,
do: expiration(default: :timer.seconds(5 * 60), interval: :timer.seconds(60))
@ -317,7 +330,8 @@ defmodule Pleroma.Application do
config = Config.get(ConcurrentLimiter, [])
[
Pleroma.Search
Pleroma.Search,
Pleroma.Webhook.Notify
]
|> Enum.each(fn module ->
mod_config = Keyword.get(config, module, [])

View file

@ -47,6 +47,7 @@ defmodule Pleroma.Config.TransferTask do
settings =
(Repo.all(ConfigDB) ++ deleted_settings)
|> Enum.reject(&ConfigDB.static?/1)
|> Enum.map(&merge_with_default/1)
started_applications = Application.started_applications()

View file

@ -23,6 +23,12 @@ defmodule Pleroma.ConfigDB do
{:pleroma, :mrf_keyword, :replace}
]
@static_config [
{:pleroma, Pleroma.Search},
{:pleroma, Pleroma.Search.ParadeDB},
{:pleroma, Pleroma.Search.ParadeDB.Repo}
]
schema "config" do
field(:key, Pleroma.EctoType.Config.Atom)
field(:group, Pleroma.EctoType.Config.Atom)
@ -32,16 +38,32 @@ defmodule Pleroma.ConfigDB do
timestamps()
end
@spec get_all_as_keyword() :: keyword()
def get_all_as_keyword do
@spec get_all_as_keyword(keyword()) :: keyword()
def get_all_as_keyword(options \\ []) do
ConfigDB
|> select([c], {c.group, c.key, c.value})
|> Repo.all()
|> maybe_reject_static(options)
|> Enum.reduce([], fn {group, key, value}, acc ->
Keyword.update(acc, group, [{key, value}], &Keyword.merge(&1, [{key, value}]))
end)
end
def static?(%{group: group, key: key}), do: static?(group, key)
def static?(_config), do: false
def static?(group, key) do
Enum.any?(@static_config, fn {static_group, static_key} ->
group in [static_group, inspect(static_group)] and key in [static_key, inspect(static_key)]
end)
end
defp maybe_reject_static(configs, exclude_static: true) do
Enum.reject(configs, fn {group, key, _value} -> static?(group, key) end)
end
defp maybe_reject_static(configs, _options), do: configs
@spec get_all_by_group(atom() | String.t()) :: [t()]
def get_all_by_group(group) do
from(c in ConfigDB, where: c.group == ^group) |> Repo.all()

View file

@ -346,6 +346,16 @@ defmodule Pleroma.ModerationLog do
"@#{actor_nickname} approved users: #{users_to_nicknames_string(users)}"
end
def get_log_entry_message(%ModerationLog{
data: %{
"actor" => %{"nickname" => actor_nickname},
"action" => "reject",
"subject" => users
}
}) do
"@#{actor_nickname} rejected users: #{users_to_nicknames_string(users)}"
end
def get_log_entry_message(%ModerationLog{
data: %{
"actor" => %{"nickname" => actor_nickname},

View file

@ -15,12 +15,28 @@ defmodule Pleroma.Object.Containment do
actor
end
def get_actor(%{"actor" => nil, "attributedTo" => actor}) when not is_nil(actor) do
get_actor(%{"actor" => actor})
end
def get_actor(%{"actor" => nil}), do: nil
def get_actor(%{"actor" => []}), do: nil
def get_actor(%{"actor" => actor}) when is_list(actor) do
if is_binary(Enum.at(actor, 0)) do
Enum.at(actor, 0)
else
Enum.find(actor, fn %{"type" => type} -> type in ["Person", "Service", "Application"] end)
|> Map.get("id")
case actor do
[first | _] when is_binary(first) ->
first
_ ->
Enum.find_value(actor, fn
%{"type" => type, "id" => id}
when type in ["Person", "Service", "Application"] and is_binary(id) ->
id
_ ->
nil
end)
end
end
@ -28,10 +44,6 @@ defmodule Pleroma.Object.Containment do
id
end
def get_actor(%{"actor" => nil, "attributedTo" => actor}) when not is_nil(actor) do
get_actor(%{"actor" => actor})
end
def get_object(%{"object" => id}) when is_binary(id) do
id
end
@ -67,9 +79,11 @@ defmodule Pleroma.Object.Containment do
def contain_origin(id, %{"actor" => _actor} = params) do
id_uri = URI.parse(id)
actor_uri = URI.parse(get_actor(params))
compare_uris(actor_uri, id_uri)
case get_actor(params) do
actor when is_binary(actor) -> compare_uris(URI.parse(actor), id_uri)
_ -> :error
end
end
def contain_origin(id, %{"attributedTo" => actor} = params),

View file

@ -12,18 +12,27 @@ defmodule Pleroma.ReverseProxy do
@keep_resp_headers @resp_cache_headers ++
~w(content-length content-type content-disposition content-encoding) ++
~w(content-range accept-ranges vary)
@default_cache_control_header "public, max-age=1209600, immutable"
@default_cache_control_header "public, max-age=1209600, immutable, no-transform"
@valid_resp_codes [200, 206, 304]
@max_read_duration :timer.seconds(30)
@max_body_length :infinity
@failed_request_ttl :timer.seconds(60)
@sniff_bytes 8 * 1024
@methods ~w(GET HEAD)
@allowed_mime_types Pleroma.Config.get([Pleroma.Upload, :allowed_mime_types], [])
@cachex Pleroma.Config.get([:cachex, :provider], Cachex)
defmodule StreamError do
@moduledoc false
defexception [:url, :reason]
@impl true
def message(%{url: url, reason: reason}) do
"reverse proxy stream from #{url} failed: #{inspect(reason)}"
end
end
def max_read_duration_default, do: @max_read_duration
def default_cache_control_header, do: @default_cache_control_header
@ -60,12 +69,7 @@ defmodule Pleroma.ReverseProxy do
* `inline_content_types`:
* `true` will not alter `content-disposition` (up to the upstream),
* `false` will add `content-disposition: attachment` to any request,
* a list of whitelisted content types for which `content-disposition: inline`
is always set (overriding any upstream header) so the media can be embedded
in pages; the filename is derived from the content type
* `sniff_content_type` (default `false`): detects an image MIME type from the first
response chunk when the upstream type is missing or `application/octet-stream`.
* a list of whitelisted content types
* `req_headers`, `resp_headers` additional headers.
@ -102,7 +106,6 @@ defmodule Pleroma.ReverseProxy do
| {:req_headers, [{String.t(), String.t()}]}
| {:resp_headers, [{String.t(), String.t()}]}
| {:inline_content_types, boolean() | list(String.t())}
| {:sniff_content_type, boolean()}
| {:redirect_on_failure, boolean()}
@spec call(Plug.Conn.t(), String.t(), list(option())) :: Plug.Conn.t()
@ -196,151 +199,93 @@ defmodule Pleroma.ReverseProxy do
end
defp request_with_constraints(method, url, headers, client_opts, opts) do
case request(method, url, headers, client_opts) do
{:ok, _code, headers, client} = response ->
case header_length_constraint(
headers,
Keyword.get(opts, :max_body_length, @max_body_length)
) do
:ok ->
response
method
|> request(url, headers, client_opts)
|> constrain_response(Keyword.get(opts, :max_body_length, @max_body_length))
end
error ->
client().close(client)
error
end
defp constrain_response({:ok, code, headers, client}, limit) do
case header_length_constraint(headers, limit) do
{:ok, headers} ->
{:ok, code, headers, client}
response ->
response
error ->
client().close(client)
error
end
end
defp constrain_response({:ok, code, headers}, limit) do
case header_length_constraint(headers, limit) do
{:ok, headers} -> {:ok, code, headers}
error -> error
end
end
defp constrain_response(response, _limit), do: response
defp response(conn, client, url, status, headers, opts) do
Logger.debug("#{__MODULE__} #{status} #{url} #{inspect(headers)}")
{headers, client, prefetched} = maybe_prefetch_for_content_type(headers, client, opts)
result =
conn
|> put_resp_headers(build_resp_headers(headers, opts))
|> streaming_compat
|> send_chunked(status)
|> chunk_reply(client, opts, prefetched)
|> chunk_reply(client, opts)
case result do
{:ok, conn} ->
halt(conn)
{:error, :closed, conn} ->
{:error, {:downstream, _error}, conn} ->
client().close(client)
halt(conn)
{:error, error, conn} ->
{:error, {:upstream, error}, conn} ->
Logger.warning(
"#{__MODULE__} request to #{url} failed while reading/chunking: #{inspect(error)}"
)
client().close(client)
halt(conn)
track_failed_url(url, error, opts)
raise_stream_error(conn, url, error)
end
end
defp chunk_reply(conn, client, opts, :none) do
defp chunk_reply(conn, client, opts) do
chunk_reply(conn, client, opts, 0, 0)
end
defp chunk_reply(conn, _client, _opts, :done), do: {:ok, conn}
defp chunk_reply(conn, _client, _opts, {:error, error}), do: {:error, error, conn}
defp chunk_reply(conn, client, opts, {:ok, data, duration}) do
with :ok <-
body_size_constraint(
byte_size(data),
Keyword.get(opts, :max_body_length, @max_body_length)
),
{:ok, conn} <- chunk(conn, data) do
chunk_reply(conn, client, opts, byte_size(data), duration)
else
{:error, error} -> {:error, error, conn}
end
end
defp chunk_reply(conn, client, opts, sent_so_far, duration) do
with {:ok, data, client, duration} <- read_chunk(client, duration, opts),
with {:ok, duration} <-
check_read_duration(
duration,
Keyword.get(opts, :max_read_duration, @max_read_duration)
),
{:ok, data, client} <- client().stream_body(client),
{:ok, duration} <- increase_read_duration(duration),
sent_so_far = sent_so_far + byte_size(data),
:ok <-
body_size_constraint(
sent_so_far,
Keyword.get(opts, :max_body_length, @max_body_length)
),
{:ok, conn} <- chunk(conn, data) do
{:ok, conn} <- write_chunk(conn, data) do
chunk_reply(conn, client, opts, sent_so_far, duration)
else
:done -> {:ok, conn}
{:error, error} -> {:error, error, conn}
{:error, {source, error}} -> {:error, {source, error}, conn}
{:error, error} -> {:error, {:upstream, error}, conn}
end
end
defp read_chunk(client, duration, opts) do
with {:ok, timer} <-
check_read_duration(
duration,
Keyword.get(opts, :max_read_duration, @max_read_duration)
),
{:ok, data, client} <- client().stream_body(client),
{:ok, duration} <- increase_read_duration(timer) do
{:ok, data, client, duration}
defp write_chunk(conn, data) do
case chunk(conn, data) do
{:error, error} -> {:error, {:downstream, error}}
result -> result
end
end
defp maybe_prefetch_for_content_type(headers, client, opts) do
if Keyword.get(opts, :sniff_content_type, false) and generic_content_type?(headers) do
case read_chunk(client, 0, opts) do
{:ok, data, client, duration} ->
{maybe_put_image_content_type(headers, data), client, {:ok, data, duration}}
:done ->
{headers, client, :done}
{:error, error} ->
{headers, client, {:error, error}}
end
else
{headers, client, :none}
end
end
defp generic_content_type?(headers) do
headers
|> get_content_type()
|> String.trim()
|> String.downcase()
|> then(&(&1 in ["", "application/octet-stream"]))
end
defp maybe_put_image_content_type(headers, data) do
with false <- data == "",
{:ok, %{mime_type: "image/" <> _ = content_type}} <-
Majic.perform({:bytes, binary_part(data, 0, min(byte_size(data), @sniff_bytes))},
pool: Pleroma.MajicPool
) do
[
{"content-type", content_type}
| Enum.reject(headers, fn {key, _} -> key == "content-type" end)
]
else
_ -> headers
end
rescue
error ->
Logger.debug("#{__MODULE__}: content-type sniffing failed: #{Exception.message(error)}")
headers
catch
kind, reason ->
Logger.debug("#{__MODULE__}: content-type sniffing failed: #{kind}: #{inspect(reason)}")
headers
end
defp head_response(conn, url, code, headers, opts) do
Logger.debug("#{__MODULE__} #{code} #{url} #{inspect(headers)}")
@ -388,33 +333,16 @@ defmodule Pleroma.ReverseProxy do
|> build_req_range_or_encoding_header(opts)
|> build_req_user_agent_header(opts)
|> merge_headers(Keyword.get(opts, :req_headers, []))
|> maybe_force_identity_encoding(opts)
end
defp merge_headers(headers, extra_headers) do
Enum.reduce(extra_headers, headers, fn {key, value}, headers ->
List.keystore(headers, String.downcase(key), 0, {String.downcase(key), value})
end)
end
defp maybe_force_identity_encoding(headers, opts) do
if Keyword.get(opts, :sniff_content_type, false) do
List.keystore(headers, "accept-encoding", 0, {"accept-encoding", "identity"})
else
headers
end
end
# Disable content-encoding if any @range_headers are requested (see #1823).
defp build_req_range_or_encoding_header(headers, _opts) do
range? = Enum.any?(headers, fn {header, _} -> Enum.member?(@range_headers, header) end)
cond do
range? && List.keymember?(headers, "accept-encoding", 0) ->
List.keydelete(headers, "accept-encoding", 0)
true ->
headers
if range? && List.keymember?(headers, "accept-encoding", 0) do
List.keydelete(headers, "accept-encoding", 0)
else
headers
end
end
@ -433,7 +361,30 @@ defmodule Pleroma.ReverseProxy do
|> build_resp_cache_headers(opts)
|> sanitise_content_type()
|> build_resp_content_disposition_header(opts)
|> Keyword.merge(Keyword.get(opts, :resp_headers, []))
|> merge_headers(Keyword.get(opts, :resp_headers, []))
|> ensure_no_transform()
end
defp merge_headers(headers, extra_headers) do
Enum.reduce(extra_headers, headers, fn {key, value}, headers ->
List.keystore(headers, String.downcase(key), 0, {String.downcase(key), value})
end)
end
defp ensure_no_transform(headers) do
{_, cache_control} =
List.keyfind(headers, "cache-control", 0, {"cache-control", @default_cache_control_header})
directives =
cache_control
|> String.split(",")
|> Enum.map(&(&1 |> String.trim() |> String.downcase()))
if "no-transform" in directives do
headers
else
replace_header(headers, "cache-control", cache_control <> ", no-transform")
end
end
defp sanitise_content_type(headers) do
@ -509,20 +460,7 @@ defmodule Pleroma.ReverseProxy do
replace_header(headers, "content-disposition", disposition)
else
if opt == true do
headers
else
name = inline_filename(content_type)
disposition =
if name do
"inline; filename=\"#{name}\""
else
"inline"
end
replace_header(headers, "content-disposition", disposition)
end
headers
end
end
@ -530,28 +468,34 @@ defmodule Pleroma.ReverseProxy do
[{key, value} | Enum.reject(headers, fn {header, _} -> header == key end)]
end
defp inline_filename(content_type) do
case MIME.extensions(content_type) do
[ext | _] when ext != "" -> "inline.#{ext}"
_ -> nil
end
end
defp header_length_constraint(headers, limit) do
lengths =
for {"content-length", value} <- headers,
do: parse_content_length(value)
defp header_length_constraint(headers, limit) when is_integer(limit) and limit > 0 do
with {_, size} <- List.keyfind(headers, "content-length", 0),
{size, _} <- Integer.parse(size),
true <- size <= limit do
:ok
else
false ->
{:error, :body_too_large}
case Enum.uniq(lengths) do
[] ->
{:ok, headers}
[size] when is_integer(size) ->
case body_size_constraint(size, limit) do
:ok -> {:ok, replace_header(headers, "content-length", to_string(size))}
error -> error
end
_ ->
:ok
{:error, :invalid_content_length}
end
end
defp header_length_constraint(_, _), do: :ok
defp parse_content_length(value) when is_binary(value) do
case Integer.parse(value) do
{size, ""} when size >= 0 -> size
_ -> :invalid
end
end
defp parse_content_length(_value), do: :invalid
defp body_size_constraint(size, limit) when is_integer(limit) and limit > 0 and size > limit do
{:error, :body_too_large}
@ -587,6 +531,40 @@ defmodule Pleroma.ReverseProxy do
defp client, do: Pleroma.ReverseProxy.Client.Wrapper
# Neither Plug adapter exposes a public way to abort a committed response.
# Cowboy finalizes chunked HTTP/1 responses when the request process exits,
# so terminate the connection first to leave the response visibly incomplete.
defp raise_stream_error(
%Plug.Conn{adapter: {Plug.Cowboy.Conn, %{pid: connection_pid}}} = conn,
url,
error
) do
if Plug.Conn.get_http_protocol(conn) in [:"HTTP/1.0", :"HTTP/1.1"] do
Process.exit(connection_pid, :kill)
end
raise StreamError, url: url, reason: error
end
# Bandit turns this exception into RST_STREAM; a regular exception sends an
# empty DATA frame with END_STREAM and makes the partial body look complete.
defp raise_stream_error(
%Plug.Conn{
adapter: {Bandit.Adapter, %{transport: %{stream_id: stream_id}}}
},
url,
error
) do
raise Bandit.HTTP2.Errors.StreamError,
message: StreamError.message(%StreamError{url: url, reason: error}),
error_code: Bandit.HTTP2.Errors.internal_error(),
stream_id: stream_id
end
defp raise_stream_error(_conn, url, error) do
raise StreamError, url: url, reason: error
end
defp track_failed_url(url, error, opts) do
ttl =
unless error in [:body_too_large, 400, 204] do
@ -598,19 +576,6 @@ defmodule Pleroma.ReverseProxy do
@cachex.put(:failed_proxy_url_cache, url, true, ttl: ttl)
end
# When Cowboy handles a chunked response with a content-length header it streams
# over HTTP 1.1 instead of chunking. Bandit cannot stream over HTTP 1.1 so the header
# must be stripped or it breaks RFC compliance for Transfer Encoding: Chunked. RFC9112§6.2
#
# HTTP2 is always streamed for all adapters.
defp streaming_compat(conn) do
with Phoenix.Endpoint.Cowboy2Adapter <- Pleroma.Web.Endpoint.config(:adapter) do
conn
else
_ -> delete_resp_header(conn, "content-length")
end
end
# Only when Tesla adapter is Hackney or Finch does the URL
# need encoding before Reverse Proxying as both end up
# using the raw Hackney client and cannot leverage our

View file

@ -5,13 +5,12 @@ defmodule Pleroma.Search do
alias Pleroma.Workers.SearchIndexingWorker
@spec add_to_index(Activity.t()) :: {:ok, Oban.Job.t() | :noop} | {:error, Oban.Job.changeset()}
def add_to_index(%Activity{id: activity_id, object: %Object{} = object} = activity) do
with {_, true} <- {:indexable, indexable?(activity)},
{_, "public"} <- {:visibility, Visibility.get_visibility(object)} do
def add_to_index(%Activity{id: activity_id, object: %Object{}} = activity) do
if indexable?(activity) do
SearchIndexingWorker.new(%{"op" => "add_to_index", "activity" => activity_id})
|> Oban.insert()
else
_ -> {:ok, :noop}
{:ok, :noop}
end
end
@ -41,40 +40,60 @@ defmodule Pleroma.Search do
def object_to_search_data(%Object{} = object) do
data = object.data
content_str =
case data["content"] do
[nil | rest] -> to_string(rest)
str -> str
end
content =
with {:ok, scrubbed} <-
FastSanitize.Sanitizer.scrub(content_str, Pleroma.HTML.Scrubber.SearchIndexing),
trimmed <- String.trim(scrubbed) do
trimmed
end
# Make sure we have a non-empty string
if content != "" do
{:ok, published, _} = DateTime.from_iso8601(data["published"])
data
|> search_texts()
|> Enum.map(&sanitize_search_text/1)
|> Enum.reject(&(&1 in ["", "."]))
|> Enum.join(" ")
with true <- content != "",
published when is_binary(published) <- data["published"],
{:ok, published, _} <- DateTime.from_iso8601(published) do
%{
id: object.id,
content: content,
ap: data["id"],
published: published |> DateTime.to_unix()
}
else
_ -> nil
end
end
defp indexable?(%Activity{
data: %{"type" => "Create"},
object: %Object{
data: %{"content" => content, "published" => published, "type" => "Note"}
}
})
when not is_nil(content) and content not in ["", "."] and not is_nil(published),
do: true
def indexable?(%Activity{
data: %{"type" => "Create"},
object: %Object{data: %{"published" => published, "type" => "Note"}} = object
})
when not is_nil(published) do
Visibility.get_visibility(object) in ["public", "unlisted"] and
not is_nil(object_to_search_data(object))
end
defp indexable?(_), do: false
def indexable?(_), do: false
defp search_texts(data) do
[data["content"], data["summary"] | attachment_names(data["attachment"])]
end
defp attachment_names(attachments) when is_list(attachments) do
Enum.map(attachments, fn
%{"summary" => summary} when is_binary(summary) -> summary
%{"name" => name} -> name
_ -> nil
end)
end
defp attachment_names(_), do: []
defp sanitize_search_text([nil | rest]), do: sanitize_search_text(to_string(rest))
defp sanitize_search_text(text) when is_binary(text) do
case FastSanitize.Sanitizer.scrub(text, Pleroma.HTML.Scrubber.SearchIndexing) do
{:ok, scrubbed} -> String.trim(scrubbed)
_ -> ""
end
end
defp sanitize_search_text(_), do: ""
end

View file

@ -0,0 +1,137 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2026 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Search.Backfill do
import Ecto.Query
alias Pleroma.Activity
alias Pleroma.Workers.SearchIndexingWorker
@default_chunk_size 100
@default_step_size 1_000
@default_limit 100_000
@doc """
Enqueues existing Create activities for search indexing.
The returned `next_cursor` can be passed as `before` to resume a bounded run.
Pass `limit: :infinity` to continue until the oldest activity is reached.
"""
def run(options \\ []) do
options =
Keyword.validate!(options,
chunk: @default_chunk_size,
step: @default_step_size,
limit: @default_limit,
before: nil,
on_page: nil
)
chunk_size = positive_option!(options, :chunk, @default_chunk_size)
step_size = positive_option!(options, :step, @default_step_size)
limit = limit_option!(options)
before = cursor_option!(options[:before])
on_page = callback_option!(options[:on_page])
do_run(before, limit, step_size, chunk_size, on_page, 0)
end
defp do_run(before, remaining, step_size, chunk_size, on_page, enqueued) do
page_size = page_size(remaining, step_size)
ids = fetch_ids(before, page_size)
case ids do
[] ->
result(enqueued, before, true)
ids ->
enqueue(ids, chunk_size)
page_count = length(ids)
next_cursor = List.last(ids)
total = enqueued + page_count
remaining = decrement(remaining, page_count)
on_page.(%{count: page_count, enqueued: total, next_cursor: next_cursor})
cond do
remaining == 0 -> result(total, next_cursor, false)
page_count < page_size -> result(total, next_cursor, true)
true -> do_run(next_cursor, remaining, step_size, chunk_size, on_page, total)
end
end
end
defp fetch_ids(before, limit) do
Activity
|> where([a], fragment("?->>'type' = 'Create'", a.data))
|> maybe_before(before)
|> order_by([a], desc: a.id)
|> limit(^limit)
|> select([a], a.id)
|> Pleroma.Repo.all()
end
defp maybe_before(query, nil), do: query
defp maybe_before(query, before), do: where(query, [a], a.id < ^before)
defp enqueue(ids, chunk_size) do
ids
|> Enum.chunk_every(chunk_size)
|> Enum.each(fn chunk ->
chunk
|> Enum.map(&SearchIndexingWorker.new(%{"op" => "add_to_index", "activity" => &1}))
|> Oban.insert_all()
end)
end
defp page_size(:infinity, step_size), do: step_size
defp page_size(remaining, step_size), do: min(remaining, step_size)
defp decrement(:infinity, _count), do: :infinity
defp decrement(remaining, count), do: remaining - count
defp result(enqueued, next_cursor, exhausted) do
%{enqueued: enqueued, next_cursor: next_cursor, exhausted: exhausted}
end
defp positive_option!(options, key, default) do
case Keyword.get(options, key, default) do
value when is_integer(value) and value > 0 -> value
value -> raise ArgumentError, "#{key} must be a positive integer, got: #{inspect(value)}"
end
end
defp limit_option!(options) do
case Keyword.fetch!(options, :limit) do
:infinity -> :infinity
value when is_integer(value) and value > 0 -> value
value -> raise ArgumentError, "limit must be a positive integer, got: #{inspect(value)}"
end
end
defp cursor_option!(nil), do: nil
defp cursor_option!(cursor) do
valid? =
try do
match?({:ok, <<_::binary-size(16)>>}, FlakeId.Ecto.CompatType.dump(cursor))
rescue
_ -> false
end
if valid? do
cursor
else
raise ArgumentError, "before must be a valid activity id, got: #{inspect(cursor)}"
end
end
defp callback_option!(nil), do: fn _ -> :ok end
defp callback_option!(callback) when is_function(callback, 1), do: callback
defp callback_option!(callback) do
raise ArgumentError, "on_page must be a function of arity 1, got: #{inspect(callback)}"
end
end

View file

@ -0,0 +1,287 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Search.ParadeDB do
@behaviour Pleroma.Search.SearchBackend
require Logger
require Pleroma.Constants
import Ecto.Query
import Pleroma.Search.DatabaseSearch,
only: [
maybe_fetch: 3,
maybe_restrict_author: 2,
maybe_restrict_blocked: 2,
maybe_restrict_local: 2
]
alias Pleroma.Activity
alias Pleroma.Config.Getting, as: Config
alias Pleroma.Search
@max_limit 40
@default_table "pleroma_search_documents"
defp client_impl do
Config.get([Pleroma.Search.ParadeDB, :client_impl]) ||
Pleroma.Search.ParadeDB.Client.Postgres
end
defp table do
Config.get([Pleroma.Search.ParadeDB, :table], @default_table)
end
defp fuzzy_distance do
case Config.get([Pleroma.Search.ParadeDB, :fuzzy_distance], 0) do
value when is_integer(value) and value in 0..2 ->
value
value when is_binary(value) ->
case Integer.parse(value) do
{parsed, ""} when parsed in 0..2 -> parsed
_ -> 0
end
_ ->
0
end
end
@impl true
def create_index do
with {:ok, _} <- client_impl().query("CREATE EXTENSION IF NOT EXISTS pg_search", []),
{:ok, _} <- client_impl().query(create_table_sql(table()), []),
{:ok, _} <- client_impl().query(create_object_id_index_sql(table()), []),
{:ok, _} <- client_impl().query(create_bm25_index_sql(table()), []) do
:ok
else
{:error, error} ->
Logger.error("ParadeDB create_index failed: #{inspect(error)}")
{:error, error}
end
end
@impl true
def drop_index do
case client_impl().query("DROP TABLE IF EXISTS #{table()}", []) do
{:ok, _} ->
:ok
{:error, error} ->
Logger.error("ParadeDB drop_index failed: #{inspect(error)}")
{:error, error}
end
end
@impl true
def healthcheck_endpoints, do: nil
@impl true
def add_to_index(activity) do
with true <- Search.indexable?(activity),
maybe_search_data when not is_nil(maybe_search_data) <-
Search.object_to_search_data(activity.object) do
dumped_activity_id = dump_activity_id(activity.id)
actor_ap_id = activity.data["actor"]
published_at = DateTime.from_unix!(maybe_search_data.published)
if is_nil(dumped_activity_id) do
Logger.error("ParadeDB add_to_index failed: invalid activity id #{inspect(activity.id)}")
:ok
else
sql =
"""
INSERT INTO #{table()} (id, object_id, object_ap_id, actor_ap_id, content, published_at)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (id) DO UPDATE SET
object_id = EXCLUDED.object_id,
object_ap_id = EXCLUDED.object_ap_id,
actor_ap_id = EXCLUDED.actor_ap_id,
content = EXCLUDED.content,
published_at = EXCLUDED.published_at
"""
params = [
dumped_activity_id,
maybe_search_data.id,
maybe_search_data.ap,
actor_ap_id,
maybe_search_data.content,
published_at
]
case client_impl().query(sql, params) do
{:ok, _} ->
:ok
{:error, error} ->
Logger.error("ParadeDB add_to_index failed: #{inspect(error)}")
{:error, error}
end
end
else
_ -> :ok
end
end
@impl true
def remove_from_index(object) do
sql = "DELETE FROM #{table()} WHERE object_id = $1"
case client_impl().query(sql, [object.id]) do
{:ok, _} ->
:ok
{:error, error} ->
Logger.error("ParadeDB remove_from_index failed: #{inspect(error)}")
{:error, error}
end
end
@impl true
def search(user, query, options \\ []) do
limit = options |> Keyword.get(:limit, @max_limit) |> min(@max_limit)
offset = Keyword.get(options, :offset, 0)
author = Keyword.get(options, :author)
case search_ids(query, limit, offset, author) do
{:ok, []} ->
maybe_fetch([], user, query)
{:ok, ids} ->
ids
|> fetch_activities(user, query, author)
{:error, error} ->
Logger.error("ParadeDB search failed: #{inspect(error)}")
maybe_fetch([], user, query)
end
end
defp fetch_activities(ids, user, query, author) do
dumped_ids =
ids
|> Enum.map(&dump_activity_id/1)
|> Enum.reject(&is_nil/1)
from(a in Activity, where: a.id in ^dumped_ids)
|> Activity.with_preloaded_object()
|> Activity.restrict_deactivated_users()
|> maybe_restrict_public(user)
|> maybe_restrict_local(user)
|> maybe_restrict_author(author)
|> maybe_restrict_blocked(user)
|> order_by(
[a],
fragment(
"array_position(?, ?)",
^dumped_ids,
a.id
)
)
|> Pleroma.Repo.all()
|> maybe_fetch(user, query)
end
defp maybe_restrict_public(query, %Pleroma.User{}) do
intended_recipients = [
Pleroma.Constants.as_public(),
Pleroma.Web.ActivityPub.Utils.as_local_public()
]
from(a in query,
where: fragment("?->>'type' = 'Create'", a.data),
where: fragment("? && ?", ^intended_recipients, a.recipients)
)
end
defp maybe_restrict_public(query, _user) do
from(a in query,
where: fragment("?->>'type' = 'Create'", a.data),
where: ^Pleroma.Constants.as_public() in a.recipients
)
end
defp search_ids(query, limit, offset, author) do
base_sql =
"SELECT id FROM #{table()} WHERE content &&& #{search_query_param_sql(fuzzy_distance())}"
params = [query]
{base_sql, params} =
case author do
%Pleroma.User{ap_id: ap_id} when is_binary(ap_id) ->
{base_sql <> " AND actor_ap_id = $2", params ++ [ap_id]}
_ ->
{base_sql, params}
end
limit_arg = length(params) + 1
offset_arg = length(params) + 2
sql =
base_sql <>
" ORDER BY published_at DESC, id DESC LIMIT $#{limit_arg} OFFSET $#{offset_arg}"
params = params ++ [limit, offset]
with {:ok, %{rows: rows}} <- client_impl().query(sql, params) do
{:ok, Enum.map(rows, fn [id] -> id end)}
end
end
defp search_query_param_sql(0), do: "$1"
defp search_query_param_sql(distance), do: "($1::text)::pdb.fuzzy(#{distance})"
defp create_table_sql(table) do
"""
CREATE TABLE IF NOT EXISTS #{table} (
id uuid PRIMARY KEY,
object_id bigint NOT NULL,
object_ap_id text NOT NULL,
actor_ap_id text,
content text NOT NULL,
published_at timestamptz
)
"""
end
defp create_object_id_index_sql(table) do
"CREATE INDEX IF NOT EXISTS #{table}_object_id_idx ON #{table} (object_id)"
end
defp create_bm25_index_sql(table) do
"""
CREATE INDEX IF NOT EXISTS #{table}_bm25_idx ON #{table}
USING bm25 (id, content, (actor_ap_id::pdb.literal), published_at)
WITH (key_field = 'id')
"""
end
defp dump_activity_id(<<_::binary-size(16)>> = uuid), do: uuid
defp dump_activity_id(id) when is_binary(id) do
case Ecto.UUID.dump(id) do
{:ok, <<_::binary-size(16)>> = uuid} ->
uuid
:error ->
dump_flake_activity_id(id)
end
end
defp dump_activity_id(_), do: nil
defp dump_flake_activity_id(id) do
case FlakeId.Ecto.CompatType.dump(id) do
{:ok, <<_::binary-size(16)>> = uuid} -> uuid
_ -> nil
end
rescue
_ -> nil
end
end

View file

@ -0,0 +1,24 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Search.ParadeDB.Client do
@moduledoc false
@type sql :: String.t()
@type params :: list()
@type result :: %{optional(:rows) => list(list())}
@callback query(sql(), params()) :: {:ok, result()} | {:error, any()}
end
defmodule Pleroma.Search.ParadeDB.Client.Postgres do
@moduledoc false
@behaviour Pleroma.Search.ParadeDB.Client
@impl true
def query(sql, params \\ []) do
Ecto.Adapters.SQL.query(Pleroma.Search.ParadeDB.Repo, sql, params)
end
end

View file

@ -0,0 +1,24 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Search.ParadeDB.Repo do
use Ecto.Repo,
otp_app: :pleroma,
adapter: Ecto.Adapters.Postgres,
migration_timestamps: [type: :naive_datetime_usec]
@doc """
Loads the ParadeDB database URL from `PARADEDB_DATABASE_URL`.
If the env var is not set, falls back to `config :pleroma, Pleroma.Search.ParadeDB, url: ...`.
"""
def init(_, opts) do
config = Application.get_env(:pleroma, Pleroma.Search.ParadeDB, [])
url = System.get_env("PARADEDB_DATABASE_URL") || config[:url]
url = if is_nil(url), do: nil, else: to_string(url)
{:ok, Keyword.put(opts, :url, url)}
end
end

View file

@ -39,6 +39,7 @@ defmodule Pleroma.User do
alias Pleroma.Web.Endpoint
alias Pleroma.Web.OAuth
alias Pleroma.Web.RelMe
alias Pleroma.Webhook.Notify
alias Pleroma.Workers.BackgroundWorker
alias Pleroma.Workers.DeleteWorker
alias Pleroma.Workers.UserRefreshWorker
@ -326,7 +327,7 @@ defmodule Pleroma.User do
@spec visible_for(User.t(), User.t() | nil) ::
:visible
| :invisible
| :restricted_unauthenticated
| :restrict_unauthenticated
| :deactivated
| :confirmation_pending
def visible_for(user, for_user \\ nil)
@ -987,6 +988,7 @@ defmodule Pleroma.User do
@doc "Inserts provided changeset, performs post-registration actions (confirmation email sending etc.)"
def register(%Ecto.Changeset{} = changeset) do
with {:ok, user} <- Repo.insert(changeset) do
Notify.trigger_webhooks(user, :"account.created")
post_register_action(user)
end
end
@ -1970,6 +1972,12 @@ defmodule Pleroma.User do
def approve(%User{} = user), do: {:ok, user}
def reject(%User{is_approved: false} = user) do
delete(user)
end
def reject(%User{} = _user), do: {:error, "User is approved"}
def confirm(users) when is_list(users) do
Repo.transaction(fn ->
Enum.map(users, fn user ->

View file

@ -63,7 +63,8 @@ defmodule Pleroma.User.Query do
limit: pos_integer(),
actor_types: [String.t()],
birthday_day: pos_integer(),
birthday_month: pos_integer()
birthday_month: pos_integer(),
staff: boolean()
}
| map()
@ -179,6 +180,10 @@ defmodule Pleroma.User.Query do
defp compose_query({:external, _}, query), do: location_query(query, false)
defp compose_query({:active, false}, query) do
where(query, [u], u.is_active == false or u.is_approved == false or u.is_confirmed == false)
end
defp compose_query({:active, _}, query) do
where(query, [u], u.is_active == true)
|> where([u], u.is_approved == true)
@ -202,6 +207,10 @@ defmodule Pleroma.User.Query do
where(query, [u], u.is_confirmed != ^bool)
end
defp compose_query({:need_approval, false}, query) do
where(query, [u], u.is_approved == true)
end
defp compose_query({:need_approval, _}, query) do
where(query, [u], u.is_approved == false)
end
@ -285,6 +294,19 @@ defmodule Pleroma.User.Query do
|> where([u], fragment("date_part('month', ?)", u.birthday) == ^month)
end
defp compose_query({:staff, false}, query) do
where(query, [u], u.is_admin == false and u.is_moderator == false)
end
defp compose_query({:staff, _}, query) do
where(query, [u], u.is_admin == true or u.is_moderator == true)
end
defp compose_query({:domain, domain}, query) do
query
|> where([u], like(u.nickname, ^"%@#{domain}"))
end
defp compose_query(_unsupported_param, query), do: query
defp location_query(query, local) do

View file

@ -30,6 +30,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
import Ecto.Query
import Pleroma.Web.ActivityPub.Utils
import Pleroma.Web.ActivityPub.Visibility
import Pleroma.Webhook.Notify, only: [trigger_webhooks: 2]
require Logger
require Pleroma.Constants
@ -415,7 +416,9 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
with flag_data <- make_flag_data(params, additional),
{:ok, activity} <- insert(flag_data, local),
_ <- notify_and_stream(activity),
:ok <- maybe_federate(activity) do
_ <- trigger_webhooks(activity, :"report.created"),
:ok <-
maybe_federate(activity) do
User.all_users_with_privilege(:reports_manage_reports)
|> Enum.filter(fn user -> user.ap_id != actor end)
|> Enum.filter(fn user -> not is_nil(user.email) end)
@ -710,6 +713,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
|> Map.put(:type, ["Create", "Announce"])
|> Map.put(:user, reading_user)
|> Map.put(:actor_id, user.ap_id)
|> Map.put(:actor_local, user.local)
|> Map.put(:pinned_object_ids, Map.keys(user.pinned_objects))
params =
@ -1016,6 +1020,14 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
defp restrict_state(query, _), do: query
defp restrict_report_target(query, %{report_target_id: actor_id}) do
from(activity in query,
where: fragment("?->'object'->>0 = ?", activity.data, ^actor_id)
)
end
defp restrict_report_target(query, _), do: query
defp restrict_assigned_account(query, %{assigned_account: assigned_account}) do
from(activity in query,
where: fragment("?->>'assigned_account' = ?", activity.data, ^assigned_account)
@ -1349,6 +1361,50 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
defp restrict_unauthenticated(query, _), do: query
defp restrict_activity_and_object_origin(query, local?) do
local_object_prefix = Pleroma.Web.Endpoint.url() <> "/"
# Keeping the correlated origin checks inside CASE prevents PostgreSQL from choosing a
# global bitmap-and-sort plan before applying the account statuses limit.
from([activity, object: object] in query,
where:
fragment(
"CASE WHEN ? = ? AND starts_with((?->>'id'), ?) = ? THEN true ELSE false END",
activity.local,
^local?,
object.data,
^local_object_prefix,
^local?
)
)
end
defp maybe_restrict_unauthenticated(
query,
%{restrict_unauthenticated: true, user: nil, actor_local: actor_local}
)
when is_boolean(actor_local) do
local_restricted = Config.restrict_unauthenticated_access?(:activities, :local)
remote_restricted = Config.restrict_unauthenticated_access?(:activities, :remote)
actor_restricted = if actor_local, do: local_restricted, else: remote_restricted
cond do
actor_restricted ->
from(activity in query, where: false)
local_restricted ->
restrict_activity_and_object_origin(query, false)
remote_restricted ->
restrict_activity_and_object_origin(query, true)
true ->
query
end
end
defp maybe_restrict_unauthenticated(query, _), do: query
defp restrict_quote_url(query, %{quote_url: quote_url}) do
from([_activity, object] in query,
where: fragment("(?)->'quoteUrl' = ?", object.data, ^quote_url)
@ -1506,6 +1562,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
|> maybe_preload_report_notes(opts)
|> maybe_set_thread_muted_field(opts)
|> maybe_order(opts)
|> maybe_restrict_unauthenticated(opts)
|> restrict_recipients_or_hashtags(recipients, opts[:user], opts[:followed_hashtags])
|> restrict_replies(opts)
|> restrict_since(opts)
@ -1514,6 +1571,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
|> restrict_actor(opts)
|> restrict_type(opts)
|> restrict_state(opts)
|> restrict_report_target(opts)
|> restrict_assigned_account(opts)
|> restrict_favorited_by(opts)
|> restrict_blocked(restrict_blocked_opts)

View file

@ -309,7 +309,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do
} = conn,
%{"nickname" => nickname} = params
) do
with {:recipient_exists, %User{} = recipient} <-
with false <- unsupported_http_message_signature?(conn),
{:recipient_exists, %User{} = recipient} <-
{:recipient_exists, User.get_cached_by_nickname(nickname)},
{:sender_exists, {:ok, %User{} = actor}} <-
{:sender_exists, User.get_or_fetch_by_ap_id(params["actor"])},
@ -320,6 +321,9 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do
Federator.incoming_ap_doc(params)
json(conn, "ok")
else
true ->
reject_unsupported_http_message_signature(conn)
{:recipient_exists, _} ->
conn
|> put_status(:not_found)
@ -348,20 +352,28 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do
end
def inbox(%{assigns: %{valid_signature: true, valid_host_header: true}} = conn, params) do
Federator.incoming_ap_doc(params)
json(conn, "ok")
if unsupported_http_message_signature?(conn) do
reject_unsupported_http_message_signature(conn)
else
Federator.incoming_ap_doc(params)
json(conn, "ok")
end
end
def inbox(%{assigns: %{valid_signature: false}} = conn, params) do
Federator.incoming_failed_signature_ap_doc(%{
method: conn.method,
req_headers: conn.req_headers,
request_path: conn.request_path,
params: params,
query_string: conn.query_string
})
if unsupported_http_message_signature?(conn) do
reject_unsupported_http_message_signature(conn)
else
Federator.incoming_failed_signature_ap_doc(%{
method: conn.method,
req_headers: conn.req_headers,
request_path: conn.request_path,
params: params,
query_string: conn.query_string
})
json(conn, "ok")
json(conn, "ok")
end
end
# POST /relay/inbox -or- POST /internal/fetch/inbox
@ -381,6 +393,27 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do
|> json("error, missing HTTP Signature")
end
defp reject_unsupported_http_message_signature(conn) do
conn
|> put_status(:unauthorized)
|> json("error, unsupported HTTP Message Signature")
end
defp unsupported_http_message_signature?(conn) do
signature_headers = Plug.Conn.get_req_header(conn, "signature")
multiple_signature_headers?(signature_headers) or
(Plug.Conn.get_req_header(conn, "signature-input") != [] and
not cavage_signature?(signature_headers))
end
defp multiple_signature_headers?([_, _ | _]), do: true
defp multiple_signature_headers?(_), do: false
defp cavage_signature?(signature_headers) do
Enum.any?(signature_headers, &Regex.match?(~r/(^|,)\s*keyId\s*=/, &1))
end
defp post_inbox_relayed_create(conn, params) do
Logger.debug(
"Signature missing or not from author, relayed Create message, fetching object from source"

View file

@ -118,6 +118,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do
when objtype in ~w[Question Answer Audio Video Image Event Article Note Page] do
with {:ok, object_data} <-
object
|> CommonFixes.maybe_set_attributed_to_from_activity(create_activity)
|> CommonFixes.maybe_add_language_from_activity(create_activity)
|> cast_and_apply_and_stringify_with_history(),
meta = Keyword.put(meta, :object_data, object_data),
@ -172,6 +173,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do
{_, {:ok, object_data, _}} <-
{:object_validation,
object
|> CommonFixes.maybe_set_attributed_to_from_activity(update_activity)
|> CommonFixes.maybe_add_language_from_activity(update_activity)
|> validate(meta)},
meta = Keyword.put(meta, :object_data, object_data),

View file

@ -50,7 +50,32 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ArticleNotePageValidator do
end
defp fix_url(%{"url" => url} = data) when is_bitstring(url), do: data
defp fix_url(%{"url" => url} = data) when is_map(url), do: Map.put(data, "url", url["href"])
defp fix_url(%{"url" => url} = data) when is_map(url) do
if is_binary(url["href"]) do
Map.put(data, "url", url["href"])
else
Map.delete(data, "url")
end
end
defp fix_url(%{"url" => url} = data) when is_list(url) do
first_element = Enum.at(url, 0)
url_string =
cond do
is_bitstring(first_element) -> first_element
is_map(first_element) -> first_element["href"]
true -> nil
end
if is_binary(url_string) do
Map.put(data, "url", url_string)
else
Map.delete(data, "url")
end
end
defp fix_url(data), do: data
defp fix_tag(%{"tag" => tag} = data) when is_list(tag) do
@ -79,10 +104,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ArticleNotePageValidator do
defp fix_replies(data), do: Map.delete(data, "replies")
def fix_attachments(%{"attachment" => attachment} = data) when is_map(attachment),
do: Map.put(data, "attachment", [attachment])
def fix_attachments(data), do: data
def fix_attachments(data), do: Transmogrifier.fix_attachments(data)
defp remote_mention_resolver(
%{"id" => ap_id, "tag" => tags},

View file

@ -41,22 +41,34 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes do
def fix_object_defaults(data) do
data = Maps.filter_empty_values(data)
in_reply_to_id = in_reply_to_id(data["inReplyTo"])
context =
Utils.maybe_create_context(
data["context"] || data["conversation"] || data["inReplyTo"] || data["id"]
data["context"] || data["conversation"] || in_reply_to_id || data["id"]
)
%User{follower_address: follower_collection} = User.get_cached_by_ap_id(data["attributedTo"])
data = Map.put(data, "context", context)
data
|> Map.put("context", context)
|> cast_and_filter_recipients("to", follower_collection)
|> cast_and_filter_recipients("cc", follower_collection)
|> cast_and_filter_recipients("bto", follower_collection)
|> cast_and_filter_recipients("bcc", follower_collection)
|> Transmogrifier.fix_implicit_addressing(follower_collection)
with attributed_to when is_binary(attributed_to) <- data["attributedTo"],
%User{follower_address: follower_collection} <- User.get_cached_by_ap_id(attributed_to) do
data
|> cast_and_filter_recipients("to", follower_collection)
|> cast_and_filter_recipients("cc", follower_collection)
|> cast_and_filter_recipients("bto", follower_collection)
|> cast_and_filter_recipients("bcc", follower_collection)
|> Transmogrifier.fix_implicit_addressing(follower_collection)
else
_ -> data
end
end
defp in_reply_to_id(in_reply_to) when is_binary(in_reply_to), do: in_reply_to
defp in_reply_to_id(%{"id" => in_reply_to}) when is_binary(in_reply_to), do: in_reply_to
defp in_reply_to_id(%{"href" => in_reply_to}) when is_binary(in_reply_to), do: in_reply_to
defp in_reply_to_id([in_reply_to | _]), do: in_reply_to_id(in_reply_to)
defp in_reply_to_id(_), do: nil
def fix_activity_addressing(activity) do
%User{follower_address: follower_collection} = User.get_cached_by_ap_id(activity["actor"])
@ -79,6 +91,17 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes do
|> Map.put("attributedTo", actor)
end
def maybe_set_attributed_to_from_activity(object, activity) when is_map(object) do
if is_nil(object["attributedTo"]) and is_nil(object["actor"]) do
case Containment.get_actor(activity) do
actor when is_binary(actor) -> Map.put(object, "attributedTo", actor)
_ -> object
end
else
object
end
end
def fix_activity_context(data, %Object{data: %{"context" => object_context}}) do
data
|> Map.put("context", object_context)
@ -96,23 +119,37 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes do
Map.put(data, "to", to)
end
def fix_quote_url(%{"quoteUrl" => _quote_url} = data), do: data
def fix_quote_url(%{"quoteUrl" => quote_url} = data) do
case normalize_object_id(quote_url) do
quote_url when is_binary(quote_url) -> Map.put(data, "quoteUrl", quote_url)
_ -> Map.delete(data, "quoteUrl")
end
end
# Fedibird
# https://github.com/fedibird/mastodon/commit/dbd7ae6cf58a92ec67c512296b4daaea0d01e6ac
def fix_quote_url(%{"quoteUri" => quote_url} = data) do
Map.put(data, "quoteUrl", quote_url)
case normalize_object_id(quote_url) do
quote_url when is_binary(quote_url) -> Map.put(data, "quoteUrl", quote_url)
_ -> data
end
end
# Old Fedibird (bug)
# https://github.com/fedibird/mastodon/issues/9
def fix_quote_url(%{"quoteURL" => quote_url} = data) do
Map.put(data, "quoteUrl", quote_url)
case normalize_object_id(quote_url) do
quote_url when is_binary(quote_url) -> Map.put(data, "quoteUrl", quote_url)
_ -> data
end
end
# Misskey fallback
def fix_quote_url(%{"_misskey_quote" => quote_url} = data) do
Map.put(data, "quoteUrl", quote_url)
case normalize_object_id(quote_url) do
quote_url when is_binary(quote_url) -> Map.put(data, "quoteUrl", quote_url)
_ -> data
end
end
def fix_quote_url(%{"tag" => [_ | _] = tags} = data) do
@ -128,6 +165,11 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes do
def fix_quote_url(data), do: data
defp normalize_object_id(object_id) when is_binary(object_id), do: object_id
defp normalize_object_id(%{"id" => object_id}) when is_binary(object_id), do: object_id
defp normalize_object_id([object_id | _]) when is_binary(object_id), do: object_id
defp normalize_object_id(_), do: nil
# On Mastodon, `"likes"` attribute includes an inlined `Collection` with `totalItems`,
# not a list of users.
# https://github.com/mastodon/mastodon/pull/32007

View file

@ -12,6 +12,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.TagValidator do
require Pleroma.Constants
@primary_key false
@tag_types ~w[Mention Hashtag Emoji Link]
embedded_schema do
# Common
field(:type, :string)
@ -49,7 +51,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.TagValidator do
|> validate_required([:type, :href])
end
def changeset(struct, %{"type" => "Hashtag", "name" => name} = data) do
def changeset(struct, %{"type" => "Hashtag", "name" => name} = data) when is_binary(name) do
name = String.downcase(name)
data = Map.put(data, "name", name)
@ -58,13 +60,42 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.TagValidator do
|> validate_required([:type, :name])
end
def changeset(struct, %{"type" => "Emoji"} = data) do
data = Map.put(data, "name", String.trim(data["name"], ":"))
def changeset(struct, %{"type" => "Hashtag"} = data) do
struct
|> cast(data, [:type, :name, :updated, :id])
|> cast_embed(:icon, with: &icon_changeset/2)
|> validate_required([:type, :name, :icon])
|> cast(data, [])
|> Map.put(:action, :ignore)
end
def changeset(struct, %{"type" => "Emoji", "name" => name} = data) when is_binary(name) do
data =
data
|> Map.put("name", String.trim(data["name"], ":"))
|> normalize_emoji_icon()
case data["icon"] do
%{"url" => url} when is_binary(url) ->
if valid_http_url?(url) do
struct
|> cast(data, [:type, :name, :updated, :id])
|> cast_embed(:icon, with: &icon_changeset/2)
|> validate_required([:type, :name, :icon])
else
struct
|> cast(data, [])
|> Map.put(:action, :ignore)
end
_ ->
struct
|> cast(data, [])
|> Map.put(:action, :ignore)
end
end
def changeset(struct, %{"type" => "Emoji"} = data) do
struct
|> cast(data, [])
|> Map.put(:action, :ignore)
end
def changeset(struct, %{"type" => "Link"} = data) do
@ -74,12 +105,80 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.TagValidator do
|> validate_required([:type, :href, :mediaType])
end
def changeset(struct, %{"type" => types} = data) when is_list(types) do
changeset(struct, infer_type(data))
end
def changeset(struct, %{"type" => _} = data) do
struct
|> cast(data, [])
|> Map.put(:action, :ignore)
end
def changeset(struct, data) when is_map(data) do
data = normalize(data)
if Map.has_key?(data, "type") do
changeset(struct, data)
else
struct
|> cast(data, [])
|> Map.put(:action, :ignore)
end
end
def normalize(data) when is_map(data), do: infer_type(data)
defp infer_type(%{"type" => types} = data) when is_list(types) do
type = Enum.find(types, &(&1 in @tag_types)) || Enum.find(types, &is_binary/1)
if type, do: Map.put(data, "type", type), else: Map.delete(data, "type")
end
defp infer_type(%{"type" => _} = data), do: data
defp infer_type(%{"name" => "#" <> _} = data), do: Map.put(data, "type", "Hashtag")
defp infer_type(%{"name" => "@" <> _} = data), do: Map.put(data, "type", "Mention")
defp infer_type(%{"mediaType" => _media_type, "href" => _href} = data),
do: Map.put(data, "type", "Link")
defp infer_type(data), do: data
defp normalize_emoji_icon(%{"icon" => icon} = data) when is_binary(icon) do
Map.put(data, "icon", %{"type" => "Image", "url" => icon})
end
defp normalize_emoji_icon(%{"icon" => icon} = data) when is_map(icon) do
case extract_icon_url(icon) do
url when is_binary(url) -> Map.put(data, "icon", %{"type" => "Image", "url" => url})
_ -> Map.delete(data, "icon")
end
end
defp normalize_emoji_icon(data), do: data
defp extract_icon_url(%{"url" => url}) when is_binary(url), do: url
defp extract_icon_url(%{"url" => %{"href" => href}}) when is_binary(href), do: href
defp extract_icon_url(%{"url" => [first | _]}) do
cond do
is_binary(first) -> first
is_map(first) -> first["href"]
true -> nil
end
end
defp extract_icon_url(%{"href" => href}) when is_binary(href), do: href
defp extract_icon_url(_), do: nil
defp valid_http_url?(url) when is_binary(url) do
match?({:ok, _}, ObjectValidators.Uri.cast(url))
end
defp valid_http_url?(_), do: false
def icon_changeset(struct, data) do
struct
|> cast(data, [:type, :url])

View file

@ -19,6 +19,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
alias Pleroma.Web.ActivityPub.Builder
alias Pleroma.Web.ActivityPub.ObjectValidator
alias Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes
alias Pleroma.Web.ActivityPub.ObjectValidators.TagValidator
alias Pleroma.Web.ActivityPub.Pipeline
alias Pleroma.Web.ActivityPub.Utils
alias Pleroma.Web.ActivityPub.Visibility
@ -28,6 +29,9 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
require Pleroma.Constants
@supported_types Pleroma.Constants.activity_types() ++
Pleroma.Constants.object_types() ++ Pleroma.Constants.actor_types()
@doc """
Modifies an incoming AP object (mastodon format) to our internal format.
"""
@ -201,9 +205,29 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
def fix_in_reply_to(%{"inReplyTo" => in_reply_to} = object, options)
when not is_nil(in_reply_to) do
in_reply_to_id = prepare_in_reply_to(in_reply_to)
object =
cond do
is_list(in_reply_to) ->
if not_empty_string(in_reply_to_id) do
Map.put(object, "inReplyTo", in_reply_to_id)
else
Map.delete(object, "inReplyTo")
end
is_map(in_reply_to) and is_nil(in_reply_to["id"]) and is_binary(in_reply_to["href"]) ->
Map.put(object, "inReplyTo", in_reply_to["href"])
is_map(in_reply_to) and is_nil(in_reply_to["id"]) and is_nil(in_reply_to["href"]) ->
Map.delete(object, "inReplyTo")
true ->
object
end
depth = (options[:depth] || 0) + 1
if Federator.allowed_thread_distance?(depth) do
if not_empty_string(in_reply_to_id) and Federator.allowed_thread_distance?(depth) do
with {:ok, replied_object} <- get_obj_helper(in_reply_to_id, options),
%Activity{} <- Activity.get_create_by_object_ap_id(replied_object.data["id"]) do
object
@ -249,9 +273,17 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
is_map(in_reply_to) && is_bitstring(in_reply_to["id"]) ->
in_reply_to["id"]
is_map(in_reply_to) && is_bitstring(in_reply_to["href"]) ->
in_reply_to["href"]
is_list(in_reply_to) && is_bitstring(Enum.at(in_reply_to, 0)) ->
Enum.at(in_reply_to, 0)
is_list(in_reply_to) && is_map(Enum.at(in_reply_to, 0)) ->
in_reply_to
|> Enum.at(0)
|> prepare_in_reply_to()
true ->
""
end
@ -265,59 +297,74 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
|> Map.drop(["conversation"])
end
defp valid_http_url?(href) when is_binary(href) do
match?({:ok, _}, ObjectValidators.Uri.cast(href))
end
defp valid_http_url?(_), do: false
def fix_attachments(%{"attachment" => attachment} = object) when is_list(attachment) do
attachments =
Enum.map(attachment, fn data ->
url =
cond do
is_list(data["url"]) -> List.first(data["url"])
is_map(data["url"]) -> data["url"]
true -> nil
end
Enum.map(attachment, fn
data when is_map(data) ->
url =
cond do
is_list(data["url"]) -> List.first(data["url"])
is_map(data["url"]) -> data["url"]
true -> nil
end
media_type =
cond do
is_map(url) && url =~ Pleroma.Constants.mime_regex() ->
url["mediaType"]
url_data = if is_map(url), do: url, else: %{}
is_bitstring(data["mediaType"]) && data["mediaType"] =~ Pleroma.Constants.mime_regex() ->
data["mediaType"]
media_type =
cond do
is_bitstring(url_data["mediaType"]) &&
url_data["mediaType"] =~ Pleroma.Constants.mime_regex() ->
url_data["mediaType"]
is_bitstring(data["mimeType"]) && data["mimeType"] =~ Pleroma.Constants.mime_regex() ->
data["mimeType"]
is_bitstring(data["mediaType"]) &&
data["mediaType"] =~ Pleroma.Constants.mime_regex() ->
data["mediaType"]
true ->
nil
end
is_bitstring(data["mimeType"]) && data["mimeType"] =~ Pleroma.Constants.mime_regex() ->
data["mimeType"]
href =
cond do
is_map(url) && is_binary(url["href"]) -> url["href"]
is_binary(data["url"]) -> data["url"]
is_binary(data["href"]) -> data["href"]
true -> nil
end
true ->
nil
end
href =
cond do
is_binary(url) -> url
is_binary(url_data["href"]) -> url_data["href"]
is_binary(data["url"]) -> data["url"]
is_binary(data["href"]) -> data["href"]
true -> nil
end
if href && valid_http_url?(href) do
attachment_url =
%{
"href" => href,
"type" => Map.get(url_data, "type", "Link")
}
|> Maps.put_if_present("mediaType", media_type)
|> Maps.put_if_present("width", url_data["width"] || data["width"])
|> Maps.put_if_present("height", url_data["height"] || data["height"])
if href do
attachment_url =
%{
"href" => href,
"type" => Map.get(url || %{}, "type", "Link")
"url" => [attachment_url],
"type" => data["type"] || "Document"
}
|> Maps.put_if_present("mediaType", media_type)
|> Maps.put_if_present("width", (url || %{})["width"] || data["width"])
|> Maps.put_if_present("height", (url || %{})["height"] || data["height"])
|> Maps.put_if_present("name", data["name"])
|> Maps.put_if_present("blurhash", data["blurhash"])
else
nil
end
%{
"url" => [attachment_url],
"type" => data["type"] || "Document"
}
|> Maps.put_if_present("mediaType", media_type)
|> Maps.put_if_present("name", data["name"])
|> Maps.put_if_present("blurhash", data["blurhash"])
else
_ ->
nil
end
end)
|> Enum.filter(& &1)
@ -351,24 +398,58 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
def fix_url(object), do: object
defp emoji_icon_url(icon) when is_binary(icon), do: icon
defp emoji_icon_url(%{"url" => url}) when is_binary(url), do: url
defp emoji_icon_url(%{"url" => %{"href" => href}}) when is_binary(href), do: href
defp emoji_icon_url(%{"url" => [first | _]}) do
cond do
is_binary(first) -> first
is_map(first) -> first["href"] || first["url"] || first["id"]
true -> nil
end
end
defp emoji_icon_url(%{"href" => href}) when is_binary(href), do: href
defp emoji_icon_url(_), do: nil
defp valid_emoji_icon_url(icon) do
case emoji_icon_url(icon) do
url when is_binary(url) -> if valid_http_url?(url), do: url
_ -> nil
end
end
def fix_emoji(%{"tag" => tags} = object) when is_list(tags) do
emoji =
tags
|> Enum.filter(fn data -> is_map(data) and data["type"] == "Emoji" and data["icon"] end)
|> Enum.filter(fn data ->
is_map(data) and data["type"] == "Emoji" and is_binary(data["name"]) and data["icon"]
end)
|> Enum.reduce(%{}, fn data, mapping ->
name = String.trim(data["name"], ":")
Map.put(mapping, name, data["icon"]["url"])
case valid_emoji_icon_url(data["icon"]) do
url when is_binary(url) -> Map.put(mapping, name, url)
_ -> mapping
end
end)
Map.put(object, "emoji", emoji)
end
def fix_emoji(%{"tag" => %{"type" => "Emoji"} = tag} = object) do
name = String.trim(tag["name"], ":")
emoji = %{name => tag["icon"]["url"]}
def fix_emoji(%{"tag" => %{"type" => "Emoji", "name" => name} = tag} = object)
when is_binary(name) do
name = String.trim(name, ":")
Map.put(object, "emoji", emoji)
with url when is_binary(url) <- valid_emoji_icon_url(tag["icon"]) do
Map.put(object, "emoji", %{name => url})
else
_ -> object
end
end
def fix_emoji(object), do: object
@ -376,7 +457,9 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
def fix_tag(%{"tag" => tag} = object) when is_list(tag) do
tags =
tag
|> Enum.filter(fn data -> data["type"] == "Hashtag" and data["name"] end)
|> Enum.filter(fn data ->
is_map(data) and data["type"] == "Hashtag" and is_binary(data["name"])
end)
|> Enum.map(fn
%{"name" => "#" <> hashtag} -> String.downcase(hashtag)
%{"name" => hashtag} -> String.downcase(hashtag)
@ -439,9 +522,37 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
def handle_incoming(data, options \\ []) do
data
|> fix_recursive(&strip_internal_fields/1)
|> fix_recursive(&fix_type_as_string/1)
|> handle_incoming_normalized(options)
end
defp fix_type_as_string(data) do
data
|> normalize_type()
|> normalize_tag_types()
end
defp normalize_type(%{"type" => types} = data) when is_list(types) do
type = Enum.find(types, &(&1 in @supported_types)) || Enum.find(types, &is_binary/1)
if type, do: Map.put(data, "type", type), else: data
end
defp normalize_type(data), do: data
defp normalize_tag_types(%{"tag" => tags} = data) when is_list(tags) do
Map.put(data, "tag", Enum.map(tags, &normalize_tag_type/1))
end
defp normalize_tag_types(%{"tag" => tag} = data) when is_map(tag) do
Map.put(data, "tag", normalize_tag_type(tag))
end
defp normalize_tag_types(data), do: data
defp normalize_tag_type(tag) when is_map(tag), do: TagValidator.normalize(tag)
defp normalize_tag_type(tag), do: tag
# Flag objects are placed ahead of the ID check because Mastodon 2.8 and earlier send them
# with nil ID.
defp handle_incoming_normalized(

View file

@ -75,6 +75,7 @@ defmodule Pleroma.Web.AdminAPI.ConfigController do
Pleroma.Docs.JSON.compiled_descriptions()
|> Enum.filter(&allowed_config?(&1, :whitelist))
|> Enum.reject(&allowed_config?(&1, :blacklist))
|> Enum.reject(&ConfigDB.static?/1)
json(conn, translate_descriptions(descriptions))
end
@ -92,7 +93,7 @@ defmodule Pleroma.Web.AdminAPI.ConfigController do
def show(conn, _params) do
with :ok <- configurable_from_database() do
configs = ConfigDB.get_all_as_keyword()
configs = ConfigDB.get_all_as_keyword(exclude_static: true)
merged =
Config.Holder.default_config()
@ -123,6 +124,7 @@ defmodule Pleroma.Web.AdminAPI.ConfigController do
end)
end)
|> List.flatten()
|> Enum.reject(&ConfigDB.static?/1)
render(conn, "index.json", %{
configs: merged,
@ -137,6 +139,7 @@ defmodule Pleroma.Web.AdminAPI.ConfigController do
configs
|> Enum.filter(&allowed_config?(&1, :whitelist))
|> Enum.reject(&allowed_config?(&1, :blacklist))
|> Enum.reject(&(ConfigDB.static?(&1) and not &1[:delete]))
|> Enum.map(fn
%{group: group, key: key, delete: true} = params ->
ConfigDB.delete(%{group: group, key: key, subkeys: params[:subkeys]})

View file

@ -11,6 +11,12 @@ defmodule Pleroma.Web.AdminAPI.FallbackController do
|> json(%{error: dgettext("errors", "Not found")})
end
def call(conn, {:error, :forbidden}) do
conn
|> put_status(:forbidden)
|> json(%{error: dgettext("errors", "Forbidden")})
end
def call(conn, {:error, reason}) do
conn
|> put_status(:bad_request)

View file

@ -8,13 +8,17 @@ defmodule Pleroma.Web.AdminAPI.UserController do
import Pleroma.Web.ControllerHelper,
only: [fetch_integer_param: 3]
alias Ecto.Multi
alias Pleroma.ModerationLog
alias Pleroma.PasswordResetToken
alias Pleroma.User
alias Pleroma.Web.ActivityPub.Builder
alias Pleroma.Web.ActivityPub.Pipeline
alias Pleroma.Web.AdminAPI
alias Pleroma.Web.AdminAPI.Search
alias Pleroma.Web.Endpoint
alias Pleroma.Web.Plugs.OAuthScopesPlug
alias Pleroma.Web.Router
@users_page_size 50
@ -148,9 +152,25 @@ defmodule Pleroma.Web.AdminAPI.UserController do
} = conn,
_
) do
changesets =
conn =
if Enum.any?(users, fn user -> not Map.has_key?(user, :password) end) do
put_private(conn, :skip_idempotency_cache, true)
else
conn
end
multi =
users
|> Enum.map(fn %{nickname: nickname, email: email, password: password} ->
|> Enum.map(fn %{nickname: nickname, email: email} = attrs ->
{password, passwordless?} =
case Map.fetch(attrs, :password) do
{:ok, password} ->
{password, false}
:error ->
{:crypto.strong_rand_bytes(16) |> Base.encode64(), true}
end
user_data = %{
nickname: nickname,
name: nickname,
@ -160,44 +180,80 @@ defmodule Pleroma.Web.AdminAPI.UserController do
bio: "."
}
User.register_changeset(%User{}, user_data, need_confirmation: false)
{User.register_changeset(%User{}, user_data, confirmed: true), passwordless?}
end)
|> Enum.reduce(Ecto.Multi.new(), fn changeset, multi ->
Ecto.Multi.insert(multi, Ecto.UUID.generate(), changeset)
|> Enum.reduce(Multi.new(), fn {changeset, passwordless?}, multi ->
user_operation = {:user, Ecto.UUID.generate()}
multi = Multi.insert(multi, user_operation, changeset)
if passwordless? do
Multi.run(
multi,
{:password_reset_token, Ecto.UUID.generate()},
fn _repo, changes ->
changes
|> Map.fetch!(user_operation)
|> PasswordResetToken.create_token()
end
)
else
multi
end
end)
case Pleroma.Repo.transaction(changesets) do
{:ok, users_map} ->
users =
users_map
case Pleroma.Repo.transaction(multi) do
{:ok, results} ->
{users, password_reset_tokens} =
results
|> Map.values()
|> Enum.split_with(&match?(%User{}, &1))
users =
users
|> Enum.map(fn user ->
{:ok, user} = User.post_register_action(user)
user
end)
password_reset_links =
Map.new(password_reset_tokens, fn token ->
{token.user_id, Router.Helpers.reset_password_url(Endpoint, :reset, token.token)}
end)
ModerationLog.insert_log(%{
actor: admin,
subjects: users,
action: "create"
})
render(conn, "created_many.json", users: users)
render(conn, "created_many.json",
users: users,
password_reset_links: password_reset_links
)
{:error, id, changeset, _} ->
{:error, {:user, _} = id, %Ecto.Changeset{} = changeset, _} ->
changesets =
Enum.map(changesets.operations, fn
Enum.flat_map(multi.operations, fn
{^id, {:changeset, _current_changeset, _}} ->
changeset
[changeset]
{_, {:changeset, current_changeset, _}} ->
current_changeset
{{:user, _}, {:changeset, current_changeset, _}} ->
[current_changeset]
_ ->
[]
end)
conn
|> put_status(:conflict)
|> render("create_errors.json", changesets: changesets)
{:error, {:password_reset_token, _}, _reason, _} ->
Pleroma.Web.AdminAPI.FallbackController.call(
conn,
{:password_reset_token_error, :creation_failed}
)
end
end

View file

@ -0,0 +1,101 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.AdminAPI.WebhookController do
use Pleroma.Web, :controller
alias Pleroma.Repo
alias Pleroma.Web.Plugs.OAuthScopesPlug
alias Pleroma.Webhook
plug(Pleroma.Web.ApiSpec.CastAndValidate)
plug(
OAuthScopesPlug,
%{scopes: ["admin:write"]}
when action in [:update, :create, :delete, :enable, :disable, :rotate_secret]
)
plug(OAuthScopesPlug, %{scopes: ["admin:read"]} when action in [:index, :show])
action_fallback(Pleroma.Web.AdminAPI.FallbackController)
defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.Admin.WebhookOperation
def index(conn, _) do
webhooks =
Webhook
|> Repo.all()
render(conn, "index.json", webhooks: webhooks)
end
def show(conn, %{id: id}) do
with %Webhook{} = webhook <- Webhook.get(id) do
render(conn, "show.json", webhook: webhook)
else
nil -> {:error, :not_found}
end
end
def create(%{body_params: params} = conn, _) do
with {:ok, webhook} <- Webhook.create(webhook_params(params)) do
render(conn, "show.json", webhook: webhook)
end
end
def update(%{body_params: params} = conn, %{id: id}) do
with %Webhook{internal: false} = webhook <- Webhook.get(id),
{:ok, webhook} <- Webhook.update(webhook, webhook_params(params)) do
render(conn, "show.json", webhook: webhook)
else
%Webhook{internal: true} -> {:error, :forbidden}
nil -> {:error, :not_found}
end
end
def delete(conn, %{id: id}) do
with %Webhook{internal: false} = webhook <- Webhook.get(id),
{:ok, webhook} <- Webhook.delete(webhook) do
render(conn, "show.json", webhook: webhook)
else
%Webhook{internal: true} -> {:error, :forbidden}
nil -> {:error, :not_found}
end
end
def enable(conn, %{id: id}) do
with %Webhook{internal: false} = webhook <- Webhook.get(id),
{:ok, webhook} <- Webhook.set_enabled(webhook, true) do
render(conn, "show.json", webhook: webhook)
else
%Webhook{internal: true} -> {:error, :forbidden}
nil -> {:error, :not_found}
end
end
def disable(conn, %{id: id}) do
with %Webhook{internal: false} = webhook <- Webhook.get(id),
{:ok, webhook} <- Webhook.set_enabled(webhook, false) do
render(conn, "show.json", webhook: webhook)
else
%Webhook{internal: true} -> {:error, :forbidden}
nil -> {:error, :not_found}
end
end
def rotate_secret(conn, %{id: id}) do
with %Webhook{internal: false} = webhook <- Webhook.get(id),
{:ok, webhook} <- Webhook.rotate_secret(webhook) do
render(conn, "show.json", webhook: webhook)
else
%Webhook{internal: true} -> {:error, :forbidden}
nil -> {:error, :not_found}
end
end
defp webhook_params(params) do
Map.drop(params, [:internal, "internal"])
end
end

View file

@ -88,18 +88,29 @@ defmodule Pleroma.Web.AdminAPI.AccountView do
}
end
def render("created_many.json", %{users: users}) do
render_many(users, AccountView, "created.json", as: :user)
def render("created_many.json", %{users: users} = assigns) do
password_reset_links = Map.get(assigns, :password_reset_links, %{})
render_many(users, AccountView, "created.json",
as: :user,
password_reset_links: password_reset_links
)
end
def render("created.json", %{user: user}) do
%{
type: "success",
code: 200,
data: %{
def render("created.json", %{user: user} = assigns) do
password_reset_links = Map.get(assigns, :password_reset_links, %{})
data =
%{
nickname: user.nickname,
email: user.email
}
|> maybe_put_password_reset_link(Map.get(password_reset_links, user.id))
%{
type: "success",
code: 200,
data: data
}
end
@ -119,6 +130,11 @@ defmodule Pleroma.Web.AdminAPI.AccountView do
}
end
defp maybe_put_password_reset_link(data, nil), do: data
defp maybe_put_password_reset_link(data, link),
do: Map.put(data, :password_reset_link, link)
def merge_account_views(%User{} = user) do
MastodonAPI.AccountView.render("show.json", %{user: user, skip_visibility_check: true})
|> Map.merge(AdminAPI.AccountView.render("show.json", %{user: user}))

View file

@ -0,0 +1,34 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.AdminAPI.WebhookView do
use Pleroma.Web, :view
alias Pleroma.Web.CommonAPI.Utils
def render("index.json", %{webhooks: webhooks}) do
render_many(webhooks, __MODULE__, "show.json")
end
def render("show.json", %{webhook: webhook}) do
%{
id: webhook.id |> to_string(),
url: webhook.url,
events: webhook.events,
secret: webhook.secret,
enabled: webhook.enabled,
internal: webhook.internal,
created_at: Utils.to_masto_date(webhook.inserted_at),
updated_at: Utils.to_masto_date(webhook.updated_at)
}
end
def render("event.json", %{type: type, object: object}) do
%{
event: type,
created_at: Utils.to_masto_date(NaiveDateTime.utc_now()),
object: object
}
end
end

View file

@ -105,7 +105,15 @@ defmodule Pleroma.Web.ApiSpec do
"Report management",
"Status administration",
"User administration",
"Announcement management"
"Announcement management",
"Webhooks"
]
},
%{
"name" => "Administration (Mastodon API)",
"tags" => [
"User administration (Mastodon API)",
"Report management (Mastodon API)"
]
},
%{"name" => "Applications", "tags" => ["Applications", "Push subscriptions"]},

View file

@ -82,8 +82,15 @@ defmodule Pleroma.Web.ApiSpec.Admin.UserOperation do
properties: %{
nickname: %Schema{type: :string},
email: %Schema{type: :string},
password: %Schema{type: :string}
}
password: %Schema{
type: :string,
description:
"Optional. When omitted, a `password_reset_link` is returned, " <>
"allowing the user to set their own password. When provided, " <>
"it must not be empty."
}
},
required: [:nickname, :email]
}
}
}
@ -102,7 +109,8 @@ defmodule Pleroma.Web.ApiSpec.Admin.UserOperation do
type: :object,
properties: %{
email: %Schema{type: :string, format: :email},
nickname: %Schema{type: :string}
nickname: %Schema{type: :string},
password_reset_link: %Schema{type: :string}
}
}
}

View file

@ -0,0 +1,208 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ApiSpec.Admin.WebhookOperation do
alias OpenApiSpex.Operation
alias OpenApiSpex.Schema
alias Pleroma.Web.ApiSpec.Schemas.ApiError
import Pleroma.Web.ApiSpec.Helpers
def open_api_operation(action) do
operation = String.to_existing_atom("#{action}_operation")
apply(__MODULE__, operation, [])
end
def index_operation do
%Operation{
tags: ["Webhooks"],
summary: "Retrieve a list of webhooks",
operationId: "AdminAPI.WebhookController.index",
security: [%{"oAuth" => ["admin:read"]}],
responses: %{
200 =>
Operation.response("Array of webhooks", "application/json", %Schema{
type: :array,
items: webhook()
})
}
}
end
def show_operation do
%Operation{
tags: ["Webhooks"],
summary: "Retrieve a webhook",
operationId: "AdminAPI.WebhookController.show",
security: [%{"oAuth" => ["admin:read"]}],
parameters: [id_param()],
responses: %{
200 => Operation.response("Webhook", "application/json", webhook())
}
}
end
def create_operation do
%Operation{
tags: ["Webhooks"],
summary: "Create a webhook",
operationId: "AdminAPI.WebhookController.create",
security: [%{"oAuth" => ["admin:write"]}],
requestBody:
request_body(
"Parameters",
%Schema{
description: "POST body for creating a webhook",
type: :object,
required: [:url, :events],
properties: %{
url: %Schema{type: :string, format: :uri, maxLength: 255},
events: event_type(min_items: 1),
enabled: %Schema{type: :boolean}
},
additionalProperties: false
}
),
responses: %{
200 => Operation.response("Webhook", "application/json", webhook())
}
}
end
def update_operation do
%Operation{
tags: ["Webhooks"],
summary: "Update a webhook",
operationId: "AdminAPI.WebhookController.update",
security: [%{"oAuth" => ["admin:write"]}],
parameters: [id_param()],
requestBody:
request_body(
"Parameters",
%Schema{
description: "POST body for updating a webhook",
type: :object,
properties: %{
url: %Schema{type: :string, format: :uri, maxLength: 255},
events: event_type(min_items: 1),
enabled: %Schema{type: :boolean}
},
additionalProperties: false
}
),
responses: %{
200 => Operation.response("Webhook", "application/json", webhook()),
403 => Operation.response("Forbidden", "application/json", ApiError)
}
}
end
def delete_operation do
%Operation{
tags: ["Webhooks"],
summary: "Delete a webhook",
operationId: "AdminAPI.WebhookController.delete",
security: [%{"oAuth" => ["admin:write"]}],
parameters: [id_param()],
responses: %{
200 => Operation.response("Webhook", "application/json", webhook()),
403 => Operation.response("Forbidden", "application/json", ApiError)
}
}
end
def enable_operation do
%Operation{
tags: ["Webhooks"],
summary: "Enable a webhook",
operationId: "AdminAPI.WebhookController.enable",
security: [%{"oAuth" => ["admin:write"]}],
parameters: [id_param()],
responses: %{
200 => Operation.response("Webhook", "application/json", webhook()),
403 => Operation.response("Forbidden", "application/json", ApiError)
}
}
end
def disable_operation do
%Operation{
tags: ["Webhooks"],
summary: "Disable a webhook",
operationId: "AdminAPI.WebhookController.disable",
security: [%{"oAuth" => ["admin:write"]}],
parameters: [id_param()],
responses: %{
200 => Operation.response("Webhook", "application/json", webhook()),
403 => Operation.response("Forbidden", "application/json", ApiError)
}
}
end
def rotate_secret_operation do
%Operation{
tags: ["Webhooks"],
summary: "Rotate webhook signing secret",
operationId: "AdminAPI.WebhookController.rotate_secret",
security: [%{"oAuth" => ["admin:write"]}],
parameters: [id_param()],
responses: %{
200 => Operation.response("Webhook", "application/json", webhook()),
403 => Operation.response("Forbidden", "application/json", ApiError)
}
}
end
defp webhook do
%Schema{
title: "Webhook",
description: "Schema for a webhook",
type: :object,
properties: %{
id: %Schema{type: :string},
url: %Schema{type: :string, format: :uri, maxLength: 255},
events: event_type(),
secret: %Schema{type: :string},
enabled: %Schema{type: :boolean},
internal: %Schema{type: :boolean},
created_at: %Schema{type: :string, format: :"date-time"},
updated_at: %Schema{type: :string, format: :"date-time"}
},
example: %{
"id" => "1",
"url" => "https://example.com/webhook",
"events" => ["report.created"],
"secret" => "d3d8cf4bc11fd9c41fd34dcc38d282e451c8bd34",
"enabled" => true,
"internal" => false,
"created_at" => "2022-06-24T16:19:38.523Z",
"updated_at" => "2022-06-24T16:19:38.523Z"
}
}
end
defp event_type(opts \\ []) do
schema = %Schema{
type: :array,
items: %Schema{
title: "Event",
description: "Event type",
type: :string,
enum: ["account.created", "report.created"]
}
}
case Keyword.fetch(opts, :min_items) do
{:ok, min_items} -> %{schema | minItems: min_items}
:error -> schema
end
end
defp id_param do
Operation.parameter(:id, :path, :string, "Webhook ID",
example: "123",
required: true
)
end
end

View file

@ -0,0 +1,348 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ApiSpec.MastodonAdmin.AccountOperation do
alias OpenApiSpex.Operation
alias OpenApiSpex.Schema
alias Pleroma.Web.ApiSpec.Schemas.Account
alias Pleroma.Web.ApiSpec.Schemas.ApiError
alias Pleroma.Web.ApiSpec.Schemas.FlakeID
import Pleroma.Web.ApiSpec.Helpers
def open_api_operation(action) do
operation = String.to_existing_atom("#{action}_operation")
apply(__MODULE__, operation, [])
end
def index_operation do
%Operation{
tags: ["User administration (Mastodon API)"],
summary: "View accounts by criteria (v1)",
operationId: "MastodonAdmin.AccountController.index",
description: "View accounts matching certain criteria for filtering, up to 40 at a time.",
security: [%{"oAuth" => ["admin:read:accounts"]}],
parameters:
[
Operation.parameter(:local, :query, :boolean, "Filter for local accounts?"),
Operation.parameter(:remote, :query, :boolean, "Filter for remote accounts?"),
Operation.parameter(
:active,
:query,
:boolean,
"Filter for currently active accounts?"
),
Operation.parameter(
:pending,
:query,
:boolean,
"Filter for currently pending accounts?"
),
Operation.parameter(
:disabled,
:query,
:boolean,
"Filter for currently disabled accounts?"
),
Operation.parameter(
:silenced,
:query,
:boolean,
"Filter for currently silenced accounts? (not implemented yet)"
),
Operation.parameter(
:suspended,
:query,
:boolean,
"Filter for currently suspended accounts? (not implemented yet)"
),
Operation.parameter(
:sensitized,
:query,
:boolean,
"Filter for accounts force-marked as sensitive? (not implemented yet)"
),
Operation.parameter(:username, :query, :string, "Search for the given username"),
Operation.parameter(
:display_name,
:query,
:string,
"Search for the given display name"
),
Operation.parameter(
:by_domain,
:query,
:string,
"Filter by the given domain"
),
Operation.parameter(:email, :query, :string, "Lookup a user with this email"),
Operation.parameter(
:ip,
:query,
:string,
"Lookup users with this IP address (not implemented yet)"
),
Operation.parameter(:staff, :query, :boolean, "Filter for staff accounts?")
] ++
pagination_params(),
responses: %{
200 =>
Operation.response("Account", "application/json", %Schema{
title: "ArrayOfAccounts",
type: :array,
items: account()
}),
401 => Operation.response("Error", "application/json", ApiError)
}
}
end
def index2_operation do
%Operation{
tags: ["User administration (Mastodon API)"],
summary: "View accounts by criteria (v2)",
operationId: "MastodonAdmin.AccountController.index2",
description: "View accounts matching certain criteria for filtering, up to 40 at a time.",
security: [%{"oAuth" => ["admin:read:accounts"]}],
parameters:
[
Operation.parameter(
:origin,
:query,
%Schema{type: :string, enum: ["local", "remote"]},
"Filter for local or remote accounts"
),
Operation.parameter(
:status,
:query,
%Schema{
type: :string,
enum: ["active", "inactive", "pending", "disabled", "silenced", "suspended"]
},
"Filter for active, pending, disabled, silenced or suspended accounts"
),
Operation.parameter(
:permissions,
:query,
:string,
"Filter for accounts with staff permissions (users that can manage reports)."
),
Operation.parameter(
:role_ids,
:query,
%Schema{
oneOf: [
%Schema{type: :array, items: %Schema{type: :string}},
%Schema{type: :string}
]
},
"Filter for users with these roles. (not implemented yet)"
),
Operation.parameter(
:invited_by,
:query,
:string,
"Lookup users invited by the account with this ID. (not implemented yet)"
),
Operation.parameter(:username, :query, :string, "Search for the given username"),
Operation.parameter(
:display_name,
:query,
:string,
"Search for the given display name"
),
Operation.parameter(
:by_domain,
:query,
:string,
"Filter by the given domain"
),
Operation.parameter(:email, :query, :string, "Lookup a user with this email"),
Operation.parameter(
:ip,
:query,
:string,
"Lookup users with this IP address (not implemented yet)"
)
] ++
pagination_params(),
responses: %{
200 =>
Operation.response("Account", "application/json", %Schema{
title: "ArrayOfAccounts",
type: :array,
items: account()
}),
401 => Operation.response("Error", "application/json", ApiError)
}
}
end
def show_operation do
%Operation{
tags: ["User administration (Mastodon API)"],
summary: "View a specific account",
operationId: "MastodonAdmin.AccountController.show",
description: "View admin-level information about the given account.",
security: [%{"oAuth" => ["admin:read:accounts"]}],
parameters: [
Operation.parameter(:id, :path, :string, "ID of the account")
],
responses: %{
200 => Operation.response("Account", "application/json", account()),
401 => Operation.response("Error", "application/json", ApiError),
404 => Operation.response("Error", "application/json", ApiError)
}
}
end
def account_action_operation do
%Operation{
tags: ["User administration (Mastodon API)"],
summary: "Perform an action against an account",
operationId: "MastodonAdmin.AccountController.account_action",
description:
"Perform an action against an account and log this action in the moderation history.",
security: [%{"oAuth" => ["admin:write:accounts"]}],
parameters: [
Operation.parameter(:id, :path, :string, "ID of the account")
],
requestBody:
request_body(
"Parameters",
%Schema{
type: :object,
properties: %{
type: %Schema{
type: :string,
enum: ["none", "disable"]
},
report_id: %Schema{
type: :string,
nullable: true,
description: "ID of an associated report that caused this action to be taken"
}
},
required: [:type]
},
required: true
),
responses: %{
204 => no_content_response(),
400 => Operation.response("Error", "application/json", ApiError),
401 => Operation.response("Error", "application/json", ApiError),
403 => Operation.response("Error", "application/json", ApiError),
404 => Operation.response("Error", "application/json", ApiError)
}
}
end
def delete_operation do
%Operation{
tags: ["User administration (Mastodon API)"],
summary: "Delete a specific account",
operationId: "MastodonAdmin.AccountController.delete",
description: "Delete the given account.",
security: [%{"oAuth" => ["admin:write:accounts"]}],
parameters: [
Operation.parameter(:id, :path, :string, "ID of the account")
],
responses: %{
200 => Operation.response("Account", "application/json", account()),
401 => Operation.response("Error", "application/json", ApiError),
404 => Operation.response("Error", "application/json", ApiError)
}
}
end
def enable_operation do
%Operation{
tags: ["User administration (Mastodon API)"],
summary: "Re-enable account",
operationId: "MastodonAdmin.AccountController.enable",
description: "Re-enable a local account whose login is currently disabled.",
security: [%{"oAuth" => ["admin:write:accounts"]}],
parameters: [
Operation.parameter(:id, :path, :string, "ID of the account")
],
responses: %{
200 => Operation.response("Account", "application/json", account()),
401 => Operation.response("Error", "application/json", ApiError),
404 => Operation.response("Error", "application/json", ApiError)
}
}
end
def approve_operation do
%Operation{
tags: ["User administration (Mastodon API)"],
summary: "Approve pending account",
operationId: "MastodonAdmin.AccountController.approve",
description: "Approve the given local account if it is currently pending approval.",
parameters: [
Operation.parameter(:id, :path, :string, "ID of the account")
],
responses: %{
200 => Operation.response("Account", "application/json", account()),
401 => Operation.response("Error", "application/json", ApiError),
404 => Operation.response("Error", "application/json", ApiError)
}
}
end
def reject_operation do
%Operation{
tags: ["User administration (Mastodon API)"],
summary: "Reject pending account",
operationId: "MastodonAdmin.AccountController.reject",
description: "Reject the given local account if it is currently pending approval.",
parameters: [
Operation.parameter(:id, :path, :string, "ID of the account")
],
responses: %{
200 => Operation.response("Account", "application/json", account()),
400 => Operation.response("Error", "application/json", ApiError),
401 => Operation.response("Error", "application/json", ApiError),
404 => Operation.response("Error", "application/json", ApiError)
}
}
end
def account do
%Schema{
title: "Admin::Account",
description: "Admin-level information about a given account.",
type: :object,
properties: %{
id: FlakeID,
username: %Schema{type: :string},
domain: %Schema{type: :string, nullable: true},
created_at: %Schema{type: :string, format: "date-time"},
email: %Schema{type: :string, format: "email", nullable: true},
ip: %Schema{type: :string, nullable: true},
ips: %Schema{
type: :array,
items: %Schema{
type: :object,
properties: %{
ip: %Schema{type: :string},
used_at: %Schema{type: :string, format: "date-time"}
}
}
},
locale: %Schema{type: :string, nullable: true},
invite_request: %Schema{type: :string, nullable: true},
role: %Schema{type: :string, nullable: true},
confirmed: %Schema{type: :boolean},
approved: %Schema{type: :boolean},
disabled: %Schema{type: :boolean},
silenced: %Schema{type: :boolean, nullable: true},
suspended: %Schema{type: :boolean, nullable: true},
account: Account,
created_by_application_id: %Schema{type: :string, nullable: true},
invited_by_account_id: %Schema{type: :string, nullable: true}
}
}
end
end

View file

@ -0,0 +1,180 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ApiSpec.MastodonAdmin.ReportOperation do
alias OpenApiSpex.Operation
alias OpenApiSpex.Schema
alias Pleroma.Web.ApiSpec.MastodonAdmin.AccountOperation
alias Pleroma.Web.ApiSpec.Schemas.ApiError
alias Pleroma.Web.ApiSpec.Schemas.FlakeID
alias Pleroma.Web.ApiSpec.Schemas.Status
import Pleroma.Web.ApiSpec.Helpers
def open_api_operation(action) do
operation = String.to_existing_atom("#{action}_operation")
apply(__MODULE__, operation, [])
end
def index_operation do
%Operation{
tags: ["Report management (Mastodon API)"],
summary: "View all reports",
operationId: "MastodonAdmin.ReportController.index",
description:
"View all reports. Pagination may be done with HTTP Link header in the response.",
security: [%{"oAuth" => ["admin:read:reports"]}],
parameters:
[
Operation.parameter(:resolved, :query, :boolean, "Filter for resolved reports"),
Operation.parameter(:unresolved, :query, :boolean, "Filter for open reports"),
Operation.parameter(:account_id, :query, :string, "Filter by author account id"),
Operation.parameter(
:target_account_id,
:query,
:string,
"Filter by report target account id"
)
] ++
pagination_params(),
responses: %{
200 =>
Operation.response("Reports", "application/json", %Schema{
title: "ArrayOfReports",
type: :array,
items: report()
}),
401 => Operation.response("Error", "application/json", ApiError)
}
}
end
def show_operation do
%Operation{
tags: ["Report management (Mastodon API)"],
summary: "View a single report",
operationId: "MastodonAdmin.ReportController.show",
description: "View information about the report with the given ID.",
security: [%{"oAuth" => ["admin:read:reports"]}],
parameters: [
Operation.parameter(:id, :path, :string, "ID of the report")
],
responses: %{
200 => Operation.response("Report", "application/json", report()),
401 => Operation.response("Error", "application/json", ApiError),
404 => Operation.response("Error", "application/json", ApiError)
}
}
end
def resolve_operation do
%Operation{
tags: ["Report management (Mastodon API)"],
summary: "Mark as resolved",
operationId: "MastodonAdmin.ReportController.resolve",
description: "Mark a report as resolved with no further action taken.",
security: [%{"oAuth" => ["admin:write:reports"]}],
parameters: [
Operation.parameter(:id, :path, :string, "ID of the report")
],
responses: %{
200 => Operation.response("Report", "application/json", report()),
400 => Operation.response("Error", "application/json", ApiError),
401 => Operation.response("Error", "application/json", ApiError),
404 => Operation.response("Error", "application/json", ApiError)
}
}
end
def reopen_operation do
%Operation{
tags: ["Report management (Mastodon API)"],
summary: "Re-open report",
operationId: "MastodonAdmin.ReportController.reopen",
description: "Reopen a currently closed report.",
security: [%{"oAuth" => ["admin:write:reports"]}],
parameters: [
Operation.parameter(:id, :path, :string, "ID of the report")
],
responses: %{
200 => Operation.response("Report", "application/json", report()),
400 => Operation.response("Error", "application/json", ApiError),
401 => Operation.response("Error", "application/json", ApiError),
404 => Operation.response("Error", "application/json", ApiError)
}
}
end
def assign_to_self_operation do
%Operation{
tags: ["Report management (Mastodon API)"],
summary: "Assign report to self",
operationId: "MastodonAdmin.ReportController.assign_to_self",
security: [%{"oAuth" => ["admin:write:reports"]}],
parameters: [Operation.parameter(:id, :path, :string, "ID of the report")],
responses: report_update_responses()
}
end
def unassign_operation do
%Operation{
tags: ["Report management (Mastodon API)"],
summary: "Unassign report",
operationId: "MastodonAdmin.ReportController.unassign",
security: [%{"oAuth" => ["admin:write:reports"]}],
parameters: [Operation.parameter(:id, :path, :string, "ID of the report")],
responses: report_update_responses()
}
end
defp report_update_responses do
%{
200 => Operation.response("Report", "application/json", report()),
400 => Operation.response("Error", "application/json", ApiError),
401 => Operation.response("Error", "application/json", ApiError),
404 => Operation.response("Error", "application/json", ApiError)
}
end
defp report do
%Schema{
title: "Admin::Report",
type: :object,
properties: %{
id: FlakeID,
action_taken: %Schema{type: :boolean},
action_taken_at: %Schema{type: :string, format: "date-time", nullable: true},
category: %Schema{type: :string, enum: ["spam", "legal", "violation", "other"]},
comment: %Schema{type: :string, nullable: true},
forwarded: %Schema{type: :boolean, nullable: true},
created_at: %Schema{type: :string, format: "date-time"},
updated_at: %Schema{type: :string, format: "date-time"},
account: AccountOperation.account(),
target_account: AccountOperation.account(),
assigned_account: %Schema{
oneOf: [AccountOperation.account()],
nullable: true
},
action_taken_by_account: %Schema{
oneOf: [AccountOperation.account()],
nullable: true
},
statuses: %Schema{
type: :array,
items: Status
},
rules: %Schema{
type: :array,
items: %Schema{
type: :object,
properties: %{
id: %Schema{type: :string},
text: %Schema{type: :string}
}
}
}
}
}
end
end

View file

@ -8,6 +8,7 @@ defmodule Pleroma.Web.ApiSpec.NotificationOperation do
alias Pleroma.Web.ApiSpec.Schemas.Account
alias Pleroma.Web.ApiSpec.Schemas.ApiError
alias Pleroma.Web.ApiSpec.Schemas.BooleanLike
alias Pleroma.Web.ApiSpec.Schemas.ChatMessage
alias Pleroma.Web.ApiSpec.Schemas.Status
alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope
@ -304,7 +305,25 @@ defmodule Pleroma.Web.ApiSpec.NotificationOperation do
page_max_id: %Schema{type: :string, nullable: true},
latest_page_notification_at: %Schema{type: :string, format: :"date-time", nullable: true},
sample_account_ids: %Schema{type: :array, items: %Schema{type: :string}},
status_id: %Schema{type: :string, nullable: true}
status_id: %Schema{type: :string, nullable: true},
target_id: %Schema{
type: :string,
description: "ID of the target account for a move notification"
},
emoji: %Schema{
type: :string,
description: "Emoji used for an emoji reaction notification"
},
emoji_url: %Schema{
type: :string,
nullable: true,
description: "URL of the custom emoji used for an emoji reaction notification"
},
chat_message: ChatMessage,
report: %Schema{
type: :object,
description: "Report embedded in a report notification"
}
},
required: [
:group_key,

View file

@ -356,34 +356,6 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do
}
end
def card_operation do
%Operation{
tags: ["Retrieve status information"],
deprecated: true,
summary: "Preview card",
description: "Deprecated in favor of card property inlined on Status entity",
operationId: "StatusController.card",
parameters: [id_param()],
security: [%{"oAuth" => ["read:statuses"]}],
responses: %{
200 =>
Operation.response("Card", "application/json", %Schema{
type: :object,
nullable: true,
properties: %{
type: %Schema{type: :string, enum: ["link", "photo", "video", "rich"]},
provider_name: %Schema{type: :string, nullable: true},
provider_url: %Schema{type: :string, format: :uri},
url: %Schema{type: :string, format: :uri},
image: %Schema{type: :string, nullable: true, format: :uri},
title: %Schema{type: :string},
description: %Schema{type: :string}
}
})
}
}
end
def favourited_by_operation do
%Operation{
tags: ["Retrieve status information"],

View file

@ -5,6 +5,7 @@
defmodule Pleroma.Web.ApiSpec.Schemas.ChatMessage do
alias OpenApiSpex.Schema
alias Pleroma.Web.ApiSpec.Schemas.Emoji
alias Pleroma.Web.ApiSpec.Schemas.PreviewCard
require OpenApiSpex
@ -22,36 +23,8 @@ defmodule Pleroma.Web.ApiSpec.Schemas.ChatMessage do
emojis: %Schema{type: :array, items: Emoji},
attachment: %Schema{type: :object, nullable: true},
card: %Schema{
type: :object,
nullable: true,
description: "Preview card for links included within status content",
required: [:url, :title, :description, :type],
properties: %{
type: %Schema{
type: :string,
enum: ["link", "photo", "video", "rich"],
description: "The type of the preview card"
},
provider_name: %Schema{
type: :string,
nullable: true,
description: "The provider of the original resource"
},
provider_url: %Schema{
type: :string,
format: :uri,
description: "A link to the provider of the original resource"
},
url: %Schema{type: :string, format: :uri, description: "Location of linked resource"},
image: %Schema{
type: :string,
nullable: true,
format: :uri,
description: "Preview thumbnail"
},
title: %Schema{type: :string, description: "Title of linked resource"},
description: %Schema{type: :string, description: "Description of preview"}
}
allOf: [PreviewCard],
nullable: true
},
unread: %Schema{type: :boolean, description: "Whether a message has been marked as read."}
},

View file

@ -0,0 +1,109 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ApiSpec.Schemas.PreviewCard do
alias OpenApiSpex.Schema
alias Pleroma.Web.ApiSpec.Schemas.Account
require OpenApiSpex
OpenApiSpex.schema(%{
title: "PreviewCard",
description: "Preview card for a link included within status or chat message content",
type: :object,
required: [
:url,
:title,
:description,
:language,
:type,
:authors,
:author_name,
:author_url,
:provider_name,
:provider_url,
:html,
:width,
:height,
:image,
:image_description,
:embed_url,
:blurhash,
:published_at
],
properties: %{
url: %Schema{type: :string, format: :uri, description: "Location of linked resource"},
title: %Schema{type: :string, description: "Title of linked resource"},
description: %Schema{type: :string, description: "Description of preview"},
language: %Schema{
type: :string,
nullable: true,
description: "Language of the linked resource"
},
type: %Schema{
type: :string,
enum: ["link", "photo", "video", "rich"],
description: "The type of the preview card"
},
authors: %Schema{
type: :array,
description: "Fediverse accounts of the authors of the original resource",
items: %Schema{
type: :object,
required: [:name, :url, :account],
properties: %{
name: %Schema{type: :string},
url: %Schema{type: :string},
account: %Schema{allOf: [Account], nullable: true}
}
}
},
author_name: %Schema{type: :string, description: "Author of the original resource"},
author_url: %Schema{
type: :string,
description: "Link to the author of the original resource"
},
provider_name: %Schema{
type: :string,
description: "The provider of the original resource"
},
provider_url: %Schema{
type: :string,
description: "A link to the provider of the original resource"
},
html: %Schema{
type: :string,
format: :html,
description: "HTML to be used for generating the preview card"
},
width: %Schema{type: :integer, description: "Width of preview, in pixels"},
height: %Schema{type: :integer, description: "Height of preview, in pixels"},
image: %Schema{
type: :string,
nullable: true,
format: :uri,
description: "Preview thumbnail"
},
image_description: %Schema{
type: :string,
description: "Alternate text that describes what is in the thumbnail"
},
embed_url: %Schema{
type: :string,
description: "Used for photo embeds, instead of custom `html`"
},
blurhash: %Schema{
type: :string,
nullable: true,
description:
"A hash computed by the [BlurHash algorithm](https://github.com/woltapp/blurhash), for generating colorful preview thumbnails when media has not been downloaded yet."
},
published_at: %Schema{
type: :string,
nullable: true,
description: "Publication date of the linked resource"
}
}
})
end

View file

@ -9,6 +9,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do
alias Pleroma.Web.ApiSpec.Schemas.Emoji
alias Pleroma.Web.ApiSpec.Schemas.FlakeID
alias Pleroma.Web.ApiSpec.Schemas.Poll
alias Pleroma.Web.ApiSpec.Schemas.PreviewCard
alias Pleroma.Web.ApiSpec.Schemas.Tag
alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope
@ -31,40 +32,8 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do
},
bookmarked: %Schema{type: :boolean, description: "Have you bookmarked this status?"},
card: %Schema{
type: :object,
nullable: true,
description: "Preview card for links included within status content",
required: [:url, :title, :description, :type],
properties: %{
type: %Schema{
type: :string,
enum: ["link", "photo", "video", "rich"],
description: "The type of the preview card"
},
provider_name: %Schema{
type: :string,
nullable: true,
description: "The provider of the original resource"
},
provider_url: %Schema{
type: :string,
format: :uri,
description: "A link to the provider of the original resource"
},
url: %Schema{type: :string, format: :uri, description: "Location of linked resource"},
image: %Schema{
type: :string,
nullable: true,
format: :uri,
description: "Preview thumbnail"
},
image_description: %Schema{
type: :string,
description: "Alternate text that describes what is in the thumbnail"
},
title: %Schema{type: :string, description: "Title of linked resource"},
description: %Schema{type: :string, description: "Description of preview"}
}
allOf: [PreviewCard],
nullable: true
},
content: %Schema{type: :string, format: :html, description: "HTML-encoded status content"},
text: %Schema{

View file

@ -700,7 +700,7 @@ defmodule Pleroma.Web.CommonAPI do
end
def update_report_state(activity_id, state) do
with %Activity{} = activity <- Activity.get_by_id(activity_id, filter: []) do
with %Activity{} = activity <- Activity.get_report(activity_id) do
Utils.update_report_state(activity, state)
else
nil -> {:error, :not_found}
@ -715,7 +715,7 @@ defmodule Pleroma.Web.CommonAPI do
end
def assign_report_to_account(activity_id, user) do
with %Activity{} = activity <- Activity.get_by_id(activity_id) do
with %Activity{} = activity <- Activity.get_report(activity_id) do
Utils.assign_report_to_account(activity, user)
else
nil -> {:error, :not_found}

View file

@ -0,0 +1,305 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.Admin.AccountController do
use Pleroma.Web, :controller
import Pleroma.Web.ControllerHelper,
only: [
add_link_headers: 2,
assign_account_by_id: 2,
json_response: 3
]
alias Pleroma.Activity
alias Pleroma.ModerationLog
alias Pleroma.Pagination
alias Pleroma.User
alias Pleroma.Web.ActivityPub.Builder
alias Pleroma.Web.ActivityPub.Pipeline
alias Pleroma.Web.CommonAPI
alias Pleroma.Web.Plugs.OAuthScopesPlug
@filter_params ~W(
local external active needing_approval deactivated nickname name domain email staff origin status
)
plug(Pleroma.Web.ApiSpec.CastAndValidate)
plug(
OAuthScopesPlug,
%{scopes: ["admin:read:accounts"]}
when action in [:index, :index2, :show]
)
action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
plug(
OAuthScopesPlug,
%{scopes: ["admin:write:accounts"]}
when action in [
:delete,
:enable,
:account_action,
:approve,
:reject
]
)
plug(
:assign_account_by_id
when action in [
:show,
:delete,
:enable,
:account_action,
:approve,
:reject
]
)
defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.MastodonAdmin.AccountOperation
def index(conn, params) do
users =
params
|> build_criteria()
|> User.Query.build()
|> Pagination.fetch_paginated(params)
conn
|> add_link_headers(users)
|> render("index.json", users: users)
end
def index2(conn, params) do
users =
params
|> build_criteria_v2()
|> User.Query.build()
|> Pagination.fetch_paginated(params)
conn
|> add_link_headers(users)
|> render("index.json", users: users)
end
def show(%{assigns: %{user: _admin, account: user}} = conn, _params) do
render(conn, "show.json", user: user)
end
def account_action(
%{assigns: %{user: admin, account: user}, body_params: %{type: type} = body_params} =
conn,
_params
) do
with :ok <- validate_account_action(user, type),
{:ok, report} <- associated_report(user, body_params),
:ok <- resolve_report(admin, report),
{:ok, _user} <- handle_account_action(user, admin, type) do
json_response(conn, :no_content, "")
else
{:error, :not_found} ->
{:error, :not_found}
{:error, error} ->
json_response(conn, :bad_request, %{error: error})
end
end
def delete(%{assigns: %{user: admin, account: user}} = conn, _params) do
{:ok, delete_data, _} = Builder.delete(admin, user.ap_id)
Pipeline.common_pipeline(delete_data, local: true)
ModerationLog.insert_log(%{
actor: admin,
subject: [user],
action: "delete"
})
render(conn, "show.json", user: user)
end
def enable(%{assigns: %{user: admin, account: user}} = conn, _params) do
{:ok, user} = User.set_activation(user, true)
ModerationLog.insert_log(%{
actor: admin,
subject: [user],
action: "activate"
})
render(conn, "show.json", user: user)
end
def approve(%{assigns: %{user: admin, account: user}} = conn, _params) do
{:ok, user} = User.approve(user)
ModerationLog.insert_log(%{
actor: admin,
subject: [user],
action: "approve"
})
render(conn, "show.json", user: user)
end
def reject(%{assigns: %{user: admin, account: user}} = conn, _params) do
with {:ok, _} <- User.reject(user) do
ModerationLog.insert_log(%{
actor: admin,
subject: [user],
action: "reject"
})
render(conn, "show.json", user: user)
else
{:error, error} ->
json_response(conn, :bad_request, %{error: error})
end
end
defp handle_account_action(%User{local: true} = user, admin, "disable") do
ModerationLog.insert_log(%{
actor: admin,
subject: [user],
action: "deactivate"
})
User.set_activation(user, false)
end
defp handle_account_action(%User{}, _admin, "disable") do
{:error, "Only local accounts can be disabled"}
end
defp handle_account_action(user, _admin, "none") do
{:ok, user}
end
defp validate_account_action(%User{local: true}, "disable"), do: :ok
defp validate_account_action(%User{}, "disable"),
do: {:error, "Only local accounts can be disabled"}
defp validate_account_action(%User{}, "none"), do: :ok
defp build_criteria(params) do
%{}
|> maybe_filter_local(params)
|> maybe_filter_external(params)
|> maybe_filter_active(params)
|> maybe_filter_needing_approval(params)
|> maybe_filter_deactivated(params)
|> maybe_filter_nickname(params)
|> maybe_filter_name(params)
|> maybe_filter_domain(params)
|> maybe_filter_email(params)
|> maybe_filter_staff(params)
end
defp build_criteria_v2(params) do
%{}
|> maybe_filter_origin(params)
|> maybe_filter_status(params)
|> maybe_filter_staff(params)
|> maybe_filter_nickname(params)
|> maybe_filter_name(params)
|> maybe_filter_domain(params)
|> maybe_filter_email(params)
end
defp maybe_filter_local(criteria, %{local: true} = _params),
do: Map.put(criteria, :local, true)
defp maybe_filter_local(criteria, %{local: false} = _params),
do: Map.put(criteria, :external, true)
defp maybe_filter_external(criteria, %{remote: true} = _params),
do: Map.put(criteria, :external, true)
defp maybe_filter_external(criteria, %{remote: false} = _params),
do: Map.put(criteria, :local, true)
defp maybe_filter_origin(criteria, %{origin: "local"} = _params),
do: Map.put(criteria, :local, true)
defp maybe_filter_origin(criteria, %{origin: "remote"} = _params),
do: Map.put(criteria, :external, true)
defp maybe_filter_active(criteria, %{active: active} = _params),
do: Map.put(criteria, :active, active)
defp maybe_filter_needing_approval(criteria, %{pending: need_approval} = _params),
do: Map.put(criteria, :need_approval, need_approval)
defp maybe_filter_deactivated(criteria, %{disabled: deactivated} = _params),
do: Map.put(criteria, :deactivated, deactivated)
defp maybe_filter_status(criteria, %{status: "active"} = _params),
do: Map.put(criteria, :active, true)
defp maybe_filter_status(criteria, %{status: "inactive"} = _params),
do: Map.put(criteria, :active, false)
defp maybe_filter_status(criteria, %{status: "pending"} = _params),
do: Map.put(criteria, :need_approval, true)
defp maybe_filter_status(criteria, %{status: "disabled"} = _params),
do: Map.put(criteria, :deactivated, true)
defp maybe_filter_nickname(criteria, %{username: nickname} = _params),
do: Map.put(criteria, :nickname, nickname)
defp maybe_filter_name(criteria, %{display_name: name} = _params),
do: Map.put(criteria, :name, name)
defp maybe_filter_domain(criteria, %{by_domain: domain} = _params),
do: Map.put(criteria, :domain, domain)
defp maybe_filter_email(criteria, %{email: email} = _params),
do: Map.put(criteria, :email, email)
defp maybe_filter_staff(criteria, %{permissions: "staff"} = _params),
do: Map.put(criteria, :staff, true)
defp maybe_filter_staff(criteria, %{staff: staff} = _params),
do: Map.put(criteria, :staff, staff)
for filter_param <- @filter_params do
defp unquote(:"maybe_filter_#{filter_param}")(criteria, _params), do: criteria
end
defp associated_report(user, %{report_id: id}) do
case Activity.get_report(id) do
%Activity{data: %{"object" => [target_ap_id | _]}} = report
when target_ap_id == user.ap_id ->
{:ok, report}
%Activity{} ->
{:error, "Report does not target this account"}
nil ->
{:error, :not_found}
end
end
defp associated_report(_user, _params), do: {:ok, nil}
defp resolve_report(admin, %Activity{id: id}) do
with {:ok, activity} <- CommonAPI.update_report_state(id, "resolved"),
report <- Activity.get_by_id_with_user_actor(activity.id) do
ModerationLog.insert_log(%{
action: "report_update",
actor: admin,
subject: activity,
subject_actor: report.user_actor
})
:ok
end
end
defp resolve_report(_admin, nil), do: :ok
end

View file

@ -0,0 +1,171 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.Admin.ReportController do
use Pleroma.Web, :controller
import Pleroma.Web.ControllerHelper,
only: [
add_link_headers: 2,
json_response: 3
]
alias Pleroma.Activity
alias Pleroma.ModerationLog
alias Pleroma.Pagination
alias Pleroma.User
alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.AdminAPI.Report
alias Pleroma.Web.CommonAPI
alias Pleroma.Web.Plugs.OAuthScopesPlug
plug(Pleroma.Web.ApiSpec.CastAndValidate)
plug(OAuthScopesPlug, %{scopes: ["admin:read:reports"]} when action in [:index, :show])
plug(OAuthScopesPlug, %{scopes: ["admin:write:reports"]} when action in [:resolve, :reopen])
plug(
OAuthScopesPlug,
%{scopes: ["admin:write:reports"]} when action in [:assign_to_self, :unassign]
)
action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.MastodonAdmin.ReportOperation
def index(conn, params) do
opts =
%{}
|> Map.put(:type, "Flag")
|> Map.put(:skip_preload, true)
|> Map.put(:preload_report_notes, true)
|> Map.put(:total, true)
|> restrict_state(params)
|> restrict_actor(params)
|> restrict_target(params)
reports =
ActivityPub.fetch_activities_query([], opts)
|> Pagination.fetch_paginated(params)
conn
|> add_link_headers(reports)
|> render("index.json", reports: reports)
end
def show(conn, %{id: id}) do
with %Activity{} = report <- Activity.get_report(id) do
render(conn, "show.json", Report.extract_report_info(report))
else
_ -> {:error, :not_found}
end
end
def resolve(%{assigns: %{user: admin}} = conn, %{id: id}) do
with {:ok, activity} <- CommonAPI.update_report_state(id, "resolved"),
report <- Activity.get_by_id_with_user_actor(activity.id) do
ModerationLog.insert_log(%{
action: "report_update",
actor: admin,
subject: activity,
subject_actor: report.user_actor
})
render(conn, "show.json", Report.extract_report_info(report))
else
{:error, :not_found} ->
{:error, :not_found}
{:error, error} ->
json_response(conn, :bad_request, %{error: error})
end
end
def reopen(%{assigns: %{user: admin}} = conn, %{id: id}) do
with {:ok, activity} <- CommonAPI.update_report_state(id, "open"),
report <- Activity.get_by_id_with_user_actor(activity.id) do
ModerationLog.insert_log(%{
action: "report_update",
actor: admin,
subject: activity,
subject_actor: report.user_actor
})
render(conn, "show.json", Report.extract_report_info(report))
else
{:error, :not_found} ->
{:error, :not_found}
{:error, error} ->
json_response(conn, :bad_request, %{error: error})
end
end
def assign_to_self(%{assigns: %{user: admin}} = conn, %{id: id}) do
with {:ok, activity} <- CommonAPI.assign_report_to_account(id, admin.id),
report <- Activity.get_by_id_with_user_actor(activity.id) do
ModerationLog.insert_log(%{
action: "report_assigned",
actor: admin,
subject: activity,
subject_actor: report.user_actor,
assigned_account: admin.nickname
})
render(conn, "show.json", Report.extract_report_info(report))
else
{:error, :not_found} ->
{:error, :not_found}
{:error, error} ->
json_response(conn, :bad_request, %{error: error})
end
end
def unassign(%{assigns: %{user: admin}} = conn, %{id: id}) do
with {:ok, activity} <- CommonAPI.assign_report_to_account(id, nil),
report <- Activity.get_by_id_with_user_actor(activity.id) do
ModerationLog.insert_log(%{
action: "report_unassigned",
actor: admin,
subject: activity,
subject_actor: report.user_actor
})
render(conn, "show.json", Report.extract_report_info(report))
else
{:error, :not_found} ->
{:error, :not_found}
{:error, error} ->
json_response(conn, :bad_request, %{error: error})
end
end
defp restrict_state(opts, %{resolved: true, unresolved: true}), do: opts
defp restrict_state(opts, %{resolved: true}), do: Map.put(opts, :state, "resolved")
defp restrict_state(opts, _params), do: Map.put(opts, :state, "open")
defp restrict_actor(opts, %{account_id: actor}) do
with %User{ap_id: ap_id} <- User.get_by_id(actor) do
Map.put(opts, :actor_id, ap_id)
else
_ -> Map.put(opts, :actor_id, actor)
end
end
defp restrict_actor(opts, _params), do: opts
defp restrict_target(opts, %{target_account_id: actor}) do
case User.get_by_id(actor) do
%User{ap_id: ap_id} -> Map.put(opts, :report_target_id, ap_id)
_ -> Map.put(opts, :report_target_id, actor)
end
end
defp restrict_target(opts, _params), do: opts
end

View file

@ -0,0 +1,64 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.Admin.AccountView do
use Pleroma.Web, :view
alias Pleroma.User
alias Pleroma.Web.CommonAPI.Utils
alias Pleroma.Web.MastodonAPI
def render("index.json", %{users: users}) do
render_many(users, __MODULE__, "show.json", as: :user)
end
def render("show.json", %{user: user}) do
account =
MastodonAPI.AccountView.render("show.json", %{user: user, skip_visibility_check: true})
%{
id: user.id,
username: username_from_nickname(user.nickname),
domain: domain_from_nickname(user.nickname),
created_at: Utils.to_masto_date(user.inserted_at),
email: user.email,
ip: nil,
ips: [],
locale: nil,
invite_request: user.registration_reason,
role: role(user),
confirmed: user.is_confirmed,
approved: user.is_approved,
disabled: !user.is_active,
silenced: nil,
suspended: nil,
account: account
}
end
defp username_from_nickname(string) when is_binary(string) do
hd(String.split(string, "@"))
end
defp username_from_nickname(_), do: nil
defp domain_from_nickname(string) when is_binary(string) do
String.split(string, "@")
|> Enum.at(1, nil)
end
defp domain_from_nickname(_), do: nil
defp role(%User{is_admin: true}) do
"admin"
end
defp role(%User{is_moderator: true}) do
"moderator"
end
defp role(_user) do
nil
end
end

View file

@ -0,0 +1,71 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.Admin.ReportView do
use Pleroma.Web, :view
alias Pleroma.HTML
alias Pleroma.Rule
alias Pleroma.Web.AdminAPI.Report
alias Pleroma.Web.CommonAPI.Utils
alias Pleroma.Web.MastodonAPI.Admin.AccountView
alias Pleroma.Web.MastodonAPI.StatusView
def render("index.json", %{reports: reports}) do
reports
|> Enum.map(&Report.extract_report_info/1)
|> Enum.map(&render(__MODULE__, "show.json", &1))
end
def render("show.json", %{
report: report,
user: account,
account: target_account,
statuses: statuses,
assigned_account: assigned_account
}) do
created_at = Utils.to_masto_date(report.data["published"])
updated_at = Utils.to_masto_date(report.updated_at)
action_taken = report.data["state"] != "open"
content =
unless is_nil(report.data["content"]) do
HTML.filter_tags(report.data["content"])
else
nil
end
%{
id: report.id,
action_taken: action_taken,
action_taken_at: if(action_taken, do: updated_at),
category: "other",
comment: content,
forwarded: false,
created_at: created_at,
updated_at: updated_at,
account: AccountView.render("show.json", %{user: account}),
target_account: AccountView.render("show.json", %{user: target_account}),
assigned_account: render_account(assigned_account),
action_taken_by_account: nil,
statuses:
StatusView.render("index.json", %{
activities: statuses,
as: :activity
}),
rules: rules(report.data["rules"])
}
end
defp render_account(nil), do: nil
defp render_account(account), do: AccountView.render("show.json", %{user: account})
defp rules(nil), do: []
defp rules(rule_ids) do
rule_ids
|> Rule.get()
|> Enum.map(&%{id: to_string(&1.id), text: &1.text})
end
end

View file

@ -348,6 +348,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do
params
|> Map.delete(:tagged)
|> Map.put(:tag, params[:tagged])
|> Map.put(:restrict_unauthenticated, true)
activities = ActivityPub.fetch_user_activities(user, reading_user, params)

View file

@ -101,13 +101,13 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do
|> Enum.filter(& &1)
|> Enum.uniq_by(& &1[:id])
actors =
accounts =
notification_groups
|> List.flatten()
|> notification_actors()
|> notification_accounts()
%{
accounts: AccountView.render("index.json", %{users: actors, for: reading_user}),
accounts: AccountView.render("index.json", %{users: accounts, for: reading_user}),
statuses: statuses,
notification_groups:
Enum.map(
@ -191,7 +191,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do
defp render_group(
[%Notification{} = notification | _] = notifications,
_reading_user,
reading_user,
grouped_types,
notification_group_counts,
notification_group_bounds,
@ -237,13 +237,49 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do
response
end
if status_activity do
Map.put(response, :status_id, to_string(status_activity.id))
else
response
response =
if status_activity do
Map.put(response, :status_id, to_string(status_activity.id))
else
response
end
put_group_details(response, notification, reading_user)
end
defp put_group_details(response, %Notification{type: "move", activity: activity}, _reading_user) do
case User.get_cached_by_ap_id(activity.data["target"]) do
%User{id: id} -> Map.put(response, :target_id, to_string(id))
_ -> response
end
end
defp put_group_details(
response,
%Notification{type: "pleroma:emoji_reaction", activity: activity},
_reading_user
) do
put_emoji(response, activity)
end
defp put_group_details(
response,
%Notification{type: "pleroma:chat_mention", activity: activity},
reading_user
) do
put_chat_message(response, activity, reading_user, %{})
end
defp put_group_details(
response,
%Notification{type: "pleroma:report", activity: activity},
_reading_user
) do
put_report(response, activity)
end
defp put_group_details(response, _notification, _reading_user), do: response
defp status_activity_for_group([%Notification{} = notification | _], grouped_types) do
status_activity_for(notification, grouped_types)
end
@ -287,6 +323,19 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do
|> Enum.uniq_by(& &1.id)
end
defp notification_accounts(notifications) do
move_targets =
notifications
|> Enum.filter(&(&1.type == "move"))
|> Enum.map(&User.get_cached_by_ap_id(&1.activity.data["target"]))
|> Enum.filter(& &1)
notifications
|> notification_actors()
|> Kernel.++(move_targets)
|> Enum.uniq_by(& &1.id)
end
defp put_emoji(response, activity) do
response
|> Map.put(:emoji, activity.data["content"])

View file

@ -587,14 +587,25 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do
video_url = proxied_url(rich_media["video"], page_url_data)
%{
type: "link",
provider_name: page_url_data.host,
provider_url: page_url_data.scheme <> "://" <> page_url_data.host,
type: card_type(rich_media["type"]),
provider_name: rich_media["provider_name"] || page_url_data.host,
provider_url:
rich_media["provider_url"] || page_url_data.scheme <> "://" <> page_url_data.host,
url: page_url,
image: image_url,
image_description: rich_media["image:alt"] || "",
title: rich_media["title"] || "",
description: rich_media["description"] || "",
language: nil,
authors: [],
author_name: rich_media["author_name"] || "",
author_url: rich_media["author_url"] || "",
html: rich_media["html"] || "",
width: card_dimension(rich_media["width"]),
height: card_dimension(rich_media["height"]),
embed_url: rich_media["embed_url"] || "",
blurhash: nil,
published_at: nil,
pleroma: %{
opengraph:
rich_media
@ -620,7 +631,6 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do
String.contains?(media_type, "image") -> "image"
String.contains?(media_type, "video") -> "video"
String.contains?(media_type, "audio") -> "audio"
attachment["type"] == "Image" -> "image"
true -> "unknown"
end
@ -699,6 +709,20 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do
%{content: content, detected_source_language: detected_source_language, provider: provider}
end
defp card_type(type) when type in ["link", "photo", "video", "rich"], do: type
defp card_type(_), do: "link"
defp card_dimension(value) when is_integer(value) and value >= 0, do: value
defp card_dimension(value) when is_binary(value) do
case Integer.parse(value) do
{dimension, ""} when dimension >= 0 -> dimension
_ -> 0
end
end
defp card_dimension(_), do: 0
def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
object = Object.normalize(activity, fetch: false)

View file

@ -208,7 +208,6 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do
defp media_proxy_opts do
Config.get([:media_proxy, :proxy_opts], [])
|> Keyword.put_new(:sniff_content_type, true)
end
defp sandbox(conn, _params) do

View file

@ -8,6 +8,7 @@ defmodule Pleroma.Web.OAuth.App do
import Ecto.Query
alias Pleroma.Repo
alias Pleroma.User
alias Pleroma.Web.OAuth.Authorization
alias Pleroma.Web.OAuth.Token
@type t :: %__MODULE__{}
@ -191,15 +192,26 @@ defmodule Pleroma.Web.OAuth.App do
@spec remove_orphans(pos_integer()) :: :ok
def remove_orphans(limit \\ 100) do
fifteen_mins_ago = DateTime.add(DateTime.utc_now(), -900, :second)
fifteen_mins_ago = NaiveDateTime.add(NaiveDateTime.utc_now(), -900)
Repo.transaction(fn ->
app_ids =
from(a in __MODULE__,
where: is_nil(a.user_id) and a.inserted_at < ^fifteen_mins_ago,
limit: ^limit,
lock: "FOR UPDATE SKIP LOCKED",
select: a.id
)
|> Repo.all()
# Bulk deletes bypass the schema's association cleanup.
from(a in Authorization, where: a.app_id in ^app_ids) |> Repo.delete_all()
from(t in Token, where: t.app_id in ^app_ids) |> Repo.delete_all()
from(a in __MODULE__,
where: is_nil(a.user_id) and a.inserted_at < ^fifteen_mins_ago,
limit: ^limit
where: a.id in ^app_ids and is_nil(a.user_id) and a.inserted_at < ^fifteen_mins_ago
)
|> Repo.all()
|> Enum.each(&Repo.delete(&1))
|> Repo.delete_all()
end)
:ok

View file

@ -6,6 +6,8 @@ defmodule Pleroma.Web.Plugs.IdempotencyPlug do
import Phoenix.Controller, only: [json: 2]
import Plug.Conn
alias Pleroma.Config
@behaviour Plug
@cachex Pleroma.Config.get([:cachex, :provider], Cachex)
@ -27,9 +29,11 @@ defmodule Pleroma.Web.Plugs.IdempotencyPlug do
def call(conn, _), do: conn
def process_request(conn, key) do
case @cachex.get(:idempotency_cache, key) do
cache_key = {conn.method, conn.request_path, actor_key(conn), key}
case @cachex.get(:idempotency_cache, cache_key) do
{:ok, nil} ->
cache_resposnse(conn, key)
cache_resposnse(conn, key, cache_key)
{:ok, record} ->
send_cached(conn, key, record)
@ -39,18 +43,48 @@ defmodule Pleroma.Web.Plugs.IdempotencyPlug do
end
end
defp cache_resposnse(conn, key) do
register_before_send(conn, fn conn ->
[request_id] = get_resp_header(conn, "x-request-id")
content_type = get_content_type(conn)
defp cache_resposnse(conn, key, cache_key) do
register_before_send(conn, &maybe_cache_response(&1, key, cache_key))
end
record = {request_id, content_type, conn.status, conn.resp_body}
{:ok, _} = @cachex.put(:idempotency_cache, key, record)
defp maybe_cache_response(
%{private: %{skip_idempotency_cache: true}} = conn,
_key,
_cache_key
),
do: conn
conn
|> put_resp_header("idempotency-key", key)
|> put_resp_header("x-original-request-id", request_id)
end)
defp maybe_cache_response(conn, key, cache_key) do
[request_id] = get_resp_header(conn, "x-request-id")
content_type = get_content_type(conn)
record = {request_id, content_type, conn.status, conn.resp_body}
{:ok, _} = @cachex.put(:idempotency_cache, cache_key, record)
conn
|> put_resp_header("idempotency-key", key)
|> put_resp_header("x-original-request-id", request_id)
end
defp actor_key(%{assigns: assigns}) do
{principal_key(assigns), token_key(assigns[:token]), staff_privileges()}
end
defp principal_key(%{user: %{id: id} = user}) do
{:user, id, Map.get(user, :is_admin), Map.get(user, :is_moderator)}
end
defp principal_key(%{app: %{id: id}}), do: {:app, id}
defp principal_key(_assigns), do: nil
defp token_key(%{id: id} = token), do: {id, token |> Map.get(:scopes, []) |> Enum.sort()}
defp token_key(_token), do: nil
defp staff_privileges do
{
Config.get([:instance, :admin_privileges], []) |> Enum.sort(),
Config.get([:instance, :moderator_privileges], []) |> Enum.sort()
}
end
defp send_cached(conn, key, record) do

View file

@ -137,6 +137,12 @@ defmodule Pleroma.Web.Router do
plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :users_manage_activation_state)
end
pipeline :require_privileged_role_account_actions do
plug(:admin_api)
plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :users_manage_activation_state)
plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :reports_manage_reports)
end
pipeline :require_privileged_role_users_manage_invites do
plug(:admin_api)
plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :users_manage_invites)
@ -316,6 +322,15 @@ defmodule Pleroma.Web.Router do
post("/rules", RuleController, :create)
patch("/rules/:id", RuleController, :update)
delete("/rules/:id", RuleController, :delete)
get("/webhooks", WebhookController, :index)
get("/webhooks/:id", WebhookController, :show)
post("/webhooks", WebhookController, :create)
patch("/webhooks/:id", WebhookController, :update)
delete("/webhooks/:id", WebhookController, :delete)
post("/webhooks/:id/enable", WebhookController, :enable)
post("/webhooks/:id/disable", WebhookController, :disable)
post("/webhooks/:id/rotate_secret", WebhookController, :rotate_secret)
end
# AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
@ -424,6 +439,60 @@ defmodule Pleroma.Web.Router do
delete("/chats/:id/messages/:message_id", ChatController, :delete_message)
end
# Mastodon AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v1/admin", Pleroma.Web.MastodonAPI.Admin do
pipe_through([:require_privileged_role_users_read])
get("/accounts", AccountController, :index)
get("/accounts/:id", AccountController, :show)
end
# Mastodon AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v1/admin", Pleroma.Web.MastodonAPI.Admin do
pipe_through(:require_privileged_role_users_delete)
delete("/accounts/:id", AccountController, :delete)
end
# Mastodon AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v1/admin", Pleroma.Web.MastodonAPI.Admin do
pipe_through([:require_privileged_role_users_manage_activation_state])
post("/accounts/:id/enable", AccountController, :enable)
end
scope "/api/v1/admin", Pleroma.Web.MastodonAPI.Admin do
pipe_through([:require_privileged_role_account_actions])
post("/accounts/:id/action", AccountController, :account_action)
end
# Mastodon AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v1/admin", Pleroma.Web.MastodonAPI.Admin do
pipe_through([:require_privileged_role_users_manage_invites])
post("/accounts/:id/approve", AccountController, :approve)
post("/accounts/:id/reject", AccountController, :reject)
end
# Mastodon AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v1/admin", Pleroma.Web.MastodonAPI.Admin do
pipe_through([:require_privileged_role_reports_manage_reports])
get("/reports", ReportController, :index)
get("/reports/:id", ReportController, :show)
post("/reports/:id/resolve", ReportController, :resolve)
post("/reports/:id/reopen", ReportController, :reopen)
post("/reports/:id/assign_to_self", ReportController, :assign_to_self)
post("/reports/:id/unassign", ReportController, :unassign)
end
# Mastodon AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v2/admin", Pleroma.Web.MastodonAPI.Admin do
pipe_through([:require_privileged_role_users_read])
get("/accounts", AccountController, :index2)
end
# AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v1/pleroma/admin", Pleroma.Web.AdminAPI do
pipe_through(:require_privileged_role_emoji_manage_emoji)

98
lib/pleroma/webhook.ex Normal file
View file

@ -0,0 +1,98 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Webhook do
use Ecto.Schema
import Ecto.Changeset
import Ecto.Query
alias Pleroma.EctoType.ActivityPub.ObjectValidators
alias Pleroma.Repo
@event_types [:"account.created", :"report.created"]
schema "webhooks" do
field(:url, ObjectValidators.Uri)
field(:events, {:array, Ecto.Enum}, values: @event_types, default: [])
field(:secret, :string, default: "")
field(:enabled, :boolean, default: true)
field(:internal, :boolean, default: false)
timestamps()
end
def get(id), do: Repo.get(__MODULE__, id)
def get_by_type(type) do
__MODULE__
|> where([w], ^type in w.events)
|> where([w], w.enabled == true)
|> Repo.all()
end
def changeset(%__MODULE__{} = webhook, params) do
webhook
|> cast(params, [:url, :events, :enabled, :internal])
|> validate_required([:url, :events])
|> validate_length(:url, max: 255)
|> validate_events_present()
|> unique_constraint(:url)
|> put_secret()
end
def update_changeset(%__MODULE__{} = webhook, params \\ %{}) do
webhook
|> cast(params, [:url, :events, :enabled, :internal])
|> validate_length(:url, max: 255)
|> validate_events_present()
|> unique_constraint(:url)
end
def create(params) do
%__MODULE__{}
|> changeset(params)
|> Repo.insert()
end
def update(%__MODULE__{} = webhook, params) do
webhook
|> update_changeset(params)
|> Repo.update()
end
def delete(webhook), do: webhook |> Repo.delete()
def rotate_secret(%__MODULE__{} = webhook) do
webhook
|> cast(%{}, [])
|> put_secret()
|> Repo.update()
end
def set_enabled(%__MODULE__{} = webhook, enabled) do
webhook
|> cast(%{enabled: enabled}, [:enabled])
|> Repo.update()
end
defp validate_events_present(changeset) do
cond do
Keyword.has_key?(changeset.errors, :events) -> changeset
get_field(changeset, :internal) -> changeset
match?([_ | _], get_field(changeset, :events)) -> changeset
true -> add_error(changeset, :events, "can't be blank")
end
end
defp put_secret(changeset) do
changeset
|> put_change(:secret, generate_secret())
end
defp generate_secret do
Base.encode16(:crypto.strong_rand_bytes(20))
|> String.downcase()
end
end

View file

@ -0,0 +1,94 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Webhook.Notify do
alias Phoenix.View
alias Pleroma.Activity
alias Pleroma.User
alias Pleroma.Web.AdminAPI.Report
alias Pleroma.Webhook
alias Pleroma.Workers.WebhookWorker
def trigger_webhooks(%Activity{} = activity, :"report.created" = type) do
webhooks = Webhook.get_by_type(type)
webhooks
|> Enum.map(fn webhook ->
WebhookWorker.new(%{
"op" => "notify",
"type" => type,
"webhook_id" => webhook.id,
"activity_id" => activity.id
})
end)
|> Oban.insert_all()
end
def trigger_webhooks(%User{} = user, :"account.created" = type) do
webhooks = Webhook.get_by_type(type)
webhooks
|> Enum.map(fn webhook ->
WebhookWorker.new(%{
"op" => "notify",
"type" => type,
"webhook_id" => webhook.id,
"user_id" => user.id
})
end)
|> Oban.insert_all()
end
def report_created(%Webhook{} = webhook, %Activity{} = report) do
object =
View.render(
Pleroma.Web.MastodonAPI.Admin.ReportView,
"show.json",
Report.extract_report_info(report)
)
deliver(webhook, object, :"report.created")
end
def account_created(%Webhook{} = webhook, %User{} = user) do
object =
View.render(
Pleroma.Web.MastodonAPI.Admin.AccountView,
"show.json",
user: user
)
deliver(webhook, object, :"account.created")
end
defp deliver(%Webhook{url: url, secret: secret}, object, type) do
body =
View.render_to_string(Pleroma.Web.AdminAPI.WebhookView, "event.json",
type: type,
object: object
)
headers = [
{"Content-Type", "application/json"},
{"X-Hub-Signature", "sha256=#{signature(body, secret)}"}
]
ConcurrentLimiter.limit(__MODULE__, fn ->
case Pleroma.HTTP.post(url, body, headers) do
{:ok, %Tesla.Env{status: status}} when status in 200..299 ->
:ok
{:ok, %Tesla.Env{status: status}} ->
{:error, {:http_status, status}}
{:error, _reason} = error ->
error
end
end)
end
defp signature(body, secret) do
:crypto.mac(:hmac, :sha256, secret, body) |> Base.encode16(case: :lower)
end
end

View file

@ -0,0 +1,50 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Workers.WebhookWorker do
alias Pleroma.Activity
alias Pleroma.User
alias Pleroma.Webhook
alias Pleroma.Webhook.Notify
use Oban.Worker, queue: :background
@impl true
def perform(%Job{
args: %{
"op" => "notify",
"type" => "report.created",
"webhook_id" => webhook_id,
"activity_id" => report_id
}
}) do
with %Webhook{enabled: true} = webhook <- Webhook.get(webhook_id),
%Activity{} = report <- Activity.get_by_id(report_id) do
Notify.report_created(webhook, report)
else
%Webhook{enabled: false} -> {:cancel, :disabled}
nil -> {:cancel, :not_found}
end
end
def perform(%Job{
args: %{
"op" => "notify",
"type" => "account.created",
"webhook_id" => webhook_id,
"user_id" => user_id
}
}) do
with %Webhook{enabled: true} = webhook <- Webhook.get(webhook_id),
%User{} = user <- User.get_by_id(user_id) do
Notify.account_created(webhook, user)
else
%Webhook{enabled: false} -> {:cancel, :disabled}
nil -> {:cancel, :not_found}
end
end
@impl true
def timeout(_job), do: :timer.seconds(10)
end

BIN
logo-with-name.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

View file

@ -174,6 +174,14 @@ defmodule Pleroma.Mixfile do
{:gen_smtp, "~> 0.15"},
{:mua, "~> 0.2.4"},
{:mail, "~> 0.3.1"},
# Fixes intermittent failure to compile due to an old pc version,
# upstream hasn't made a release with the fix yet.
# https://github.com/Vagabond/erlang-syslog/pull/44
{:syslog,
git: "https://github.com/Vagabond/erlang-syslog",
ref: "614a1fde7a8654bee2815b599bbdaae1bb0323b5",
override: true,
runtime: false},
{:ex_syslogger, "~> 1.5"},
{:floki, "~> 0.38"},
{:timex, "~> 3.7"},

View file

@ -139,7 +139,7 @@
"statistex": {:hex, :statistex, "1.1.0", "7fec1eb2f580a0d2c1a05ed27396a084ab064a40cfc84246dbfb0c72a5c761e5", [:mix], [], "hexpm", "f5950ea26ad43246ba2cce54324ac394a4e7408fdcf98b8e230f503a0cba9cf5"},
"sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"},
"swoosh": {:hex, :swoosh, "1.16.12", "cbb24ad512f2f7f24c7a469661c188a00a8c2cd64e0ab54acd1520f132092dfd", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "0e262df1ae510d59eeaaa3db42189a2aa1b3746f73771eb2616fc3f7ee63cc20"},
"syslog": {:hex, :syslog, "1.1.0", "6419a232bea84f07b56dc575225007ffe34d9fdc91abe6f1b2f254fd71d8efc2", [:rebar3], [], "hexpm", "4c6a41373c7e20587be33ef841d3de6f3beba08519809329ecc4d27b15b659e1"},
"syslog": {:git, "https://github.com/Vagabond/erlang-syslog", "614a1fde7a8654bee2815b599bbdaae1bb0323b5", [ref: "614a1fde7a8654bee2815b599bbdaae1bb0323b5"]},
"table_rex": {:hex, :table_rex, "4.1.0", "fbaa8b1ce154c9772012bf445bfb86b587430fb96f3b12022d3f35ee4a68c918", [:mix], [], "hexpm", "95932701df195d43bc2d1c6531178fc8338aa8f38c80f098504d529c43bc2601"},
"telemetry": {:hex, :telemetry, "1.0.0", "0f453a102cdf13d506b7c0ab158324c337c41f1cc7548f0bc0e130bbf0ae9452", [:rebar3], [], "hexpm", "73bc09fa59b4a0284efb4624335583c528e07ec9ae76aca96ea0673850aec57a"},
"telemetry_metrics": {:hex, :telemetry_metrics, "0.6.2", "2caabe9344ec17eafe5403304771c3539f3b6e2f7fb6a6f602558c825d0d0bfb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9b43db0dc33863930b9ef9d27137e78974756f5f198cae18409970ed6fa5b561"},

View file

@ -0,0 +1,20 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Repo.Migrations.CreateWebhooks do
use Ecto.Migration
def change do
create_if_not_exists table(:webhooks) do
add(:url, :string, null: false)
add(:events, {:array, :string}, null: false, default: [])
add(:secret, :string, null: false, default: "")
add(:enabled, :boolean, null: false, default: true)
timestamps()
end
create_if_not_exists(unique_index(:webhooks, [:url]))
end
end

View file

@ -0,0 +1,13 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Repo.Migrations.AddInternalToWebhooks do
use Ecto.Migration
def change do
alter table(:webhooks) do
add(:internal, :boolean, default: false, null: false)
end
end
end

View file

@ -0,0 +1,14 @@
# Adapted from Rebased
# https://gitlab.com/soapbox-pub/rebased/-/blob/main/priv/repo/migrations/20210612185407_add_email_list_field_to_users.exs
defmodule Pleroma.Repo.Migrations.AddEmailListFieldToUsers do
use Ecto.Migration
def up, do: :noop
def down do
alter table(:users) do
remove_if_exists(:accepts_email_list, :boolean)
end
end
end

View file

@ -0,0 +1,11 @@
defmodule Pleroma.Repo.Migrations.AddLocationToUsers do
use Ecto.Migration
def up, do: :noop
def down do
alter table(:users) do
remove_if_exists(:location, :string)
end
end
end

View file

@ -0,0 +1,45 @@
defmodule Pleroma.Repo.Migrations.AddPleromaParticipationAcceptedToNotificationsEnum do
use Ecto.Migration
def up, do: :noop
def down do
alter table(:notifications) do
modify(:type, :string)
end
"""
delete from notifications where type in ('pleroma:participation_accepted', 'pleroma:participation_request', 'pleroma:event_reminder', 'pleroma:event_update')
"""
|> execute()
"""
drop type if exists notification_type
"""
|> execute()
"""
create type notification_type as enum (
'follow',
'follow_request',
'mention',
'move',
'pleroma:emoji_reaction',
'pleroma:chat_mention',
'reblog',
'favourite',
'pleroma:report',
'poll',
'status',
'update'
)
"""
|> execute()
"""
alter table notifications
alter column type type notification_type using (type::notification_type)
"""
|> execute()
end
end

View file

@ -0,0 +1,11 @@
defmodule Pleroma.Repo.Migrations.AddLastMoveAtToUsers do
use Ecto.Migration
def up, do: :noop
def down do
alter table(:users) do
remove_if_exists(:last_move_at, :naive_datetime)
end
end
end

View file

@ -0,0 +1,17 @@
# Adapted from Rebased
# https://gitlab.com/soapbox-pub/rebased/-/blob/main/priv/repo/migrations/20221207235104_add_events_index.exs
defmodule Pleroma.Repo.Migrations.AddEventsIndex do
use Ecto.Migration
def up, do: :noop
def down do
drop_if_exists(
index(:objects, ["(data->>'type')"],
where: "data->>'type' = 'Event'",
name: :objects_events
)
)
end
end

View file

@ -17,6 +17,13 @@ retry="SIGTERM/30/SIGKILL/5"
pidfile="/var/run/pleroma.pid"
depend() {
want nginx
need postgresql
want nginx
need postgresql
}
start_pre() {
ebegin "running migrations if any"
su -s /bin/sh -c "cd \"${directory}\" && exec /opt/pleroma/bin/pleroma_ctl migrate" \
"${command_user%%:*}"
eend "$?"
}

View file

@ -0,0 +1,5 @@
import Config
config :pleroma, Pleroma.Search, module: Pleroma.Search.ParadeDB
config :pleroma, Pleroma.Search.ParadeDB, url: "postgres://localhost/paradedb"
config :pleroma, Pleroma.Search.ParadeDB.Repo, pool_size: 2

View file

@ -0,0 +1,32 @@
{
"@context": [
"https://www.w3.org/ns/activitystreams"
],
"type": "Create",
"id": "https://fedidev.fun/activity/attachment-embedded-note",
"actor": "https://fedidev.fun/users/alice",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [],
"published": "2025-11-21T14:43:01Z",
"object": {
"type": "Note",
"id": "https://fedidev.fun/objects/note-attachment-embedded-note",
"attributedTo": "https://fedidev.fun/users/alice",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [],
"published": "2025-11-21T14:43:01Z",
"content": "Note with embedded Note attachment",
"attachment": {
"type": "Note",
"attributedTo": "https://fedidev.fun/users/alice",
"name": "attached note",
"content": "This is just a note",
"published": "2024-03-06T07:23:56Z"
}
}
}

View file

@ -0,0 +1,31 @@
{
"@context": [
"https://www.w3.org/ns/activitystreams"
],
"type": "Create",
"id": "https://fedidev.fun/activity/attachment-payto-link",
"actor": "https://fedidev.fun/users/alice",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [],
"published": "2025-11-21T14:43:00Z",
"object": {
"type": "Note",
"id": "https://fedidev.fun/objects/note-attachment-payto-link",
"attributedTo": "https://fedidev.fun/users/alice",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [],
"published": "2025-11-21T14:43:00Z",
"content": "Payment Link",
"attachment": {
"type": "Link",
"name": "Donate",
"href": "payto://iban/DE75512108001245126199",
"rel": "payment"
}
}
}

View file

@ -0,0 +1,40 @@
{
"@context": [
"https://www.w3.org/ns/activitystreams"
],
"type": "Create",
"id": "https://fedidev.fun/activity/attachment-url-list",
"actor": "https://fedidev.fun/users/alice",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [],
"published": "2025-11-21T14:43:02Z",
"object": {
"type": "Note",
"id": "https://fedidev.fun/objects/note-attachment-url-list",
"attributedTo": "https://fedidev.fun/users/alice",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [],
"published": "2025-11-21T14:43:02Z",
"content": "url is array of two Link objects",
"attachment": {
"type": "Image",
"url": [
{
"type": "Link",
"href": "https://fedidev.fun/images/007.png",
"mediaType": "image/png"
},
{
"type": "Link",
"href": "https://fedidev.fun/images/008.jpg",
"mediaType": "image/jpeg"
}
]
}
}
}

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