Skip to content

Commit 757bb93

Browse files
authored
Merge pull request #46 from RSS-Bridge/master
[Mastodon] Use ActivityPub outbox for Mastodon (et al.) feed (RSS-Bridge#2756)
2 parents 53126a7 + e9b8a1f commit 757bb93

File tree

2 files changed

+209
-46
lines changed

2 files changed

+209
-46
lines changed

bridges/MastodonBridge.php

+152-46
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,33 @@
11
<?php
22

3-
class MastodonBridge extends FeedExpander {
3+
class MastodonBridge extends BridgeAbstract {
4+
// This script attempts to imitiate the behaviour of a read-only ActivityPub server
5+
// to read the outbox.
46

5-
const MAINTAINER = 'husim0';
6-
const NAME = 'Mastodon Bridge';
7+
// Note: Most PixelFed instances have ActivityPub outbox disabled,
8+
// so use the official feed: https://pixelfed.instance/users/username.atom (Posts only)
9+
10+
const MAINTAINER = 'Austin Huang';
11+
const NAME = 'ActivityPub Bridge';
712
const CACHE_TIMEOUT = 900; // 15mn
8-
const DESCRIPTION = 'Returns toots';
13+
const DESCRIPTION = 'Returns recent statuses. Supports Mastodon, Pleroma and Misskey, among others. Access to
14+
instances that have Authorized Fetch enabled requires
15+
<a href="https://rss-bridge.github.io/rss-bridge/Bridge_Specific/ActivityPub_(Mastodon).html">configuration</a>.';
916
const URI = 'https://mastodon.social';
1017

18+
// Some Mastodon instances use Secure Mode which requires all requests to be signed.
19+
// You do not need this for most instances, but if you want to support every known
20+
// instance, then you should configure them.
21+
// See also https://docs.joinmastodon.org/spec/security/#http
22+
const CONFIGURATION = array(
23+
'private_key' => array(
24+
'required' => false,
25+
),
26+
'key_id' => array(
27+
'required' => false,
28+
),
29+
);
30+
1131
const PARAMETERS = array(array(
1232
'canusername' => array(
1333
'name' => 'Canonical username',
@@ -17,74 +37,160 @@ class MastodonBridge extends FeedExpander {
1737
'norep' => array(
1838
'name' => 'Without replies',
1939
'type' => 'checkbox',
20-
'title' => 'Only return initial toots'
40+
'title' => 'Only return statuses that are not replies, as determined by relations (not mentions).'
2141
),
2242
'noboost' => array(
2343
'name' => 'Without boosts',
2444
'required' => false,
2545
'type' => 'checkbox',
26-
'title' => 'Hide boosts'
46+
'title' => 'Hide boosts. Note that RSS-Bridge will fetch the original status from other federated instances.'
2747
)
2848
));
2949

30-
public function getName(){
31-
switch($this->queriedContext) {
32-
case 'By username':
50+
public function getName() {
51+
if($this->getInput('canusername')) {
3352
return $this->getInput('canusername');
34-
default: return parent::getName();
3553
}
54+
return parent::getName();
3655
}
3756

38-
protected function parseItem($newItem){
39-
$item = parent::parseItem($newItem);
40-
41-
$content = str_get_html($item['content']);
42-
$title = str_get_html($item['title']);
57+
private function getInstance() {
58+
preg_match('/^@[a-zA-Z0-9_]+@(.+)/', $this->getInput('canusername'), $matches);
59+
return $matches[1];
60+
}
4361

44-
$item['title'] = $content->plaintext;
62+
private function getUsername() {
63+
preg_match('/^@([a-zA-Z_0-9_]+)@.+/', $this->getInput('canusername'), $matches);
64+
return $matches[1];
65+
}
4566

46-
if(strlen($item['title']) > 75) {
47-
$item['title'] = substr($item['title'], 0, strpos(wordwrap($item['title'], 75), "\n")) . '...';
67+
public function getURI(){
68+
if($this->getInput('canusername')) {
69+
// We parse webfinger to make sure the URL is correct. This is mostly because
70+
// MissKey uses user ID instead of the username in the endpoint, domain delegations,
71+
// and also to be compatible with future ActivityPub implementations.
72+
$resource = 'acct:' . $this->getUsername() . '@' . $this->getInstance();
73+
$webfingerUrl = 'https://' . $this->getInstance() . '/.well-known/webfinger?resource=' . $resource;
74+
$webfingerHeader = array(
75+
'Content-Type: application/jrd+json'
76+
);
77+
$webfinger = json_decode(getContents($webfingerUrl, $webfingerHeader), true);
78+
foreach ($webfinger['links'] as $link) {
79+
if ($link['type'] === 'application/activity+json') {
80+
return $link['href'];
81+
}
82+
}
4883
}
4984

50-
if(strpos($title, 'shared a status by') !== false) {
51-
if($this->getInput('noboost')) {
52-
return null;
53-
}
85+
return parent::getURI();
86+
}
5487

55-
preg_match('/shared a status by (\S{0,})/', $title, $matches);
56-
$item['title'] = 'Boost ' . $matches[1] . ' ' . $item['title'];
57-
$item['author'] = $matches[1];
88+
public function collectData() {
89+
$url = $this->getURI() . '/outbox?page=true';
90+
$content = $this->fetchAP($url);
91+
if ($content['id'] === $url) {
92+
foreach ($content['orderedItems'] as $status) {
93+
$this->items[] = $this->parseItem($status);
94+
}
5895
} else {
59-
$item['author'] = $this->getInput('canusername');
96+
throw new \Exception('Unexpected response from server.');
6097
}
98+
}
6199

62-
// Check if it's a initial toot or a response
63-
if($this->getInput('norep') && preg_match('/^@.+/', trim($content->plaintext))) {
64-
return null;
100+
protected function parseItem($content) {
101+
$item = array();
102+
switch ($content['type']) {
103+
case 'Announce': // boost
104+
if ($this->getInput('noboost')) {
105+
return null;
106+
}
107+
// We fetch the boosted content.
108+
try {
109+
$rtContent = $this->fetchAP($content['object']);
110+
$rtUser = $this->loadCacheValue($rtContent['attributedTo'], 86400);
111+
if (!isset($rtUser)) {
112+
// We fetch the author, since we cannot always assume the format of the URL.
113+
$user = $this->fetchAP($rtContent['attributedTo']);
114+
preg_match('/https?:\/\/([a-z0-9-\.]{0,})\//', $rtContent['attributedTo'], $matches);
115+
// We assume that the server name as indicated by the path is the actual server name,
116+
// since using webfinger to delegate domains is not officially supported, and it only
117+
// seems to work in one way.
118+
$rtUser = '@' . $user['preferredUsername'] . '@' . $matches[1];
119+
$this->saveCacheValue($rtContent['attributedTo'], $rtUser);
120+
}
121+
$item['author'] = $rtUser;
122+
$item['title'] = 'Shared a status by ' . $rtUser . ': ';
123+
$item = $this->parseObject($rtContent, $item);
124+
} catch (UnexpectedResponseException $th) {
125+
$item['title'] = 'Shared an unreachable status: ' . $content['object'];
126+
$item['content'] = $content['object'];
127+
$item['uri'] = $content['object'];
128+
}
129+
break;
130+
case 'Create': // posts
131+
if ($this->getInput('norep') && isset($content['object']['inReplyTo'])) {
132+
return null;
133+
}
134+
$item['author'] = $this->getInput('canusername');
135+
$item['title'] = '';
136+
$item = $this->parseObject($content['object'], $item);
65137
}
66-
138+
$item['timestamp'] = $content['published'];
139+
$item['uid'] = $content['id'];
67140
return $item;
68141
}
69142

70-
private function getInstance(){
71-
preg_match('/^@[a-zA-Z0-9_]+@(.+)/', $this->getInput('canusername'), $matches);
72-
return $matches[1];
73-
}
74-
75-
private function getUsername(){
76-
preg_match('/^@([a-zA-Z_0-9_]+)@.+/', $this->getInput('canusername'), $matches);
77-
return $matches[1];
78-
}
143+
protected function parseObject($object, $item) {
144+
$item['content'] = $object['content'];
145+
$strippedContent = strip_tags(str_replace('<br>', ' ', $object['content']));
79146

80-
public function getURI(){
81-
if($this->getInput('canusername'))
82-
return 'https://' . $this->getInstance() . '/@' . $this->getUsername() . '.rss';
83-
84-
return parent::getURI();
147+
if (mb_strlen($strippedContent) > 75) {
148+
$contentSubstring = mb_substr($strippedContent, 0, mb_strpos(wordwrap($strippedContent, 75), "\n"));
149+
$item['title'] .= $contentSubstring . '...';
150+
} else {
151+
$item['title'] .= $strippedContent;
152+
}
153+
$item['uri'] = $object['id'];
154+
foreach ($object['attachment'] as $attachment) {
155+
// Only process REMOTE pictures (prevent xss)
156+
if ($attachment['mediaType']
157+
&& preg_match('/^image\//', $attachment['mediaType'], $match)
158+
&& preg_match('/^http(s|):\/\//', $attachment['url'], $match)
159+
) {
160+
$item['content'] = $item['content'] . '<br /><img ';
161+
if ($attachment['name']) {
162+
$item['content'] .= sprintf('alt="%s" ', $attachment['name']);
163+
}
164+
$item['content'] .= sprintf('src="%s" />', $attachment['url']);
165+
}
166+
}
167+
return $item;
85168
}
86169

87-
public function collectData(){
88-
return $this->collectExpandableDatas($this->getURI());
170+
protected function fetchAP($url) {
171+
$d = new DateTime();
172+
$d->setTimezone(new DateTimeZone('GMT'));
173+
$date = $d->format('D, d M Y H:i:s e');
174+
preg_match('/https?:\/\/([a-z0-9-\.]{0,})(\/[^?#]+)/', $url, $matches);
175+
$headers = array(
176+
'Accept: application/activity+json',
177+
'Host: ' . $matches[1],
178+
'Date: ' . $date
179+
);
180+
$privateKey = $this->getOption('private_key');
181+
$keyId = $this->getOption('key_id');
182+
if ($privateKey && $keyId) {
183+
$pkey = openssl_pkey_get_private('file://' . $privateKey);
184+
$toSign = '(request-target): get ' . $matches[2] . "\nhost: " . $matches[1] . "\ndate: " . $date;
185+
$result = openssl_sign($toSign, $signature, $pkey, 'RSA-SHA256');
186+
if ($result) {
187+
Debug::log($toSign);
188+
$sig = 'Signature: keyId="' . $keyId . '",headers="(request-target) host date",signature="' .
189+
base64_encode($signature) . '"';
190+
Debug::log($sig);
191+
array_push($headers, $sig);
192+
}
193+
}
194+
return json_decode(getContents($url, $headers), true);
89195
}
90196
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# MastodonBridge (aka. ActivityPub Bridge)
2+
3+
Certain ActivityPub implementations, such as [Mastodon](https://docs.joinmastodon.org/spec/security/#http) and [Pleroma](https://docs-develop.pleroma.social/backend/configuration/cheatsheet/#activitypub), allow instances to require requests to ActivityPub endpoints to be signed. RSS-Bridge can handle the HTTP signature header if a private key is provided, while the ActivityPub instance must be able to know the corresponding public key.
4+
5+
You do **not** need to configure this if the usage on your RSS-Bridge instance is limited to accessing ActivityPub instances that do not have such requirements. While the majority of ActivityPub instances don't have them at the time of writing, the situation may change in the future.
6+
7+
## Configuration
8+
9+
[This article](https://blog.joinmastodon.org/2018/06/how-to-implement-a-basic-activitypub-server/) is referenced.
10+
11+
1. Select a domain. It may, but does not need to, be the one RSS-Bridge is on. For all subsequent steps, replace `DOMAIN` with this domain.
12+
2. Run the following commands on your machine:
13+
```bash
14+
$ openssl genrsa -out private.pem 2048
15+
$ openssl rsa -in private.pem -outform PEM -pubout -out public.pem
16+
```
17+
3. Place `private.pem` in an appropriate location and note down its absolute path.
18+
4. Serve the following page at `https://DOMAIN/.well-known/webfinger`:
19+
```json
20+
{
21+
"subject": "acct:DOMAIN@DOMAIN",
22+
"aliases": ["https://DOMAIN/actor"],
23+
"links": [{
24+
"rel": "self",
25+
"type": "application/activity+json",
26+
"href": "https://DOMAIN/actor"
27+
}]
28+
}
29+
```
30+
5. Serve the following page at `https://DOMAIN/actor`, replacing the value of `publicKeyPem` with the contents of the `public.pem` file in step 2, with all line breaks substituted with `\n`:
31+
```json
32+
{
33+
"@context": [
34+
"https://www.w3.org/ns/activitystreams",
35+
"https://w3id.org/security/v1"
36+
],
37+
"id": "https://DOMAIN/actor",
38+
"type": "Application",
39+
"inbox": "https://DOMAIN/actor/inbox",
40+
"preferredUsername": "DOMAIN",
41+
"publicKey": {
42+
"id": "https://DOMAIN/actor#main-key",
43+
"owner": "https://DOMAIN/actor",
44+
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----\n"
45+
}
46+
}
47+
```
48+
6. Add the following configuration in `config.ini.php` in your RSS-Bridge folder, replacing the path with the one from step 3:
49+
```ini
50+
[MastodonBridge]
51+
private_key = "/absolute/path/to/your/private.pem"
52+
key_id = "https://DOMAIN/actor#main-key"
53+
```
54+
55+
## Considerations
56+
57+
Any ActivityPub instance your users requested content from will be able to identify requests from your RSS-Bridge instance by the domain you specified in the configuration. This also means that an ActivityPub instance may choose to block this domain should they judge your instance's usage excessive. Therefore, public instance operators should monitor for abuse and prepare to communicate with ActivityPub instance admins when necessary. You may also leave contact information as the `summary` value in the actor JSON (step 5).

0 commit comments

Comments
 (0)