"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 {
}
}
?>