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'); 9require_once('bskyProvider.php'); 10//if (DB_TYPE === 'sqlite') require_once('db.php'); 11if (DB_TYPE === 'mysql') require_once('maria-db.php'); 12 13use Matrix\Async; 14use React\Http\Browser; 15use React\Promise\Promise; 16use React\Async\Parallel; 17use React\Dns\Config\Config; 18use GuzzleHttp\Client; 19use GuzzleHttp\Query; 20use GuzzleHttp\Middleware; 21use GuzzleHttp\Handler\CurlHandler; 22use GuzzleHttp\HandlerStack; 23use GuzzleHttp\Exception\ConnectException; 24use GuzzleHttp\Psr7\Request; 25use GuzzleHttp\TransferStats; 26use Psr\Http\Message\RequestInterface; 27use Psr\Http\Message\ResponseInterface; 28use Fusonic\OpenGraph\Consumer; 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 /*$ret = array_map(function($rec) { 194 preg_match('/at:\/\/(did:plc:[a-z0-9]+)\/app.bsky.feed.post\/([a-z0-9]+)/', $rec->post, $uriComponents); 195 $did = $uriComponents[1]; 196 $rkey = $uriComponents[2]; 197 return $this->getPost($did, $rkey, true); 198 }, $body->feed);*/ 199 // map version 200 $ret = await(map( 201 $body->feed, 202 function ($rec) { 203 preg_match('/at:\/\/(did:plc:[a-z0-9]+)\/app.bsky.feed.post\/([a-z0-9]+)/', $rec->post, $uriComponents); 204 $did = $uriComponents[1]; 205 $rkey = $uriComponents[2]; 206 return $this->getPost($did, $rkey, true); 207 } 208 ), 10); 209 // batch version 210 /*$ret = await(batch( 211 $body->feed, 212 function ($batch) { 213 return async(function () use ($batch) { 214 return array_map(function($rec) { 215 preg_match('/at:\/\/(did:plc:[a-z0-9]+)\/app.bsky.feed.post\/([a-z0-9]+)/', $rec->post, $uriComponents); 216 $did = $uriComponents[1]; 217 $rkey = $uriComponents[2]; 218 return $this->getPost($did, $rkey, true); 219 }, $batch); 220 }); 221 }, 25, 2));*/ 222 $body->feed = $ret; 223 return $body; 224 } 225 226 /* AUTH */ 227 228 /* HELPERS */ 229 230 function getMediaUrl(string $pds, string $did, string $cid): string { 231 return $pds."/xrpc/com.atproto.sync.getBlob?did=".$did."&cid=".$cid; 232 } 233 234 function splitAtUri(string $atUri): object|bool { 235 preg_match('/at:\/\/(did:plc:[a-z0-9]+)\/([a-zA-Z\.]+)\/([a-z0-9]+)/', $atUri, $uriComponents); 236 if (!$uriComponents) return false; 237 return (object) [ 238 'did' => $uriComponents[1], 239 'collection' => $uriComponents[2], 240 'rkey' => $uriComponents[3] 241 ]; 242 } 243 244 function applyFacets(string $text, array $facets): string { 245 $ret = $text; 246 $split_str = str_split($text); 247 $additions = 0; 248 foreach ($facets as $facet) { 249 if ($facet->type === "tag") { 250 $hashtagLink = '/h/'.$facet->tag; 251 $splicedpart = array_splice($split_str, $facet->start + $additions, 0, '<a href="'.$hashtagLink.'">'); 252 $additions++; 253 array_splice($split_str, $facet->end + $additions, 0, '</a>'); 254 $additions++; 255 } else if ($facet->type === "mention") { 256 $profileLink = '/u/'.$facet->handle; 257 $replaceString = '<a href="'.$profileLink.'">@'.$facet->handle.'</a>'; 258 array_splice($split_str, $facet->start + $additions, $facet->end - $facet->start, $replaceString); 259 $additions += 1 - ($facet->end - $facet->start); 260 } else if ($facet->type === "link") { 261 $pat = '/('.preg_quote($facet->link, '/').')/'; 262 array_splice($split_str, $facet->start + $additions, 0, '<a href="'.$facet->link.'" rel="external" target="_blank">'); 263 $additions++; 264 array_splice($split_str, $facet->end + $additions, 0, '</a>'); 265 $additions++; 266 } 267 // there might be more idk. like for bold or whatever 268 } 269 return join("", $split_str); 270 } 271 272 /* USER INFO */ 273 274 function getUserInfo(string $identifier, string $type = 'handle'): object|bool { 275 $cache = \requestUserCache($identifier, $type); 276 if ($cache) return $cache; 277 $id = $identifier; 278 $did = null; 279 $id = null; 280 $pds = null; 281 if ($type === 'did') { 282 $did = $identifier; 283 $userData = await(async(fn () => $this->getSlingshotIdentityMiniDoc($did))); 284 if (!$userData || !property_exists($userData, 'did')) return false; 285 $did = $userData->did; 286 $pds = $userData->pds; 287 } else { 288 $did = await(async(fn() => $this->resolveHandle($identifier))); 289 $userData = await(async(fn () => $this->getSlingshotIdentityMiniDoc($did))); 290 if (!$userData) return false; 291 $pds = $userData->pds; 292 } 293 $userInfo = await(async(fn () => $this->getSlingshotData($did, 'app.bsky.actor.profile', 'self'))); 294 if ($userInfo) { 295 $avatar = property_exists($userInfo->value, 'avatar') ? $this->getMediaUrl($pds, $did, $userInfo->value->avatar->ref->{'$link'}) : null; 296 $banner = property_exists($userInfo->value, 'banner') ? $this->getMediaUrl($pds, $did, $userInfo->value->banner->ref->{'$link'}) : null; 297 $pinned = property_exists($userInfo->value, 'pinnedPost') ? $userInfo->value->pinnedPost->uri : null; 298 $description = property_exists($userInfo->value, 'description') ? $userInfo->value->description : ""; 299 \updateUserCache($userData->handle, $did, $userInfo->value->displayName, $pds, $avatar, $banner, $description, $pinned); 300 return (object) [ 301 'handle' => $userData->handle, 302 'displayName' => $userInfo->value->displayName, 303 'did' => $did, 304 'pds' => $pds, 305 'description' => $description, 306 'avatar' => $avatar, 307 'banner' => $banner, 308 'pinnedPost' => $pinned 309 ]; 310 } 311 return false; 312 } 313 314 /* GETTING USER INFO */ 315 316 function getUserDidAndProvider(string $user):object|bool { 317 preg_match('/^did:plc:[a-z0-9]+$/', $user, $didpat); 318 // if they gave us a DID here just return it and grab the PDS 319 if ($didpat) { 320 return $this->getPlcInfoFromRecord($user); 321 } 322 323 // check DNS first 324 $dnsRecords = dns_get_record('_atproto.'.$user, DNS_TXT); 325 if ($dnsRecords) { 326 $dnsRecords = array_filter($dnsRecords, function($v) { 327 preg_match('/^did=(did:plc:[a-z0-9]+)$/', $v['txt'], $dnsmatch); 328 if ($dnsmatch) { 329 return true; 330 } 331 return false; 332 }); 333 preg_match('/^did=(did:plc:[a-z0-9]+)$/', $dnsRecords[0]['txt'], $dnsmatch); 334 $did = $dnsmatch[1]; 335 return $this->getPlcInfoFromRecord($did); 336 } 337 // try http verification via .well-known 338 $httpRecord = file_get_contents('https://'.$user.'/.well-known/atproto-did'); 339 if ($httpRecord) { 340 $did = $httpRecord; 341 return $this->getPlcInfoFromRecord($did); 342 } 343 return false; 344 } 345 346 /* POSTS */ 347 348 function getPost(string $identifier, string $rkey, bool $slingshot = false, bool $getReplies = true):object|bool { 349 $cache = \requestPostCache($rkey); 350 if ($cache && !empty($cache)) { 351 return $this->sanitizeCachedPost($cache, $getReplies); 352 } 353 354 $ret = $this->getSlingshotData($identifier, "app.bsky.feed.post", $rkey); 355 if (!$ret) return false; 356 $post = $this->sanitizePost($ret, true, false); 357 return $post; 358 } 359 360 function getLikes(string $post):object { 361 $ret = $this->getConstellationLinkData($post, "app.bsky.feed.like", "subject.uri"); 362 if ($ret && property_exists($ret, 'records') && $ret->total !== 0) { 363 $likeUsers = await(batch( 364 $ret->records, 365 function ($batch) { 366 return async(function () use ($batch) { 367 return array_map(fn ($user) => $this->getUserInfo($user->did, 'did'), $batch); 368 }); 369 }, 50, 2)); 370 if ($ret && property_exists($ret, 'total')) return (object) ['total' => $ret->total, 'records' => $likeUsers, 'cursor' => $ret->cursor]; 371 } 372 return (object) [ 373 'total' => 0, 374 'records' => [], 375 'cursor' => 0 376 ]; 377 } 378 379 function getReposts(string $post):object { 380 $ret = $this->getConstellationLinkData($post, "app.bsky.feed.repost", "subject.uri"); 381 if ($ret && property_exists($ret, 'records') && $ret->total !== 0) { 382 $repUsers = await(batch( 383 $ret->records, 384 function ($batch) { 385 return async(function() use ($batch) { 386 return array_map(fn ($user) => $this->getUserInfo($user->did, 'did'), $batch); 387 }); 388 }, 50, 2)); 389 if ($ret && property_exists($ret, 'total')) return (object) ['total' => $ret->total, 'records' => $repUsers, 'cursor' => $ret->cursor]; 390 } 391 return (object) [ 392 'total' => 0, 393 'records' => [], 394 'cursor' => 0 395 ]; 396 } 397 398 function getQuotes(string $post):object { 399 $ret = $this->getConstellationLinkData($post, "app.bsky.feed.post", "embed.record.record.uri"); 400 if ($ret && property_exists($ret, 'records') && $ret->total !== 0) { 401 $quoteRecords = await(batch( 402 $ret->records, 403 function($batch) { 404 return async(function () use ($batch) { 405 return array_map(function ($rec) { 406 return $this->getPost($rec->did, $rec->rkey, true, false); 407 }, $batch); 408 }); 409 }, 50, 2)); 410 if ($ret && property_exists($ret, 'total')) return (object) ['total' => $ret->total, 'records' => $quoteRecords, 'cursor' => $ret->cursor]; 411 } 412 return (object) [ 413 'total' => 0, 414 'records' => [], 415 'cursor' => 0 416 ]; 417 } 418 419 function getReplies(string $post, int $recurseLevel = 0):object { 420 $ret = $this->getConstellationLinkData($post, "app.bsky.feed.post", "reply.parent.uri"); 421 if ($ret && property_exists($ret, 'records') && is_array($ret->records) && $ret->total !== 0) { 422 $replyRecords = await(batch( 423 $ret->records, function ($batch) { 424 return async(function () use ($batch) { 425 return array_map(fn($rec) => $this->getPost($rec->did, $rec->rkey, true, false), $batch); 426 }); 427 }, 50, 2)); 428 if ($ret && property_exists($ret, 'total')) return (object) ['total' => $ret->total, 'records' => $replyRecords, 'cursor' => $ret->cursor]; 429 } 430 return (object) [ 431 'total' => 0, 432 'records' => [], 433 'cursor' => 0 434 ]; 435 } 436 437 function getUserPosts(string $did, ?string $cursor = null, bool $auth = false, bool $newer = false): array|bool { 438 $userData = $this->getUserInfo($did, 'did'); 439 if (!$userData) return []; 440 $postData = await(async(function () use ($did, $cursor, $userData) { 441 return $this->getPdsData($userData->pds, 'com.atproto.repo.listRecords', [ 442 'repo' => $did, 443 'collection' => 'app.bsky.feed.post', 444 'limit' => 20, 445 'cursor' => $cursor 446 ]); 447 })); 448 if (!$postData) return false; 449 if (count($postData->records) === 0) return $postData; 450 try { 451 $posts = await(all(array_map(function ($post) { 452 return async(function () use ($post) { 453 $uri = $this->splitAtUri($post->uri); 454 $cache = \requestPostCache($uri->rkey); 455 if ($cache) return $this->sanitizeCachedPost($cache); 456 return $this->sanitizeSlingshotRecordPost($post); 457 }); 458 }, $postData->records))); 459 return $posts; 460 } catch (Exception $e) { 461 echo "<!--"; 462 print_r($e); 463 echo "-->"; 464 return false; 465 } 466 } 467 468 function getSearchResults(string $query):array { 469 $ret = $this->getPublicApiData('app.bsky.feed.searchPosts', [ 470 'q' => $query, 471 'tag' => $query 472 ]); 473 return $this->sanitizePosts($ret->posts); 474 } 475 476 function getFeedInfo(string $atUri): object|bool { 477 $cache = \requestFeedCache($atUri); 478 if ($cache) { 479 $uriComponents = $this->splitAtUri($cache->atUri); 480 $authorInfo = $this->getUserInfo($cache->creatorDid, 'did'); 481 if (!$authorInfo) return false; 482 return (object) [ 483 'title' => $cache->title, 484 'url' => '/f/'.$cache->creatorDid.'/'.$uriComponents->rkey, 485 'description' => $cache->description, 486 'avatar' => $cache->avatar, 487 'creatorDisplay' => $authorInfo->displayName, 488 'creatorDid' => $uriComponents->did, 489 'creatorPds' => $authorInfo->pds, 490 'creatorHandle' => $authorInfo->handle 491 ]; 492 } 493 494 $uriComponents = $this->splitAtUri($atUri); 495 if (!$uriComponents) return false; 496 $feedData = $this->getSlingshotData($uriComponents->did, $uriComponents->collection, $uriComponents->rkey); 497 $authorInfo = $this->getUserInfo($uriComponents->did, 'did'); 498 if (!$authorInfo) return false; 499 \updateFeedCache($atUri, $feedData->value->displayName, $feedData->value->description, $this->getMediaUrl($authorInfo->pds, $uriComponents->did, $feedData->value->avatar->ref->{'$link'}), $uriComponents->did); 500 return (object) [ 501 'title' => $feedData->value->displayName, 502 'url' => '/f/'.$uriComponents->did.'/'.$uriComponents->rkey, 503 'description' => $feedData->value->description, 504 'avatar' => $this->getMediaUrl($authorInfo->pds, $uriComponents->did, $feedData->value->avatar->ref->{'$link'}), 505 'creatorDisplay' => $authorInfo->displayName, 506 'creatorDid' => $uriComponents->did, 507 'creatorPds' => $authorInfo->pds, 508 'creatorHandle' => $authorInfo->handle 509 ]; 510 return $feedData; 511 } 512 513 function getFeed(string $atUri, ?string $cursor = null, ?string $userAuth = null, ?bool $newer = false, int $limit = 15):object|bool { 514 preg_match('/^at:\/\/(did:plc:[a-z0-9\.]+)\/app.bsky.feed.generator\/([a-z0-9]+)$/', $atUri, $uriComponents); 515 $did = $uriComponents[1]; 516 $rkey = $uriComponents[2]; 517 $userInfo = $this->getUserInfo($did, 'did'); 518 519 if (!$userInfo) return false; 520 521 $feedInfo = await(async(function () use ($did, $rkey, $userInfo) { 522 return $this->getPdsData($userInfo->pds, 'com.atproto.repo.getRecord', [ 523 'repo' => $did, 524 'collection' => 'app.bsky.feed.generator', 525 'rkey' => $rkey 526 ]); 527 })); 528 529 if (!$feedInfo) return false; 530 531 if ($userAuth) { 532 // do something. i don't care rn tho 533 534 /*$feedData = $this->getPdsData($pds, 'app.bsky.feed.getFeed', [ 535 'feed' => FRONTPAGE_FEED, 536 'limit' => $limit, 537 'cursor' => $cursor 538 ]);*/ 539 return false; 540 } 541 542 $feedData = $this->getFeedSkeleton($feedInfo->value->did, $atUri, $cursor); 543 544 if ($feedData) { 545 return $feedData; 546 } 547 548 return false; 549 } 550 551 /* SANITIZATION */ 552 553 function sanitizePost(object $post, ?bool $slingshot = false, bool $getReplies = true):object|bool { 554 if ($slingshot) { 555 return $this->sanitizeSlingshotRecordPost($post, false); 556 } 557 return $this->sanitizePublicApiPost($post, false); 558 } 559 560 function getInteractions(string $uri, bool $getReplies = true): object { 561 return (object) [ 562 'replies' => $getReplies ? $this->getReplies($uri) : (object) ['count' => -1, 'records' => []], 563 'reposts' => $this->getReposts($uri), 564 'likes' => $this->getLikes($uri), 565 'quotes' => $getReplies ? $this->getQuotes($uri) : (object) ['count' => -1, 'records' => []] 566 ]; 567 } 568 569 function sanitizeSlingshotRecordPost(object $post, ?string $cursor = null, $getReplies = true):object|bool { 570 preg_match('/at:\/\/(did:plc:[a-z0-9]+)\/[a-zA-Z\.]+\/([a-z0-9]+)/', $post->uri, $uriComponents); 571 if (!$uriComponents) return false; 572 $did = $uriComponents[1]; 573 $rkey = $uriComponents[2]; 574 $authorInfo = $this->getUserInfo($did, 'did'); 575 $facets = property_exists($post->value, 'facets') ? $this->sanitizeFacets($post->value->facets) : []; 576 $interactions = $this->getInteractions($post->uri, $getReplies); 577 $replies = $interactions->replies; 578 $likes = $interactions->likes; 579 $reposts = $interactions->reposts; 580 $quotes = $interactions->quotes; 581 $ret = (object) [ 582 'author' => (object) [ 583 'displayName' => $authorInfo->displayName, 584 'handle' => $authorInfo->handle, 585 'avatar' => $authorInfo->avatar, 586 'did' => $did, 587 'profileLink' => '/u/'.$authorInfo->handle, 588 ], 589 'uri' => $post->uri, 590 'pds' => $authorInfo->pds, 591 'postId' => $rkey, 592 'postLink' => '/u/'.$authorInfo->handle.'/'.$rkey, 593 'content' => property_exists($post->value, 'text') ? $this->applyFacets($post->value->text, $facets) : '', 594 'replyCount' => $replies->total, 595 'replies' => $replies->records, 596 'repostCount' => $reposts->total, 597 'reposts' => $reposts->records, 598 'likeCount' => $likes->total, 599 'likes' => $likes->records, 600 'quoteCount' => $quotes->total, 601 'quotes' => $quotes->records, 602 'createdAt' => $post->value->createdAt, 603 'embedType' => property_exists($post->value, 'embed') ? $post->value->embed->{'$type'} : null, 604 'embeds' => property_exists($post->value, 'embed') ? $this->sanitizeEmbeds($post->value->embed, $authorInfo) : [], 605 'cursor' => $cursor 606 ]; 607 \updatePostCache($rkey, $did, $ret->content, $ret->embedType ? $ret->embedType : '', json_encode($ret->embeds), $post->value->createdAt); 608 return $ret; 609 } 610 611 function sanitizePublicApiPost(object $post, ?string $cursor = null, bool $getReplies = true):object|bool { 612 preg_match('/at:\/\/(did:plc:[a-z0-9]+)\/[a-zA-Z\.]+\/([a-z0-9]+)/', $post->uri, $rkeyMatch); 613 if (!$rkeyMatch) return false; 614 $authorData = $this->getUserInfo($post->author->did, 'did'); 615 $facets = property_exists($post->record, 'facets') ? $this->sanitizeFacets($post->record->facets) : []; 616 $processedText = $this->applyFacets($post->record->text, $facets); 617 $embedType = property_exists($post->record, 'embed') ? $post->record->embed->{'$type'} : ''; 618 $embeds = property_exists($post->record, 'embed') ? $this->sanitizeEmbeds($post->record->embed, $authorData) : []; 619 $interactions = $this->getInteractions($post->uri, $getReplies); 620 $replies = $interactions->replies; 621 $reposts = $interactions->reposts; 622 $likes = $interactions->likes; 623 $quotes = $interactions->quotes; 624 \updatePostCache($rkeyMatch[2], $authorData->did, $processedText, $embedType, json_encode($embeds), $post->record->createdAt); 625 return (object) [ 626 'author' => (object) [ 627 'displayName' => $authorData->displayName, 628 'handle' => $authorData->handle, 629 'did' => $authorData->did, 630 'avatar' => $authorData->avatar, 631 'profileLink' => '/u/'.$authorData->handle, 632 ], 633 'uri' => $post->uri, 634 'pds' => $authorData->pds, 635 'postId' => $rkeyMatch[2], 636 'postLink' => '/u/'.$authorData->handle.'/'.$rkeyMatch[2], 637 'replyCount' => $replies->total, 638 'replies' => $replies->records, 639 'repostCount' => $reposts->total, 640 'reposts' => $reposts->records, 641 'likeCount' => $likes->total, 642 'likes' => $likes->records, 643 'quoteCount' => $quotes->total, 644 'quotes' => $quotes->records, 645 'createdAt' => $post->record->createdAt, 646 'embedType' => $embedType, 647 'embeds' => $embeds 648 ]; 649 } 650 651 function sanitizeCachedPost(object $post, bool $getReplies = true): object { 652 $uri = 'at://'.$post->did.'/app.bsky.feed.post/'.$post->rkey; 653 $authorData = $this->getUserInfo($post->did, 'did'); 654 $interactions = $this->getInteractions($uri); 655 $likes = $interactions->likes; 656 $replies = $interactions->replies; 657 $reposts = $interactions->reposts; 658 $quotes = $interactions->quotes; 659 return (object) [ 660 'author' => (object) [ 661 'displayName' => $authorData->displayName, 662 'handle' => $authorData->handle, 663 'did' => $authorData->did, 664 'avatar' => $authorData->avatar, 665 'profileLink' => '/u/'.$authorData->handle, 666 ], 667 'uri' => $uri, 668 'pds' => $authorData->pds, 669 'postId' => $post->rkey, 670 'postLink' => '/u/'.$authorData->handle.'/'.$post->rkey, 671 'content' => $post->text, 672 'createdAt' => $post->createdAt, 673 'replyCount' => $replies->total, 674 'replies' => $replies->records, 675 'repostCount' => $reposts->total, 676 'reposts' => $reposts->records, 677 'likeCount' => $likes->total, 678 'likes' => $likes->records, 679 'quoteCount' => $quotes->total, 680 'quotes' => $quotes->records, 681 'embedType' => $post->embedType, 682 'embeds' => json_decode($post->embeds) 683 ]; 684 } 685 686 function sanitizeEmbeds(object $embeds, object $authorData):array|bool { 687 if ($embeds->{'$type'} === 'app.bsky.embed.images') { 688 return array_map(function ($im) use ($authorData) { 689 return (object) [ 690 'imgUrl' => $this->getMediaUrl($authorData->pds, $authorData->did, $im->image->ref->{'$link'}), 691 'alt' => $im->alt, 692 'width' => property_exists($im, 'aspectRatio') ? $im->aspectRatio->width : "auto", 693 'height' => property_exists($im, 'aspectRatio') ? $im->aspectRatio->height : "auto" 694 ]; 695 }, $embeds->images); 696 } else if ($embeds->{'$type'} === 'app.bsky.embed.video') { 697 return [ 698 (object) [ 699 'thumb' => property_exists($embeds->video, 'thumbnail') ? $embeds->video->thumbnail : '', 700 'videoUrl' => $this->getMediaUrl($authorData->pds, $authorData->did, $embeds->video->ref->{'$link'}), 701 'width' => property_exists($embeds, 'aspectRatio') ? $embeds->aspectRatio->width : "auto", 702 'height' => property_exists($embeds, 'aspectRatio') ? $embeds->aspectRatio->height : "auto" 703 ] 704 ]; 705 } else if ($embeds->{'$type'} === 'app.bsky.embed.record') { 706 $uriComponents = $this->splitAtUri($embeds->record->uri); 707 $post = $this->getSlingshotData($uriComponents->did, $uriComponents->collection, $uriComponents->rkey); 708 $sanitizedPost = $this->sanitizePost($post, true); 709 return [ 710 (object) [ 711 'post' => $sanitizedPost 712 ] 713 ]; 714 } else if ($embeds->{'$type'} === 'app.bsky.embed.external') { 715 return [ 716 (object) [ 717 'uri' => $embeds->external->uri, 718 'title' => $embeds->external->title, 719 'description' => $embeds->external->description, 720 'thumb' => property_exists($embeds->external, 'thumb') ? $this->getMediaUrl($authorData->pds, $authorData->did, $embeds->external->thumb->ref->{'$link'}) : null 721 ] 722 ]; 723 } 724 return false; 725 } 726 727 function sanitizeUserList(array $users):array { 728 $normalized = array_map(function ($rec) { 729 $hydratedRec = $rec; 730 return $this->getUserInfo($rec->did, 'did'); 731 }, $users); 732 $ret = array_values(array_filter($normalized)); 733 return $ret; 734 } 735 736 function sanitizeFacets(array $facets):array { 737 usort($facets, function($a, $b) { 738 if ($a->index->byteStart > $b->index->byteStart) { 739 return 1; 740 } 741 return -1; 742 }); 743 return array_map(function ($facet) { 744 if ($facet->features[0]->{'$type'} === "app.bsky.richtext.facet#mention") { 745 $did = $facet->features[0]->did; 746 $userInfo = $this->getPlcInfoFromRecord($did); 747 if ($userInfo) { 748 $handle = $userInfo->handle; 749 return (object) [ 750 'type' => 'mention', 751 'did' => $did, 752 'handle' => $handle, 753 'start' => $facet->index->byteStart, 754 'end' => $facet->index->byteEnd 755 ]; 756 } 757 return (object) []; 758 } else if ($facet->features[0]->{'$type'} === "app.bsky.richtext.facet#tag") { 759 return (object) [ 760 'type' => 'tag', 761 'tag' => $facet->features[0]->tag, 762 'start' => $facet->index->byteStart, 763 'end' => $facet->index->byteEnd 764 ]; 765 } else if ($facet->features[0]->{'$type'} === "app.bsky.richtext.facet#link") { 766 return (object) [ 767 'type' => 'link', 768 'link' => $facet->features[0]->uri, 769 'start' => $facet->index->byteStart, 770 'end' => $facet->index->byteEnd 771 ]; 772 } 773 return (object) []; 774 }, $facets); 775 } 776 777 /* App-Level Ban/Timeout Implementation */ 778 779 function userBanCheck(string $did): bool { 780 } 781 782 function filterRecords(array $records, string $userField):array { 783 } 784} 785 786?>