1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
|
<?php
namespace Digitigrade\Model;
use Digitigrade\Exception\AuthException;
use Digitigrade\Exception\EndpointMissingException;
use Digitigrade\GlobalConfig;
use Digitigrade\HttpResponseStatus\Unauthorized;
use Digitigrade\Job\RefreshOutboundAuthToken;
use Digitigrade\PolicyManager;
class Instance extends FetchableModel {
public ?int $id;
public string $domain;
public string $name;
public ?string $description;
public ?string $icon; // uri
public string $softwareName;
public ?string $softwareDescription;
public ?string $softwareVersion;
public ?string $softwareHomepage;
public InstanceEndpoints $endpoints;
public InstanceAuth $auth;
protected function getUpdateWhereClause(\PDO $db): ?string {
if (self::findWhere('domain = ?', [$this->domain]) != null)
return 'domain = ' . $db->quote($this->domain);
if (self::findWhere('id = ?', [$this->id]) != null)
return "id = $this->id";
return null;
}
public static function findByHostname(string $host, bool $autoSave = true, bool $forceRefetch = false): ?self {
if ($host == GlobalConfig::getInstance()->getCanonicalHost()) {
// refuse to fetch ourselves
return null;
}
return self::findByUri("https://$host/.well-known/pawpub-instance", $autoSave, $forceRefetch);
}
protected function hydrate() {
$this->endpoints = InstanceEndpoints::findWhere('instance_id = ?', [$this->id]);
$auth = InstanceAuth::findWhere('instance_id = ?', [$this->id]);
if ($auth == null) {
$auth = new InstanceAuth();
//$auth->setOwnerId($this->id);
}
$this->auth = $auth;
}
protected function finaliseAfterFetch(string $uri) {
$this->domain = hostname_from_uri($uri);
}
protected function finaliseAfterSave() {
// this has to be done after saving because we didn't have an id before
$this->auth = InstanceAuth::findWhere('instance_id = ?', [$this->id]) ?? new InstanceAuth();
}
public static function findByUri(string $uri, bool $autoSave = true, bool $forceRefetch = false): ?static {
if (!$forceRefetch) {
$domain = hostname_from_uri($uri);
$obj = self::findWhere('domain = ?', [$domain]);
if ($obj != null)
return $obj;
}
return self::fetchFromRemote($uri, $autoSave);
}
public static function findByInboundAuthToken(string $token): ?self {
$authRecord = InstanceAuth::findWhere('inbound_token = ?', [$token]);
if ($authRecord == null)
return null;
return self::find($authRecord->instanceId);
}
public static function findByRequestHeaders(): ?self {
if (!isset($_SERVER['HTTP_AUTHORIZATION']) || !str_starts_with($_SERVER['HTTP_AUTHORIZATION'], 'Bearer '))
return null;
$header = $_SERVER['HTTP_AUTHORIZATION'];
$token = substr($header, 7);
return self::findByInboundAuthToken($token);
}
public static function requireByRequestHeaders(): self {
$inst = self::findByRequestHeaders();
if ($inst == null) {
throw new Unauthorized();
}
return $inst;
}
public static function findByDialbackSecret(string $secret): ?self {
$authRecord = InstanceAuth::findWhere('secret = ?', [$secret]);
if ($authRecord == null)
return null;
return self::find($authRecord->instanceId);
}
public function beginOutboundAuth() {
$authEndpoint = $this->endpoints->auth;
if (!isset($authEndpoint)) {
throw new EndpointMissingException($this, 'auth');
}
file_get_contents($authEndpoint . '?phase=dialback&target=' . urlencode(path_to_uri('/auth/dialback')));
}
public function requestOutboundAuthToken(string $secret) {
$authEndpoint = $this->endpoints->auth;
if (!isset($authEndpoint)) {
throw new EndpointMissingException($this, 'auth');
}
$response = file_get_contents($authEndpoint . '?phase=token&secret=' . urlencode($secret));
if ($response === false) {
throw new \RuntimeException("requesting a token from $this->domain failed");
}
$data = json_decode($response);
if ($data === false || !isset($data->token, $data->expires)) {
throw new \RuntimeException("$this->domain sent bogus json in response to token request");
}
$this->auth->outboundToken = $data->token;
$this->save();
// try to refresh it 1 hour before it expires, to leave some leeway
$refreshAt = (new \DateTimeImmutable($data->expires))->sub(new \DateInterval('PT1H'));
(new RefreshOutboundAuthToken($this->domain, $refreshAt))->submit();
// for now i think it makes sense to subscribe as soon as possible to all authed instances for maximum federation
// this is probably a bad idea in the long run but i'll cross that bridge when it comes to it
// see also the route for /subscribe
$this->subscribe();
}
public function refreshOutboundAuthToken() {
$authEndpoint = $this->endpoints->auth;
if (!isset($authEndpoint)) {
throw new EndpointMissingException($this, 'auth');
}
if (!isset($this->auth->outboundToken)) {
throw new \RuntimeException("can't refresh outbound token for $this->domain because i don't already have one");
}
$response = file_get_contents($authEndpoint . '?phase=refresh&token=' . urlencode($this->auth->outboundToken));
if ($response === false) {
throw new \RuntimeException("requesting a new token from $this->domain failed");
}
$data = json_decode($response);
if ($data === false || !isset($data->token, $data->expires)) {
throw new \RuntimeException("$this->domain sent bogus json in response to token refresh request");
}
$this->auth->outboundToken = $data->token;
$this->save();
// try to refresh it again 1 hour before it expires, to leave some leeway
$refreshAt = (new \DateTimeImmutable($data->expires))->sub(new \DateInterval('PT1H'));
(new RefreshOutboundAuthToken($this->domain, $refreshAt))->submit();
}
private function subscribeOrUnsubscribe(bool $isUnsubscribe): bool {
if (!isset($this->endpoints->subscribe, $this->endpoints->unsubscribe)) {
throw new EndpointMissingException($this, 'subscribe');
}
if (!isset($this->auth->outboundToken)) {
throw new \RuntimeException("can't (un)subscribe to $this->domain because i don't have an outbound token for it");
}
$context = stream_context_create(['http' => [
'method' => 'POST',
'header' => 'Authorization: Bearer ' . $this->auth->outboundToken
]]);
file_get_contents($isUnsubscribe ? $this->endpoints->unsubscribe : $this->endpoints->subscribe, context: $context);
// it ought to return 204 but i'll allow 200 for compatibility's sake
return str_contains($http_response_header[0], '204') || str_contains($http_response_header[0], '200');
}
/**
* Tries to subscribe to the instance for receiving objects by push
* @return bool whether subscribing was successful
*/
public function subscribe(): bool {
if ($this->subscribeOrUnsubscribe(isUnsubscribe: false)) {
$this->auth->inboundPushEnabled = true;
$this->auth->save();
return true;
}
return false;
}
/**
* Tries to unsubscribe from the instance
* @return bool whether unsubscribing was successful
*/
public function unsubscribe() {
if ($this->subscribeOrUnsubscribe(isUnsubscribe: true)) {
$this->auth->inboundPushEnabled = false;
$this->auth->save();
return true;
}
return false;
}
public function pushObject(PushableModel $obj) {
if (!isset($this->endpoints->push)) {
throw new EndpointMissingException($this, 'push');
}
if (!isset($this->auth->outboundToken)) {
throw new AuthException($this);
}
if (!$this->auth->outboundPushEnabled) {
throw new \RuntimeException("won't push to $this->domain because it hasn't subscribed to us");
}
PolicyManager::getInstance()->checkFederationOrThrow($obj, $this);
$context = stream_context_create(['http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\nAuthorization: Bearer " . $this->auth->outboundToken,
'content' => json_encode($obj)
]]);
file_get_contents($this->endpoints->push, context: $context);
if (str_contains($http_response_header[0], '200')) {
return true;
} elseif (str_contains($http_response_header[0], '401') || str_contains($http_response_header[0], '403')) {
// in this case we can't be certain whether it failed because wrong token or because they're not subscribed to us
// so just assume it's both :>
$this->auth->outboundPushEnabled = false;
$this->auth->outboundToken = false;
throw new AuthException($this);
}
return false;
}
}
|