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