friendship ended with social-app. php is my new best friend
1<?php
2
3error_reporting(E_ALL);
4ini_set('display_errors', 'On');
5ini_set('log_errors_max_len', '0');
6
7require_once('config.php');
8require_once('vendor/autoload.php');
9//if (DB_TYPE === 'sqlite') require_once('db.php');
10if (DB_TYPE === 'mysql') require_once('maria-db.php');
11
12use Matrix\Async;
13use React\Http\Browser;
14use React\Promise\Promise;
15use React\Async\Parallel;
16use React\Dns\Config\Config;
17use GuzzleHttp\Client;
18use GuzzleHttp\Query;
19use GuzzleHttp\Middleware;
20use GuzzleHttp\Handler\CurlHandler;
21use GuzzleHttp\HandlerStack;
22use GuzzleHttp\Exception\ConnectException;
23use GuzzleHttp\Psr7\Request;
24use GuzzleHttp\TransferStats;
25use Psr\Http\Message\RequestInterface;
26use Psr\Http\Message\ResponseInterface;
27use Fusonic\OpenGraph\Consumer;
28use chillerlan\OAuth\Storage\SessionStorage;
29
30class BskyToucher {
31 private $bskyApiBase = 'https://public.api.bsky.app/xrpc/';
32 private $slingshotBase = \SLINGSHOT_INSTANCE.'/xrpc/';
33 private $constellationBase = \CONSTELLATION_INSTANCE;
34 private $plcDirectoryBase = \PLC_DIRECTORY;
35 private $publicApiBase = \PUBLIC_API.'/xrpc/';
36
37 /* GENERAL QUERIES */
38
39 function makeRequest(string $method, string $uri, array $query = [], array $headers = [], string $body = ''): Promise|Exception {
40 if ($method === 'GET') {
41 $headers = [
42 "Content-Type" => "application/x-www-form-urlencoded",
43 "User-Agent" => \USER_AGENT_STR
44 ];
45 }
46 $connector = new React\Socket\Connector([
47 'dns' => '1.1.1.1'
48 ]);
49 $browser = new React\Http\Browser($connector);
50 $built_query = "";
51 if (!empty($query)) {
52 $built_query = "?".http_build_query($query);
53 }
54 $ret = retry(fn () => $browser->request($method, $uri.$built_query, $headers, $body), 5)
55 ->then(function (Psr\Http\Message\ResponseInterface $response) {
56 return $response;
57 }, function(Exception $e) {
58 return $e;
59 });
60 return $ret;
61 }
62
63 function getSlingshotData(string $repo, string $collection, ?string $rkey = 'self', ?array $params = []): object|bool {
64 $query = [
65 'collection' => $collection,
66 'repo' => $repo
67 ];
68 if ($rkey) {
69 $query['rkey'] = $rkey;
70 }
71 if ($params) {
72 $query = array_merge($query, $params);
73 }
74 $ret = await($this->makeRequest("GET", $this->slingshotBase."com.atproto.repo.getRecord", $query));
75 if ($ret && method_exists($ret, 'getBody')) return json_decode($ret->getBody());
76 return false;
77 }
78
79 function resolveHandle(string $handle): string|bool {
80 $cache = \requestUserCache($handle, 'handle');
81 if ($cache) return $cache->did;
82 $ret = await($this->makeRequest("GET", $this->slingshotBase."com.atproto.identity.resolveHandle", ['handle' => $handle]));
83 if ($ret && method_exists($ret, 'getBody') && $ret->getReasonPhrase() === "OK") {
84 return json_decode($ret->getBody())->did;
85 }
86 }
87
88 function getSlingshotIdentityMiniDoc(string $did): object|bool {
89 $cache = \requestMinidocCache($did);
90 if ($cache) return $cache;
91 $ret = await(async(fn () => $this->makeRequest("GET", $this->slingshotBase."com.bad-example.identity.resolveMiniDoc", ['identifier' => $did])));
92
93 if ($ret && method_exists($ret, 'getBody') && $ret->getReasonPhrase() === 'OK') {
94 $body = json_decode($ret->getBody());
95 \updateMinidocCache($body->handle, $body->did, $body->pds, $body->signing_key);
96 return $body;
97 }
98
99 return false;
100 }
101
102 function getConstellationLinkData(string $target, string $collection, string $path, int $limit = 50, int $offset = 0, string $endpoint = '/xrpc/blue.microcosm.links.getBacklinks'): object|bool {
103 $query = [
104 'offset' => $offset
105 ];
106 if ($endpoint === '/xrpc/blue.microcosm.links.getBacklinks') {
107 $query['subject'] = $target;
108 $query['source'] = $collection.":".$path;
109 } else {
110 $query['target'] = $target;
111 $query['collection'] = $collection;
112 $query['path'] = ".".$path;
113 }
114 $ret = await($this->makeRequest("GET", $this->constellationBase.$endpoint, $query));
115 if ($ret && method_exists($ret, 'getBody') && $ret->getReasonPhrase() === 'OK') {
116 return json_decode($ret->getBody());
117 }
118
119 return false;
120 }
121
122 function getPublicApiData(string $endpoint, array $query): object|bool {
123 $ret = await($this->makeRequest("GET", $this->publicApiBase.$endpoint, $query));
124 if ($ret && method_exists($ret, 'getBody') && $ret->getReasonPhrase() === 'OK') {
125 return json_decode($ret->getBody());
126 }
127
128 return false;
129 }
130
131 function getPdsData(string $pds, string $endpoint, array $query, ?string $token = null): object|bool {
132 $headers = [];
133 if ($token) {
134 $headers['Authorization'] = 'Bearer: '.$token;
135 }
136 $ret = await($this->makeRequest("GET", $pds."/xrpc/".$endpoint, $query, $headers));
137
138 if ($ret && method_exists($ret, 'getBody') && $ret->getReasonPhrase() === 'OK') {
139 return json_decode($ret->getBody());
140 }
141
142 return false;
143 }
144
145 function getPlcInfoFromRecord(string $did):object|bool {
146 $cache = \requestPlcCache($did);
147 if ($cache) {
148 $doc = json_decode($cache->plcdoc);
149 return (object) [
150 'pds' => $doc->service[0]->serviceEndpoint,
151 'did' => $did,
152 'handle' => str_replace("at://", "", $doc->alsoKnownAs[0])
153 ];
154 }
155
156 $plcdirBase = \PLC_DIRECTORY."/";
157 $ret = await($this->makeRequest("GET", $plcdirBase.$did));
158 if ($ret && method_exists($ret, 'getBody') && $ret->getReasonPhrase() === 'OK') {
159 $body = $ret->getBody();
160 \updatePlcCache($did, (string) $body);
161 $resp = json_decode((string) $body);
162
163 if ($resp && property_exists($resp, 'service') && is_array($resp->service) && count($resp->service) > 0 && property_exists($resp->service[0], 'serviceEndpoint')) {
164 $handle = "";
165 if (property_exists($resp, 'alsoKnownAs') && is_array($resp->alsoKnownAs) && count($resp->alsoKnownAs) > 0) {
166 $handle = $resp->alsoKnownAs[0];
167 }
168 return (object) [
169 'pds' => $resp->service[0]->serviceEndpoint,
170 'did' => $did,
171 'handle' => str_replace("at://", "", $handle)
172 ];
173 }
174 }
175
176 return false;
177 }
178
179 function getFeedSkeleton(string $didweb, string $atUri, ?string $offset = null): object|bool {
180 $query = [
181 'feed' => $atUri
182 ];
183 if ($offset) {
184 $query['offset'] = $offset;
185 }
186 preg_match('/^did:web:([a-z0-9\.\-]+)$/', $didweb, $didWebBase);
187 if (!$didWebBase) return false;
188 $base = "https://".$didWebBase[1];
189 $ret = await($this->makeRequest('GET', $base.'/xrpc/app.bsky.feed.getFeedSkeleton', ['feed' => $atUri]));
190 if (!$ret || $ret->getReasonPhrase() !== 'OK') return false;
191 $body = json_decode((string) $ret->getBody());
192 if (!$body->feed || count($body->feed) === 0) return [];
193 return $body;
194 }
195
196 /* AUTH */
197
198 /* HELPERS */
199
200 function getMediaUrl(string $pds, string $did, string $cid): string {
201 return $pds."/xrpc/com.atproto.sync.getBlob?did=".$did."&cid=".$cid;
202 }
203
204 function splitAtUri(string $atUri): object|bool {
205 preg_match('/at:\/\/(did:plc:[a-z0-9]+)\/([a-zA-Z\.]+)\/([a-z0-9]+)/', $atUri, $uriComponents);
206 if (!$uriComponents) return false;
207 return (object) [
208 'did' => $uriComponents[1],
209 'collection' => $uriComponents[2],
210 'rkey' => $uriComponents[3]
211 ];
212 }
213
214 function applyFacets(string $text, array $facets): string {
215 $ret = $text;
216 $split_str = str_split($text);
217 $additions = 0;
218 foreach ($facets as $facet) {
219 if ($facet->type === "tag") {
220 $hashtagLink = '/h/'.$facet->tag;
221 $splicedpart = array_splice($split_str, $facet->start + $additions, 0, '<a href="'.$hashtagLink.'">');
222 $additions++;
223 array_splice($split_str, $facet->end + $additions, 0, '</a>');
224 $additions++;
225 } else if ($facet->type === "mention") {
226 $profileLink = '/u/'.$facet->handle;
227 $replaceString = '<a href="'.$profileLink.'">@'.$facet->handle.'</a>';
228 array_splice($split_str, $facet->start + $additions, $facet->end - $facet->start, $replaceString);
229 $additions += 1 - ($facet->end - $facet->start);
230 } else if ($facet->type === "link") {
231 $pat = '/('.preg_quote($facet->link, '/').')/';
232 array_splice($split_str, $facet->start + $additions, 0, '<a href="'.$facet->link.'" rel="external" target="_blank">');
233 $additions++;
234 array_splice($split_str, $facet->end + $additions, 0, '</a>');
235 $additions++;
236 }
237 // there might be more idk. like for bold or whatever
238 }
239 return join("", $split_str);
240 }
241
242 /* USER INFO */
243
244 function getUserInfo(string $identifier, string $type = 'handle'): object|bool {
245 $cache = \requestUserCache($identifier, $type);
246 if ($cache) return $cache;
247 $id = $identifier;
248 $did = null;
249 $id = null;
250 $pds = null;
251 if ($type === 'did') {
252 $did = $identifier;
253 $userData = await(async(fn () => $this->getSlingshotIdentityMiniDoc($did)));
254 if (!$userData || !property_exists($userData, 'did')) return false;
255 $did = $userData->did;
256 $pds = $userData->pds;
257 } else {
258 $did = await(async(fn() => $this->resolveHandle($identifier)));
259 $userData = await(async(fn () => $this->getSlingshotIdentityMiniDoc($did)));
260 if (!$userData) return false;
261 $pds = $userData->pds;
262 }
263 $userInfo = await(async(fn () => $this->getSlingshotData($did, 'app.bsky.actor.profile', 'self')));
264 if ($userInfo) {
265 $avatar = property_exists($userInfo->value, 'avatar') ? $this->getMediaUrl($pds, $did, $userInfo->value->avatar->ref->{'$link'}) : null;
266 $banner = property_exists($userInfo->value, 'banner') ? $this->getMediaUrl($pds, $did, $userInfo->value->banner->ref->{'$link'}) : null;
267 $pinned = property_exists($userInfo->value, 'pinnedPost') ? $userInfo->value->pinnedPost->uri : null;
268 $description = property_exists($userInfo->value, 'description') ? $userInfo->value->description : "";
269 \updateUserCache($userData->handle, $did, $userInfo->value->displayName, $pds, $avatar, $banner, $description, $pinned);
270 return (object) [
271 'handle' => $userData->handle,
272 'displayName' => $userInfo->value->displayName,
273 'did' => $did,
274 'pds' => $pds,
275 'description' => $description,
276 'avatar' => $avatar,
277 'banner' => $banner,
278 'pinnedPost' => $pinned
279 ];
280 }
281 return false;
282 }
283
284 /* GETTING USER INFO */
285
286 function getUserDidAndProvider(string $user):object|bool {
287 preg_match('/^did:plc:[a-z0-9]+$/', $user, $didpat);
288 // if they gave us a DID here just return it and grab the PDS
289 if ($didpat) {
290 return $this->getPlcInfoFromRecord($user);
291 }
292
293 // check DNS first
294 $dnsRecords = dns_get_record('_atproto.'.$user, DNS_TXT);
295 if ($dnsRecords) {
296 $dnsRecords = array_filter($dnsRecords, function($v) {
297 preg_match('/^did=(did:plc:[a-z0-9]+)$/', $v['txt'], $dnsmatch);
298 if ($dnsmatch) {
299 return true;
300 }
301 return false;
302 });
303 preg_match('/^did=(did:plc:[a-z0-9]+)$/', $dnsRecords[0]['txt'], $dnsmatch);
304 $did = $dnsmatch[1];
305 return $this->getPlcInfoFromRecord($did);
306 }
307 // try http verification via .well-known
308 $httpRecord = file_get_contents('https://'.$user.'/.well-known/atproto-did');
309 if ($httpRecord) {
310 $did = $httpRecord;
311 return $this->getPlcInfoFromRecord($did);
312 }
313 return false;
314 }
315
316 /* POSTS */
317
318 function getPost(string $identifier, string $rkey, bool $slingshot = false, bool $getReplies = true):object|bool {
319 $cache = \requestPostCache($rkey);
320 if ($cache && !empty($cache)) {
321 return $this->sanitizeCachedPost($cache, $getReplies);
322 }
323
324 $ret = $this->getSlingshotData($identifier, "app.bsky.feed.post", $rkey);
325 if (!$ret) return false;
326 $post = $this->sanitizePost($ret, true, false);
327 return $post;
328 }
329
330 function getLikes(string $post):object {
331 $ret = $this->getConstellationLinkData($post, "app.bsky.feed.like", "subject.uri");
332 if ($ret && property_exists($ret, 'records') && $ret->total !== 0) {
333 $likeUsers = await(batch(
334 $ret->records,
335 function ($batch) {
336 return async(function () use ($batch) {
337 return array_map(fn ($user) => $this->getUserInfo($user->did, 'did'), $batch);
338 });
339 }, 50, 2));
340 if ($ret && property_exists($ret, 'total')) return (object) ['total' => $ret->total, 'records' => $likeUsers, 'cursor' => $ret->cursor];
341 }
342 return (object) [
343 'total' => 0,
344 'records' => [],
345 'cursor' => 0
346 ];
347 }
348
349 function getReposts(string $post):object {
350 $ret = $this->getConstellationLinkData($post, "app.bsky.feed.repost", "subject.uri");
351 if ($ret && property_exists($ret, 'records') && $ret->total !== 0) {
352 $repUsers = await(batch(
353 $ret->records,
354 function ($batch) {
355 return async(function() use ($batch) {
356 return array_map(fn ($user) => $this->getUserInfo($user->did, 'did'), $batch);
357 });
358 }, 50, 2));
359 if ($ret && property_exists($ret, 'total')) return (object) ['total' => $ret->total, 'records' => $repUsers, 'cursor' => $ret->cursor];
360 }
361 return (object) [
362 'total' => 0,
363 'records' => [],
364 'cursor' => 0
365 ];
366 }
367
368 function getQuotes(string $post):object {
369 $ret = $this->getConstellationLinkData($post, "app.bsky.feed.post", "embed.record.record.uri");
370 if ($ret && property_exists($ret, 'records') && $ret->total !== 0) {
371 $quoteRecords = await(batch(
372 $ret->records,
373 function($batch) {
374 return async(function () use ($batch) {
375 return array_map(function ($rec) {
376 return $this->getPost($rec->did, $rec->rkey, true, false);
377 }, $batch);
378 });
379 }, 50, 2));
380 if ($ret && property_exists($ret, 'total')) return (object) ['total' => $ret->total, 'records' => $quoteRecords, 'cursor' => $ret->cursor];
381 }
382 return (object) [
383 'total' => 0,
384 'records' => [],
385 'cursor' => 0
386 ];
387 }
388
389 function getReplies(string $post, int $recurseLevel = 0):object {
390 $ret = $this->getConstellationLinkData($post, "app.bsky.feed.post", "reply.parent.uri");
391 if ($ret && property_exists($ret, 'records') && is_array($ret->records) && $ret->total !== 0) {
392 $replyRecords = await(batch(
393 $ret->records, function ($batch) {
394 return async(function () use ($batch) {
395 return array_map(fn($rec) => $this->getPost($rec->did, $rec->rkey, true, false), $batch);
396 });
397 }, 50, 2));
398 if ($ret && property_exists($ret, 'total')) return (object) ['total' => $ret->total, 'records' => $replyRecords, 'cursor' => $ret->cursor];
399 }
400 return (object) [
401 'total' => 0,
402 'records' => [],
403 'cursor' => 0
404 ];
405 }
406
407 function getUserPosts(string $did, ?string $cursor = null, bool $auth = false, bool $newer = false): object|bool {
408 $userData = $this->getUserInfo($did, 'did');
409 if (!$userData) return false;
410 $postData = await(async(function () use ($did, $cursor, $userData) {
411 return $this->getPdsData($userData->pds, 'com.atproto.repo.listRecords', [
412 'repo' => $did,
413 'collection' => 'app.bsky.feed.post',
414 'limit' => 20,
415 'cursor' => $cursor
416 ]);
417 }));
418 if (!$postData) return false;
419 return $postData;
420 }
421
422 function getSearchResults(string $query):array {
423 $ret = $this->getPublicApiData('app.bsky.feed.searchPosts', [
424 'q' => $query,
425 'tag' => $query
426 ]);
427 return $this->sanitizePosts($ret->posts);
428 }
429
430 function getFeedInfo(string $atUri): object|bool {
431 $cache = \requestFeedCache($atUri);
432 if ($cache) {
433 $uriComponents = $this->splitAtUri($cache->atUri);
434 $authorInfo = $this->getUserInfo($cache->creatorDid, 'did');
435 if (!$authorInfo) return false;
436 return (object) [
437 'title' => $cache->title,
438 'url' => '/f/'.$cache->creatorDid.'/'.$uriComponents->rkey,
439 'description' => $cache->description,
440 'avatar' => $cache->avatar,
441 'creatorDisplay' => $authorInfo->displayName,
442 'creatorDid' => $uriComponents->did,
443 'creatorPds' => $authorInfo->pds,
444 'creatorHandle' => $authorInfo->handle
445 ];
446 }
447
448 $uriComponents = $this->splitAtUri($atUri);
449 if (!$uriComponents) return false;
450 $feedData = $this->getSlingshotData($uriComponents->did, $uriComponents->collection, $uriComponents->rkey);
451 $authorInfo = $this->getUserInfo($uriComponents->did, 'did');
452 if (!$authorInfo) return false;
453 \updateFeedCache($atUri, $feedData->value->displayName, $feedData->value->description, $this->getMediaUrl($authorInfo->pds, $uriComponents->did, $feedData->value->avatar->ref->{'$link'}), $uriComponents->did);
454 return (object) [
455 'title' => $feedData->value->displayName,
456 'url' => '/f/'.$uriComponents->did.'/'.$uriComponents->rkey,
457 'description' => $feedData->value->description,
458 'avatar' => $this->getMediaUrl($authorInfo->pds, $uriComponents->did, $feedData->value->avatar->ref->{'$link'}),
459 'creatorDisplay' => $authorInfo->displayName,
460 'creatorDid' => $uriComponents->did,
461 'creatorPds' => $authorInfo->pds,
462 'creatorHandle' => $authorInfo->handle
463 ];
464 return $feedData;
465 }
466
467 function getFeed(string $atUri, ?string $cursor = null, ?string $userAuth = null, ?bool $newer = false, int $limit = 15):object|bool {
468 preg_match('/^at:\/\/(did:plc:[a-z0-9\.]+)\/app.bsky.feed.generator\/([a-z0-9]+)$/', $atUri, $uriComponents);
469 $did = $uriComponents[1];
470 $rkey = $uriComponents[2];
471 $userInfo = $this->getUserInfo($did, 'did');
472
473 if (!$userInfo) return false;
474
475 $feedInfo = await(async(function () use ($did, $rkey, $userInfo) {
476 return $this->getPdsData($userInfo->pds, 'com.atproto.repo.getRecord', [
477 'repo' => $did,
478 'collection' => 'app.bsky.feed.generator',
479 'rkey' => $rkey
480 ]);
481 }));
482
483 if (!$feedInfo) return false;
484
485 if ($userAuth) {
486 // do something. i don't care rn tho
487
488 /*$feedData = $this->getPdsData($pds, 'app.bsky.feed.getFeed', [
489 'feed' => FRONTPAGE_FEED,
490 'limit' => $limit,
491 'cursor' => $cursor
492 ]);*/
493 return false;
494 }
495
496 $feedData = $this->getFeedSkeleton($feedInfo->value->did, $atUri, $cursor);
497
498 if ($feedData) {
499 return $feedData;
500 }
501
502 return false;
503 }
504
505 /* SANITIZATION */
506
507 function sanitizePost(object $post, ?bool $slingshot = false, bool $getReplies = true):object|bool {
508 if ($slingshot) {
509 return $this->sanitizeSlingshotRecordPost($post, false);
510 }
511 return $this->sanitizePublicApiPost($post, false);
512 }
513
514 function sanitizeSlingshotRecordPost(object $post, ?string $cursor = null, $getReplies = true):object|bool {
515 preg_match('/at:\/\/(did:plc:[a-z0-9]+)\/[a-zA-Z\.]+\/([a-z0-9]+)/', $post->uri, $uriComponents);
516 if (!$uriComponents) return false;
517 $did = $uriComponents[1];
518 $rkey = $uriComponents[2];
519 $authorInfo = $this->getUserInfo($did, 'did');
520 $facets = property_exists($post->value, 'facets') ? $this->sanitizeFacets($post->value->facets) : [];
521 $ret = (object) [
522 'author' => (object) [
523 'displayName' => $authorInfo->displayName,
524 'handle' => $authorInfo->handle,
525 'avatar' => $authorInfo->avatar,
526 'did' => $did,
527 'profileLink' => '/u/'.$authorInfo->handle,
528 ],
529 'uri' => $post->uri,
530 'pds' => $authorInfo->pds,
531 'postId' => $rkey,
532 'postLink' => '/u/'.$authorInfo->handle.'/'.$rkey,
533 'content' => property_exists($post->value, 'text') ? $this->applyFacets($post->value->text, $facets) : '',
534 'createdAt' => $post->value->createdAt,
535 'embedType' => property_exists($post->value, 'embed') ? $post->value->embed->{'$type'} : null,
536 'embeds' => property_exists($post->value, 'embed') ? $this->sanitizeEmbeds($post->value->embed, $authorInfo) : [],
537 'cursor' => $cursor
538 ];
539 \updatePostCache($rkey, $did, $ret->content, $ret->embedType ? $ret->embedType : '', json_encode($ret->embeds), $post->value->createdAt);
540 return $ret;
541 }
542
543 function sanitizePublicApiPost(object $post, ?string $cursor = null, bool $getReplies = true):object|bool {
544 preg_match('/at:\/\/(did:plc:[a-z0-9]+)\/[a-zA-Z\.]+\/([a-z0-9]+)/', $post->uri, $rkeyMatch);
545 if (!$rkeyMatch) return false;
546 $authorData = $this->getUserInfo($post->author->did, 'did');
547 $facets = property_exists($post->record, 'facets') ? $this->sanitizeFacets($post->record->facets) : [];
548 $processedText = $this->applyFacets($post->record->text, $facets);
549 $embedType = property_exists($post->record, 'embed') ? $post->record->embed->{'$type'} : '';
550 $embeds = property_exists($post->record, 'embed') ? $this->sanitizeEmbeds($post->record->embed, $authorData) : [];
551 \updatePostCache($rkeyMatch[2], $authorData->did, $processedText, $embedType, json_encode($embeds), $post->record->createdAt);
552 return (object) [
553 'author' => (object) [
554 'displayName' => $authorData->displayName,
555 'handle' => $authorData->handle,
556 'did' => $authorData->did,
557 'avatar' => $authorData->avatar,
558 'profileLink' => '/u/'.$authorData->handle,
559 ],
560 'uri' => $post->uri,
561 'pds' => $authorData->pds,
562 'postId' => $rkeyMatch[2],
563 'postLink' => '/u/'.$authorData->handle.'/'.$rkeyMatch[2],
564 'createdAt' => $post->record->createdAt,
565 'embedType' => $embedType,
566 'embeds' => $embeds
567 ];
568 }
569
570 function sanitizeCachedPost(object $post, bool $getReplies = true): object {
571 $uri = 'at://'.$post->did.'/app.bsky.feed.post/'.$post->rkey;
572 $authorData = $this->getUserInfo($post->did, 'did');
573 return (object) [
574 'author' => (object) [
575 'displayName' => $authorData->displayName,
576 'handle' => $authorData->handle,
577 'did' => $authorData->did,
578 'avatar' => $authorData->avatar,
579 'profileLink' => '/u/'.$authorData->handle,
580 ],
581 'uri' => $uri,
582 'pds' => $authorData->pds,
583 'postId' => $post->rkey,
584 'postLink' => '/u/'.$authorData->handle.'/'.$post->rkey,
585 'content' => $post->text,
586 'createdAt' => $post->createdAt,
587 'embedType' => $post->embedType,
588 'embeds' => json_decode($post->embeds)
589 ];
590 }
591
592 function sanitizeEmbeds(object $embeds, object $authorData):array|bool {
593 if ($embeds->{'$type'} === 'app.bsky.embed.images') {
594 return array_map(function ($im) use ($authorData) {
595 return (object) [
596 'imgUrl' => $this->getMediaUrl($authorData->pds, $authorData->did, $im->image->ref->{'$link'}),
597 'alt' => $im->alt,
598 'width' => property_exists($im, 'aspectRatio') ? $im->aspectRatio->width : "auto",
599 'height' => property_exists($im, 'aspectRatio') ? $im->aspectRatio->height : "auto"
600 ];
601 }, $embeds->images);
602 } else if ($embeds->{'$type'} === 'app.bsky.embed.video') {
603 return [
604 (object) [
605 'thumb' => property_exists($embeds->video, 'thumbnail') ? $embeds->video->thumbnail : '',
606 'videoUrl' => $this->getMediaUrl($authorData->pds, $authorData->did, $embeds->video->ref->{'$link'}),
607 'width' => property_exists($embeds, 'aspectRatio') ? $embeds->aspectRatio->width : "auto",
608 'height' => property_exists($embeds, 'aspectRatio') ? $embeds->aspectRatio->height : "auto"
609 ]
610 ];
611 } else if ($embeds->{'$type'} === 'app.bsky.embed.record') {
612 $uriComponents = $this->splitAtUri($embeds->record->uri);
613 $post = $this->getSlingshotData($uriComponents->did, $uriComponents->collection, $uriComponents->rkey);
614 $sanitizedPost = $this->sanitizePost($post, true);
615 return [
616 (object) [
617 'post' => $sanitizedPost
618 ]
619 ];
620 } else if ($embeds->{'$type'} === 'app.bsky.embed.external') {
621 return [
622 (object) [
623 'uri' => $embeds->external->uri,
624 'title' => $embeds->external->title,
625 'description' => $embeds->external->description,
626 'thumb' => property_exists($embeds->external, 'thumb') ? $this->getMediaUrl($authorData->pds, $authorData->did, $embeds->external->thumb->ref->{'$link'}) : null
627 ]
628 ];
629 }
630 return false;
631 }
632
633 function sanitizeUserList(array $users):array {
634 $normalized = array_map(function ($rec) {
635 $hydratedRec = $rec;
636 return $this->getUserInfo($rec->did, 'did');
637 }, $users);
638 $ret = array_values(array_filter($normalized));
639 return $ret;
640 }
641
642 function sanitizeFacets(array $facets):array {
643 usort($facets, function($a, $b) {
644 if ($a->index->byteStart > $b->index->byteStart) {
645 return 1;
646 }
647 return -1;
648 });
649 return array_map(function ($facet) {
650 if ($facet->features[0]->{'$type'} === "app.bsky.richtext.facet#mention") {
651 $did = $facet->features[0]->did;
652 $userInfo = $this->getPlcInfoFromRecord($did);
653 if ($userInfo) {
654 $handle = $userInfo->handle;
655 return (object) [
656 'type' => 'mention',
657 'did' => $did,
658 'handle' => $handle,
659 'start' => $facet->index->byteStart,
660 'end' => $facet->index->byteEnd
661 ];
662 }
663 return (object) [];
664 } else if ($facet->features[0]->{'$type'} === "app.bsky.richtext.facet#tag") {
665 return (object) [
666 'type' => 'tag',
667 'tag' => $facet->features[0]->tag,
668 'start' => $facet->index->byteStart,
669 'end' => $facet->index->byteEnd
670 ];
671 } else if ($facet->features[0]->{'$type'} === "app.bsky.richtext.facet#link") {
672 return (object) [
673 'type' => 'link',
674 'link' => $facet->features[0]->uri,
675 'start' => $facet->index->byteStart,
676 'end' => $facet->index->byteEnd
677 ];
678 }
679 return (object) [];
680 }, $facets);
681 }
682
683 /* App-Level Ban/Timeout Implementation */
684
685 function userBanCheck(string $did): bool {
686 }
687
688 function filterRecords(array $records, string $userField):array {
689 }
690}
691
692?>