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?>