"application/x-www-form-urlencoded", "User-Agent" => \USER_AGENT_STR ]; } $connector = new React\Socket\Connector([ 'dns' => '1.1.1.1' ]); $browser = new React\Http\Browser($connector); $built_query = ""; if (!empty($query)) { $built_query = "?".http_build_query($query); } $ret = retry(fn () => $browser->request($method, $uri.$built_query, $headers, $body), 5) ->then(function (Psr\Http\Message\ResponseInterface $response) { return $response; }, function(Exception $e) { return $e; }); return $ret; } function getSlingshotData(string $repo, string $collection, ?string $rkey = 'self', ?array $params = []): object|bool { $query = [ 'collection' => $collection, 'repo' => $repo ]; if ($rkey) { $query['rkey'] = $rkey; } if ($params) { $query = array_merge($query, $params); } $ret = await($this->makeRequest("GET", $this->slingshotBase."com.atproto.repo.getRecord", $query)); if ($ret && method_exists($ret, 'getBody')) return json_decode($ret->getBody()); return false; } function resolveHandle(string $handle): string|bool { $cache = \requestUserCache($handle, 'handle'); if ($cache) return $cache->did; $ret = await($this->makeRequest("GET", $this->slingshotBase."com.atproto.identity.resolveHandle", ['handle' => $handle])); if ($ret && method_exists($ret, 'getBody') && $ret->getReasonPhrase() === "OK") { return json_decode($ret->getBody())->did; } } function getSlingshotIdentityMiniDoc(string $did): object|bool { $cache = \requestMinidocCache($did); if ($cache) return $cache; $ret = await(async(fn () => $this->makeRequest("GET", $this->slingshotBase."com.bad-example.identity.resolveMiniDoc", ['identifier' => $did]))); if ($ret && method_exists($ret, 'getBody') && $ret->getReasonPhrase() === 'OK') { $body = json_decode($ret->getBody()); \updateMinidocCache($body->handle, $body->did, $body->pds, $body->signing_key); return $body; } return false; } function getConstellationLinkData(string $target, string $collection, string $path, int $limit = 50, int $offset = 0, string $endpoint = '/xrpc/blue.microcosm.links.getBacklinks'): object|bool { $query = [ 'offset' => $offset ]; if ($endpoint === '/xrpc/blue.microcosm.links.getBacklinks') { $query['subject'] = $target; $query['source'] = $collection.":".$path; } else { $query['target'] = $target; $query['collection'] = $collection; $query['path'] = ".".$path; } $ret = await($this->makeRequest("GET", $this->constellationBase.$endpoint, $query)); if ($ret && method_exists($ret, 'getBody') && $ret->getReasonPhrase() === 'OK') { return json_decode($ret->getBody()); } return false; } function getPublicApiData(string $endpoint, array $query): object|bool { $ret = await($this->makeRequest("GET", $this->publicApiBase.$endpoint, $query)); if ($ret && method_exists($ret, 'getBody') && $ret->getReasonPhrase() === 'OK') { return json_decode($ret->getBody()); } return false; } function getPdsData(string $pds, string $endpoint, array $query, ?string $token = null): object|bool { $headers = []; if ($token) { $headers['Authorization'] = 'Bearer: '.$token; } $ret = await($this->makeRequest("GET", $pds."/xrpc/".$endpoint, $query, $headers)); if ($ret && method_exists($ret, 'getBody') && $ret->getReasonPhrase() === 'OK') { return json_decode($ret->getBody()); } return false; } function getPlcInfoFromRecord(string $did):object|bool { $cache = \requestPlcCache($did); if ($cache) { $doc = json_decode($cache->plcdoc); return (object) [ 'pds' => $doc->service[0]->serviceEndpoint, 'did' => $did, 'handle' => str_replace("at://", "", $doc->alsoKnownAs[0]) ]; } $plcdirBase = \PLC_DIRECTORY."/"; $ret = await($this->makeRequest("GET", $plcdirBase.$did)); if ($ret && method_exists($ret, 'getBody') && $ret->getReasonPhrase() === 'OK') { $body = $ret->getBody(); \updatePlcCache($did, (string) $body); $resp = json_decode((string) $body); if ($resp && property_exists($resp, 'service') && is_array($resp->service) && count($resp->service) > 0 && property_exists($resp->service[0], 'serviceEndpoint')) { $handle = ""; if (property_exists($resp, 'alsoKnownAs') && is_array($resp->alsoKnownAs) && count($resp->alsoKnownAs) > 0) { $handle = $resp->alsoKnownAs[0]; } return (object) [ 'pds' => $resp->service[0]->serviceEndpoint, 'did' => $did, 'handle' => str_replace("at://", "", $handle) ]; } } return false; } function getFeedSkeleton(string $didweb, string $atUri, ?string $offset = null): object|bool { $query = [ 'feed' => $atUri ]; if ($offset) { $query['offset'] = $offset; } preg_match('/^did:web:([a-z0-9\.\-]+)$/', $didweb, $didWebBase); if (!$didWebBase) return false; $base = "https://".$didWebBase[1]; $ret = await($this->makeRequest('GET', $base.'/xrpc/app.bsky.feed.getFeedSkeleton', ['feed' => $atUri])); if (!$ret || $ret->getReasonPhrase() !== 'OK') return false; $body = json_decode((string) $ret->getBody()); if (!$body->feed || count($body->feed) === 0) return []; return $body; } /* AUTH */ /* HELPERS */ function getMediaUrl(string $pds, string $did, string $cid): string { return $pds."/xrpc/com.atproto.sync.getBlob?did=".$did."&cid=".$cid; } function splitAtUri(string $atUri): object|bool { preg_match('/at:\/\/(did:plc:[a-z0-9]+)\/([a-zA-Z\.]+)\/([a-z0-9]+)/', $atUri, $uriComponents); if (!$uriComponents) return false; return (object) [ 'did' => $uriComponents[1], 'collection' => $uriComponents[2], 'rkey' => $uriComponents[3] ]; } function applyFacets(string $text, array $facets): string { $ret = $text; $split_str = str_split($text); $additions = 0; foreach ($facets as $facet) { if ($facet->type === "tag") { $hashtagLink = '/h/'.$facet->tag; $splicedpart = array_splice($split_str, $facet->start + $additions, 0, ''); $additions++; array_splice($split_str, $facet->end + $additions, 0, ''); $additions++; } else if ($facet->type === "mention") { $profileLink = '/u/'.$facet->handle; $replaceString = '@'.$facet->handle.''; array_splice($split_str, $facet->start + $additions, $facet->end - $facet->start, $replaceString); $additions += 1 - ($facet->end - $facet->start); } else if ($facet->type === "link") { $pat = '/('.preg_quote($facet->link, '/').')/'; array_splice($split_str, $facet->start + $additions, 0, ''); $additions++; array_splice($split_str, $facet->end + $additions, 0, ''); $additions++; } // there might be more idk. like for bold or whatever } return join("", $split_str); } /* USER INFO */ function getUserInfo(string $identifier, string $type = 'handle'): object|bool { $cache = \requestUserCache($identifier, $type); if ($cache) return $cache; $id = $identifier; $did = null; $id = null; $pds = null; if ($type === 'did') { $did = $identifier; $userData = await(async(fn () => $this->getSlingshotIdentityMiniDoc($did))); if (!$userData || !property_exists($userData, 'did')) return false; $did = $userData->did; $pds = $userData->pds; } else { $did = await(async(fn() => $this->resolveHandle($identifier))); $userData = await(async(fn () => $this->getSlingshotIdentityMiniDoc($did))); if (!$userData) return false; $pds = $userData->pds; } $userInfo = await(async(fn () => $this->getSlingshotData($did, 'app.bsky.actor.profile', 'self'))); if ($userInfo) { $avatar = property_exists($userInfo->value, 'avatar') ? $this->getMediaUrl($pds, $did, $userInfo->value->avatar->ref->{'$link'}) : null; $banner = property_exists($userInfo->value, 'banner') ? $this->getMediaUrl($pds, $did, $userInfo->value->banner->ref->{'$link'}) : null; $pinned = property_exists($userInfo->value, 'pinnedPost') ? $userInfo->value->pinnedPost->uri : null; $description = property_exists($userInfo->value, 'description') ? $userInfo->value->description : ""; \updateUserCache($userData->handle, $did, $userInfo->value->displayName, $pds, $avatar, $banner, $description, $pinned); return (object) [ 'handle' => $userData->handle, 'displayName' => $userInfo->value->displayName, 'did' => $did, 'pds' => $pds, 'description' => $description, 'avatar' => $avatar, 'banner' => $banner, 'pinnedPost' => $pinned ]; } return false; } /* GETTING USER INFO */ function getUserDidAndProvider(string $user):object|bool { preg_match('/^did:plc:[a-z0-9]+$/', $user, $didpat); // if they gave us a DID here just return it and grab the PDS if ($didpat) { return $this->getPlcInfoFromRecord($user); } // check DNS first $dnsRecords = dns_get_record('_atproto.'.$user, DNS_TXT); if ($dnsRecords) { $dnsRecords = array_filter($dnsRecords, function($v) { preg_match('/^did=(did:plc:[a-z0-9]+)$/', $v['txt'], $dnsmatch); if ($dnsmatch) { return true; } return false; }); preg_match('/^did=(did:plc:[a-z0-9]+)$/', $dnsRecords[0]['txt'], $dnsmatch); $did = $dnsmatch[1]; return $this->getPlcInfoFromRecord($did); } // try http verification via .well-known $httpRecord = file_get_contents('https://'.$user.'/.well-known/atproto-did'); if ($httpRecord) { $did = $httpRecord; return $this->getPlcInfoFromRecord($did); } return false; } /* POSTS */ function getPost(string $identifier, string $rkey, bool $slingshot = false, bool $getReplies = true):object|bool { $cache = \requestPostCache($rkey); if ($cache && !empty($cache)) { return $this->sanitizeCachedPost($cache, $getReplies); } $ret = $this->getSlingshotData($identifier, "app.bsky.feed.post", $rkey); if (!$ret) return false; $post = $this->sanitizePost($ret, true, false); return $post; } function getLikes(string $post):object { $ret = $this->getConstellationLinkData($post, "app.bsky.feed.like", "subject.uri"); if ($ret && property_exists($ret, 'records') && $ret->total !== 0) { $likeUsers = await(batch( $ret->records, function ($batch) { return async(function () use ($batch) { return array_map(fn ($user) => $this->getUserInfo($user->did, 'did'), $batch); }); }, 50, 2)); if ($ret && property_exists($ret, 'total')) return (object) ['total' => $ret->total, 'records' => $likeUsers, 'cursor' => $ret->cursor]; } return (object) [ 'total' => 0, 'records' => [], 'cursor' => 0 ]; } function getReposts(string $post):object { $ret = $this->getConstellationLinkData($post, "app.bsky.feed.repost", "subject.uri"); if ($ret && property_exists($ret, 'records') && $ret->total !== 0) { $repUsers = await(batch( $ret->records, function ($batch) { return async(function() use ($batch) { return array_map(fn ($user) => $this->getUserInfo($user->did, 'did'), $batch); }); }, 50, 2)); if ($ret && property_exists($ret, 'total')) return (object) ['total' => $ret->total, 'records' => $repUsers, 'cursor' => $ret->cursor]; } return (object) [ 'total' => 0, 'records' => [], 'cursor' => 0 ]; } function getQuotes(string $post):object { $ret = $this->getConstellationLinkData($post, "app.bsky.feed.post", "embed.record.record.uri"); if ($ret && property_exists($ret, 'records') && $ret->total !== 0) { $quoteRecords = await(batch( $ret->records, function($batch) { return async(function () use ($batch) { return array_map(function ($rec) { return $this->getPost($rec->did, $rec->rkey, true, false); }, $batch); }); }, 50, 2)); if ($ret && property_exists($ret, 'total')) return (object) ['total' => $ret->total, 'records' => $quoteRecords, 'cursor' => $ret->cursor]; } return (object) [ 'total' => 0, 'records' => [], 'cursor' => 0 ]; } function getReplies(string $post, int $recurseLevel = 0):object { $ret = $this->getConstellationLinkData($post, "app.bsky.feed.post", "reply.parent.uri"); if ($ret && property_exists($ret, 'records') && is_array($ret->records) && $ret->total !== 0) { $replyRecords = await(batch( $ret->records, function ($batch) { return async(function () use ($batch) { return array_map(fn($rec) => $this->getPost($rec->did, $rec->rkey, true, false), $batch); }); }, 50, 2)); if ($ret && property_exists($ret, 'total')) return (object) ['total' => $ret->total, 'records' => $replyRecords, 'cursor' => $ret->cursor]; } return (object) [ 'total' => 0, 'records' => [], 'cursor' => 0 ]; } function getUserPosts(string $did, ?string $cursor = null, bool $auth = false, bool $newer = false): object|bool { $userData = $this->getUserInfo($did, 'did'); if (!$userData) return false; $postData = await(async(function () use ($did, $cursor, $userData) { return $this->getPdsData($userData->pds, 'com.atproto.repo.listRecords', [ 'repo' => $did, 'collection' => 'app.bsky.feed.post', 'limit' => 20, 'cursor' => $cursor ]); })); if (!$postData) return false; return $postData; } function getSearchResults(string $query):array { $ret = $this->getPublicApiData('app.bsky.feed.searchPosts', [ 'q' => $query, 'tag' => $query ]); return $this->sanitizePosts($ret->posts); } function getFeedInfo(string $atUri): object|bool { $cache = \requestFeedCache($atUri); if ($cache) { $uriComponents = $this->splitAtUri($cache->atUri); $authorInfo = $this->getUserInfo($cache->creatorDid, 'did'); if (!$authorInfo) return false; return (object) [ 'title' => $cache->title, 'url' => '/f/'.$cache->creatorDid.'/'.$uriComponents->rkey, 'description' => $cache->description, 'avatar' => $cache->avatar, 'creatorDisplay' => $authorInfo->displayName, 'creatorDid' => $uriComponents->did, 'creatorPds' => $authorInfo->pds, 'creatorHandle' => $authorInfo->handle ]; } $uriComponents = $this->splitAtUri($atUri); if (!$uriComponents) return false; $feedData = $this->getSlingshotData($uriComponents->did, $uriComponents->collection, $uriComponents->rkey); $authorInfo = $this->getUserInfo($uriComponents->did, 'did'); if (!$authorInfo) return false; \updateFeedCache($atUri, $feedData->value->displayName, $feedData->value->description, $this->getMediaUrl($authorInfo->pds, $uriComponents->did, $feedData->value->avatar->ref->{'$link'}), $uriComponents->did); return (object) [ 'title' => $feedData->value->displayName, 'url' => '/f/'.$uriComponents->did.'/'.$uriComponents->rkey, 'description' => $feedData->value->description, 'avatar' => $this->getMediaUrl($authorInfo->pds, $uriComponents->did, $feedData->value->avatar->ref->{'$link'}), 'creatorDisplay' => $authorInfo->displayName, 'creatorDid' => $uriComponents->did, 'creatorPds' => $authorInfo->pds, 'creatorHandle' => $authorInfo->handle ]; return $feedData; } function getFeed(string $atUri, ?string $cursor = null, ?string $userAuth = null, ?bool $newer = false, int $limit = 15):object|bool { preg_match('/^at:\/\/(did:plc:[a-z0-9\.]+)\/app.bsky.feed.generator\/([a-z0-9]+)$/', $atUri, $uriComponents); $did = $uriComponents[1]; $rkey = $uriComponents[2]; $userInfo = $this->getUserInfo($did, 'did'); if (!$userInfo) return false; $feedInfo = await(async(function () use ($did, $rkey, $userInfo) { return $this->getPdsData($userInfo->pds, 'com.atproto.repo.getRecord', [ 'repo' => $did, 'collection' => 'app.bsky.feed.generator', 'rkey' => $rkey ]); })); if (!$feedInfo) return false; if ($userAuth) { // do something. i don't care rn tho /*$feedData = $this->getPdsData($pds, 'app.bsky.feed.getFeed', [ 'feed' => FRONTPAGE_FEED, 'limit' => $limit, 'cursor' => $cursor ]);*/ return false; } $feedData = $this->getFeedSkeleton($feedInfo->value->did, $atUri, $cursor); if ($feedData) { return $feedData; } return false; } /* SANITIZATION */ function sanitizePost(object $post, ?bool $slingshot = false, bool $getReplies = true):object|bool { if ($slingshot) { return $this->sanitizeSlingshotRecordPost($post, false); } return $this->sanitizePublicApiPost($post, false); } function sanitizeSlingshotRecordPost(object $post, ?string $cursor = null, $getReplies = true):object|bool { preg_match('/at:\/\/(did:plc:[a-z0-9]+)\/[a-zA-Z\.]+\/([a-z0-9]+)/', $post->uri, $uriComponents); if (!$uriComponents) return false; $did = $uriComponents[1]; $rkey = $uriComponents[2]; $authorInfo = $this->getUserInfo($did, 'did'); $facets = property_exists($post->value, 'facets') ? $this->sanitizeFacets($post->value->facets) : []; $ret = (object) [ 'author' => (object) [ 'displayName' => $authorInfo->displayName, 'handle' => $authorInfo->handle, 'avatar' => $authorInfo->avatar, 'did' => $did, 'profileLink' => '/u/'.$authorInfo->handle, ], 'uri' => $post->uri, 'pds' => $authorInfo->pds, 'postId' => $rkey, 'postLink' => '/u/'.$authorInfo->handle.'/'.$rkey, 'content' => property_exists($post->value, 'text') ? $this->applyFacets($post->value->text, $facets) : '', 'createdAt' => $post->value->createdAt, 'embedType' => property_exists($post->value, 'embed') ? $post->value->embed->{'$type'} : null, 'embeds' => property_exists($post->value, 'embed') ? $this->sanitizeEmbeds($post->value->embed, $authorInfo) : [], 'cursor' => $cursor ]; \updatePostCache($rkey, $did, $ret->content, $ret->embedType ? $ret->embedType : '', json_encode($ret->embeds), $post->value->createdAt); return $ret; } function sanitizePublicApiPost(object $post, ?string $cursor = null, bool $getReplies = true):object|bool { preg_match('/at:\/\/(did:plc:[a-z0-9]+)\/[a-zA-Z\.]+\/([a-z0-9]+)/', $post->uri, $rkeyMatch); if (!$rkeyMatch) return false; $authorData = $this->getUserInfo($post->author->did, 'did'); $facets = property_exists($post->record, 'facets') ? $this->sanitizeFacets($post->record->facets) : []; $processedText = $this->applyFacets($post->record->text, $facets); $embedType = property_exists($post->record, 'embed') ? $post->record->embed->{'$type'} : ''; $embeds = property_exists($post->record, 'embed') ? $this->sanitizeEmbeds($post->record->embed, $authorData) : []; \updatePostCache($rkeyMatch[2], $authorData->did, $processedText, $embedType, json_encode($embeds), $post->record->createdAt); return (object) [ 'author' => (object) [ 'displayName' => $authorData->displayName, 'handle' => $authorData->handle, 'did' => $authorData->did, 'avatar' => $authorData->avatar, 'profileLink' => '/u/'.$authorData->handle, ], 'uri' => $post->uri, 'pds' => $authorData->pds, 'postId' => $rkeyMatch[2], 'postLink' => '/u/'.$authorData->handle.'/'.$rkeyMatch[2], 'createdAt' => $post->record->createdAt, 'embedType' => $embedType, 'embeds' => $embeds ]; } function sanitizeCachedPost(object $post, bool $getReplies = true): object { $uri = 'at://'.$post->did.'/app.bsky.feed.post/'.$post->rkey; $authorData = $this->getUserInfo($post->did, 'did'); return (object) [ 'author' => (object) [ 'displayName' => $authorData->displayName, 'handle' => $authorData->handle, 'did' => $authorData->did, 'avatar' => $authorData->avatar, 'profileLink' => '/u/'.$authorData->handle, ], 'uri' => $uri, 'pds' => $authorData->pds, 'postId' => $post->rkey, 'postLink' => '/u/'.$authorData->handle.'/'.$post->rkey, 'content' => $post->text, 'createdAt' => $post->createdAt, 'embedType' => $post->embedType, 'embeds' => json_decode($post->embeds) ]; } function sanitizeEmbeds(object $embeds, object $authorData):array|bool { if ($embeds->{'$type'} === 'app.bsky.embed.images') { return array_map(function ($im) use ($authorData) { return (object) [ 'imgUrl' => $this->getMediaUrl($authorData->pds, $authorData->did, $im->image->ref->{'$link'}), 'alt' => $im->alt, 'width' => property_exists($im, 'aspectRatio') ? $im->aspectRatio->width : "auto", 'height' => property_exists($im, 'aspectRatio') ? $im->aspectRatio->height : "auto" ]; }, $embeds->images); } else if ($embeds->{'$type'} === 'app.bsky.embed.video') { return [ (object) [ 'thumb' => property_exists($embeds->video, 'thumbnail') ? $embeds->video->thumbnail : '', 'videoUrl' => $this->getMediaUrl($authorData->pds, $authorData->did, $embeds->video->ref->{'$link'}), 'width' => property_exists($embeds, 'aspectRatio') ? $embeds->aspectRatio->width : "auto", 'height' => property_exists($embeds, 'aspectRatio') ? $embeds->aspectRatio->height : "auto" ] ]; } else if ($embeds->{'$type'} === 'app.bsky.embed.record') { $uriComponents = $this->splitAtUri($embeds->record->uri); $post = $this->getSlingshotData($uriComponents->did, $uriComponents->collection, $uriComponents->rkey); $sanitizedPost = $this->sanitizePost($post, true); return [ (object) [ 'post' => $sanitizedPost ] ]; } else if ($embeds->{'$type'} === 'app.bsky.embed.external') { return [ (object) [ 'uri' => $embeds->external->uri, 'title' => $embeds->external->title, 'description' => $embeds->external->description, 'thumb' => property_exists($embeds->external, 'thumb') ? $this->getMediaUrl($authorData->pds, $authorData->did, $embeds->external->thumb->ref->{'$link'}) : null ] ]; } return false; } function sanitizeUserList(array $users):array { $normalized = array_map(function ($rec) { $hydratedRec = $rec; return $this->getUserInfo($rec->did, 'did'); }, $users); $ret = array_values(array_filter($normalized)); return $ret; } function sanitizeFacets(array $facets):array { usort($facets, function($a, $b) { if ($a->index->byteStart > $b->index->byteStart) { return 1; } return -1; }); return array_map(function ($facet) { if ($facet->features[0]->{'$type'} === "app.bsky.richtext.facet#mention") { $did = $facet->features[0]->did; $userInfo = $this->getPlcInfoFromRecord($did); if ($userInfo) { $handle = $userInfo->handle; return (object) [ 'type' => 'mention', 'did' => $did, 'handle' => $handle, 'start' => $facet->index->byteStart, 'end' => $facet->index->byteEnd ]; } return (object) []; } else if ($facet->features[0]->{'$type'} === "app.bsky.richtext.facet#tag") { return (object) [ 'type' => 'tag', 'tag' => $facet->features[0]->tag, 'start' => $facet->index->byteStart, 'end' => $facet->index->byteEnd ]; } else if ($facet->features[0]->{'$type'} === "app.bsky.richtext.facet#link") { return (object) [ 'type' => 'link', 'link' => $facet->features[0]->uri, 'start' => $facet->index->byteStart, 'end' => $facet->index->byteEnd ]; } return (object) []; }, $facets); } /* App-Level Ban/Timeout Implementation */ function userBanCheck(string $did): bool { } function filterRecords(array $records, string $userField):array { } } ?>