friendship ended with social-app. php is my new best friend
1<?php
2
3error_reporting(E_ALL);
4ini_set('display_errors', 'On');
5ini_set('log_errors_max_len', '0');
6
7require_once('config.php');
8require_once('vendor/autoload.php');
9//if (DB_TYPE === 'sqlite') require_once('db.php');
10if (DB_TYPE === 'mysql') require_once('maria-db.php');
11
12use Matrix\Async;
13use React\Http\Browser;
14use React\Promise\Promise;
15use React\Async\Parallel;
16use React\Dns\Config\Config;
17use GuzzleHttp\Client;
18use GuzzleHttp\Query;
19use GuzzleHttp\Middleware;
20use GuzzleHttp\Handler\CurlHandler;
21use GuzzleHttp\HandlerStack;
22use GuzzleHttp\Exception\ConnectException;
23use GuzzleHttp\Psr7\Request;
24use GuzzleHttp\TransferStats;
25use Psr\Http\Message\RequestInterface;
26use Psr\Http\Message\ResponseInterface;
27use Fusonic\OpenGraph\Consumer;
28
29class BskyToucher {
30 private $bskyApiBase = 'https://public.api.bsky.app/xrpc/';
31 private $slingshotBase = \SLINGSHOT_INSTANCE.'/xrpc/';
32 private $constellationBase = \CONSTELLATION_INSTANCE;
33 private $plcDirectoryBase = \PLC_DIRECTORY;
34 private $publicApiBase = \PUBLIC_API.'/xrpc/';
35
36 /* GENERAL QUERIES */
37
38 function makeRequest(string $method, string $uri, array $query = [], array $headers = [], string $body = ''): Promise|Exception {
39 if ($method === 'GET') {
40 $headers = [
41 "Content-Type" => "application/x-www-form-urlencoded",
42 "User-Agent" => \USER_AGENT_STR
43 ];
44 }
45 $connector = new React\Socket\Connector([
46 'dns' => '1.1.1.1'
47 ]);
48 $browser = new React\Http\Browser($connector);
49 $built_query = "";
50 if (!empty($query)) {
51 $built_query = "?".http_build_query($query);
52 }
53 $ret = retry(fn () => $browser->request($method, $uri.$built_query, $headers, $body), 5)
54 ->then(function (Psr\Http\Message\ResponseInterface $response) {
55 return $response;
56 }, function(Exception $e) {
57 return $e;
58 });
59 return $ret;
60 }
61
62 function getSlingshotData(string $repo, string $collection, ?string $rkey = 'self', ?array $params = []): object|bool {
63 $query = [
64 'collection' => $collection,
65 'repo' => $repo
66 ];
67 if ($rkey) {
68 $query['rkey'] = $rkey;
69 }
70 if ($params) {
71 $query = array_merge($query, $params);
72 }
73 $ret = await($this->makeRequest("GET", $this->slingshotBase."com.atproto.repo.getRecord", $query));
74 if ($ret && method_exists($ret, 'getBody')) return json_decode($ret->getBody());
75 return false;
76 }
77
78 function resolveHandle(string $handle): string|bool {
79 $cache = \requestUserCache($handle, 'handle');
80 if ($cache) return $cache->did;
81 $ret = await($this->makeRequest("GET", $this->slingshotBase."com.atproto.identity.resolveHandle", ['handle' => $handle]));
82 if ($ret && method_exists($ret, 'getBody') && $ret->getReasonPhrase() === "OK") {
83 return json_decode($ret->getBody())->did;
84 }
85 }
86
87 function getSlingshotIdentityMiniDoc(string $did): object|bool {
88 $cache = \requestMinidocCache($did);
89 if ($cache) return $cache;
90 $ret = await(async(fn () => $this->makeRequest("GET", $this->slingshotBase."com.bad-example.identity.resolveMiniDoc", ['identifier' => $did])));
91
92 if ($ret && method_exists($ret, 'getBody') && $ret->getReasonPhrase() === 'OK') {
93 $body = json_decode($ret->getBody());
94 \updateMinidocCache($body->handle, $body->did, $body->pds, $body->signing_key);
95 return $body;
96 }
97
98 return false;
99 }
100
101 function getConstellationLinkData(string $target, string $collection, string $path, int $limit = 50, int $offset = 0, string $endpoint = '/xrpc/blue.microcosm.links.getBacklinks'): object|bool {
102 $query = [
103 'offset' => $offset
104 ];
105 if ($endpoint === '/xrpc/blue.microcosm.links.getBacklinks') {
106 $query['subject'] = $target;
107 $query['source'] = $collection.":".$path;
108 } else {
109 $query['target'] = $target;
110 $query['collection'] = $collection;
111 $query['path'] = ".".$path;
112 }
113 $ret = await($this->makeRequest("GET", $this->constellationBase.$endpoint, $query));
114 if ($ret && method_exists($ret, 'getBody') && $ret->getReasonPhrase() === 'OK') {
115 return json_decode($ret->getBody());
116 }
117
118 return false;
119 }
120
121 function getPublicApiData(string $endpoint, array $query): object|bool {
122 $ret = await($this->makeRequest("GET", $this->publicApiBase.$endpoint, $query));
123 if ($ret && method_exists($ret, 'getBody') && $ret->getReasonPhrase() === 'OK') {
124 return json_decode($ret->getBody());
125 }
126
127 return false;
128 }
129
130 function getPdsData(string $pds, string $endpoint, array $query, ?string $token = null): object|bool {
131 $headers = [];
132 if ($token) {
133 $headers['Authorization'] = 'Bearer: '.$token;
134 }
135 $ret = await($this->makeRequest("GET", $pds."/xrpc/".$endpoint, $query, $headers));
136
137 if ($ret && method_exists($ret, 'getBody') && $ret->getReasonPhrase() === 'OK') {
138 return json_decode($ret->getBody());
139 }
140
141 return false;
142 }
143
144 function getPlcInfoFromRecord(string $did):object|bool {
145 $cache = \requestPlcCache($did);
146 if ($cache) {
147 $doc = json_decode($cache->plcdoc);
148 return (object) [
149 'pds' => $doc->service[0]->serviceEndpoint,
150 'did' => $did,
151 'handle' => str_replace("at://", "", $doc->alsoKnownAs[0])
152 ];
153 }
154
155 $plcdirBase = \PLC_DIRECTORY."/";
156 $ret = await($this->makeRequest("GET", $plcdirBase.$did));
157 if ($ret && method_exists($ret, 'getBody') && $ret->getReasonPhrase() === 'OK') {
158 $body = $ret->getBody();
159 \updatePlcCache($did, (string) $body);
160 $resp = json_decode((string) $body);
161
162 if ($resp && property_exists($resp, 'service') && is_array($resp->service) && count($resp->service) > 0 && property_exists($resp->service[0], 'serviceEndpoint')) {
163 $handle = "";
164 if (property_exists($resp, 'alsoKnownAs') && is_array($resp->alsoKnownAs) && count($resp->alsoKnownAs) > 0) {
165 $handle = $resp->alsoKnownAs[0];
166 }
167 return (object) [
168 'pds' => $resp->service[0]->serviceEndpoint,
169 'did' => $did,
170 'handle' => str_replace("at://", "", $handle)
171 ];
172 }
173 }
174
175 return false;
176 }
177
178 function getFeedSkeleton(string $didweb, string $atUri, ?string $offset = null): object|bool {
179 $query = [
180 'feed' => $atUri
181 ];
182 if ($offset) {
183 $query['offset'] = $offset;
184 }
185 preg_match('/^did:web:([a-z0-9\.\-]+)$/', $didweb, $didWebBase);
186 if (!$didWebBase) return false;
187 $base = "https://".$didWebBase[1];
188 $ret = await($this->makeRequest('GET', $base.'/xrpc/app.bsky.feed.getFeedSkeleton', ['feed' => $atUri]));
189 if (!$ret || $ret->getReasonPhrase() !== 'OK') return false;
190 $body = json_decode((string) $ret->getBody());
191 if (!$body->feed || count($body->feed) === 0) return [];
192 return $body;
193 }
194
195 /* AUTH */
196
197 /* HELPERS */
198
199 function getMediaUrl(string $pds, string $did, string $cid): string {
200 return $pds."/xrpc/com.atproto.sync.getBlob?did=".$did."&cid=".$cid;
201 }
202
203 function splitAtUri(string $atUri): object|bool {
204 preg_match('/at:\/\/(did:plc:[a-z0-9]+)\/([a-zA-Z\.]+)\/([a-z0-9]+)/', $atUri, $uriComponents);
205 if (!$uriComponents) return false;
206 return (object) [
207 'did' => $uriComponents[1],
208 'collection' => $uriComponents[2],
209 'rkey' => $uriComponents[3]
210 ];
211 }
212
213 function applyFacets(string $text, array $facets): string {
214 $ret = $text;
215 $split_str = str_split($text);
216 $additions = 0;
217 foreach ($facets as $facet) {
218 if ($facet->type === "tag") {
219 $hashtagLink = '/h/'.$facet->tag;
220 $splicedpart = array_splice($split_str, $facet->start + $additions, 0, '<a href="'.$hashtagLink.'">');
221 $additions++;
222 array_splice($split_str, $facet->end + $additions, 0, '</a>');
223 $additions++;
224 } else if ($facet->type === "mention") {
225 $profileLink = '/u/'.$facet->handle;
226 $replaceString = '<a href="'.$profileLink.'">@'.$facet->handle.'</a>';
227 array_splice($split_str, $facet->start + $additions, $facet->end - $facet->start, $replaceString);
228 $additions += 1 - ($facet->end - $facet->start);
229 } else if ($facet->type === "link") {
230 $pat = '/('.preg_quote($facet->link, '/').')/';
231 array_splice($split_str, $facet->start + $additions, 0, '<a href="'.$facet->link.'" rel="external" target="_blank">');
232 $additions++;
233 array_splice($split_str, $facet->end + $additions, 0, '</a>');
234 $additions++;
235 }
236 // there might be more idk. like for bold or whatever
237 }
238 return join("", $split_str);
239 }
240
241 /* USER INFO */
242
243 function getUserInfo(string $identifier, string $type = 'handle'): object|bool {
244 $cache = \requestUserCache($identifier, $type);
245 if ($cache) return $cache;
246 $id = $identifier;
247 $did = null;
248 $id = null;
249 $pds = null;
250 if ($type === 'did') {
251 $did = $identifier;
252 $userData = await(async(fn () => $this->getSlingshotIdentityMiniDoc($did)));
253 if (!$userData || !property_exists($userData, 'did')) return false;
254 $did = $userData->did;
255 $pds = $userData->pds;
256 } else {
257 $did = await(async(fn() => $this->resolveHandle($identifier)));
258 $userData = await(async(fn () => $this->getSlingshotIdentityMiniDoc($did)));
259 if (!$userData) return false;
260 $pds = $userData->pds;
261 }
262 $userInfo = await(async(fn () => $this->getSlingshotData($did, 'app.bsky.actor.profile', 'self')));
263 if ($userInfo) {
264 $avatar = property_exists($userInfo->value, 'avatar') ? $this->getMediaUrl($pds, $did, $userInfo->value->avatar->ref->{'$link'}) : null;
265 $banner = property_exists($userInfo->value, 'banner') ? $this->getMediaUrl($pds, $did, $userInfo->value->banner->ref->{'$link'}) : null;
266 $pinned = property_exists($userInfo->value, 'pinnedPost') ? $userInfo->value->pinnedPost->uri : null;
267 $description = property_exists($userInfo->value, 'description') ? $userInfo->value->description : "";
268 \updateUserCache($userData->handle, $did, $userInfo->value->displayName, $pds, $avatar, $banner, $description, $pinned);
269 return (object) [
270 'handle' => $userData->handle,
271 'displayName' => $userInfo->value->displayName,
272 'did' => $did,
273 'pds' => $pds,
274 'description' => $description,
275 'avatar' => $avatar,
276 'banner' => $banner,
277 'pinnedPost' => $pinned
278 ];
279 }
280 return false;
281 }
282
283 /* GETTING USER INFO */
284
285 function getUserDidAndProvider(string $user):object|bool {
286 preg_match('/^did:plc:[a-z0-9]+$/', $user, $didpat);
287 // if they gave us a DID here just return it and grab the PDS
288 if ($didpat) {
289 return $this->getPlcInfoFromRecord($user);
290 }
291
292 // check DNS first
293 $dnsRecords = dns_get_record('_atproto.'.$user, DNS_TXT);
294 if ($dnsRecords) {
295 $dnsRecords = array_filter($dnsRecords, function($v) {
296 preg_match('/^did=(did:plc:[a-z0-9]+)$/', $v['txt'], $dnsmatch);
297 if ($dnsmatch) {
298 return true;
299 }
300 return false;
301 });
302 preg_match('/^did=(did:plc:[a-z0-9]+)$/', $dnsRecords[0]['txt'], $dnsmatch);
303 $did = $dnsmatch[1];
304 return $this->getPlcInfoFromRecord($did);
305 }
306 // try http verification via .well-known
307 $httpRecord = file_get_contents('https://'.$user.'/.well-known/atproto-did');
308 if ($httpRecord) {
309 $did = $httpRecord;
310 return $this->getPlcInfoFromRecord($did);
311 }
312 return false;
313 }
314
315 /* POSTS */
316
317 function getPost(string $identifier, string $rkey, bool $slingshot = false, bool $getReplies = true):object|bool {
318 $cache = \requestPostCache($rkey);
319 if ($cache && !empty($cache)) {
320 return $this->sanitizeCachedPost($cache, $getReplies);
321 }
322
323 $ret = $this->getSlingshotData($identifier, "app.bsky.feed.post", $rkey);
324 if (!$ret) return false;
325 $post = $this->sanitizePost($ret, true, false);
326 return $post;
327 }
328
329 function getLikes(string $post):object {
330 $ret = $this->getConstellationLinkData($post, "app.bsky.feed.like", "subject.uri");
331 if ($ret && property_exists($ret, 'records') && $ret->total !== 0) {
332 $likeUsers = await(batch(
333 $ret->records,
334 function ($batch) {
335 return async(function () use ($batch) {
336 return array_map(fn ($user) => $this->getUserInfo($user->did, 'did'), $batch);
337 });
338 }, 50, 2));
339 if ($ret && property_exists($ret, 'total')) return (object) ['total' => $ret->total, 'records' => $likeUsers, 'cursor' => $ret->cursor];
340 }
341 return (object) [
342 'total' => 0,
343 'records' => [],
344 'cursor' => 0
345 ];
346 }
347
348 function getReposts(string $post):object {
349 $ret = $this->getConstellationLinkData($post, "app.bsky.feed.repost", "subject.uri");
350 if ($ret && property_exists($ret, 'records') && $ret->total !== 0) {
351 $repUsers = await(batch(
352 $ret->records,
353 function ($batch) {
354 return async(function() use ($batch) {
355 return array_map(fn ($user) => $this->getUserInfo($user->did, 'did'), $batch);
356 });
357 }, 50, 2));
358 if ($ret && property_exists($ret, 'total')) return (object) ['total' => $ret->total, 'records' => $repUsers, 'cursor' => $ret->cursor];
359 }
360 return (object) [
361 'total' => 0,
362 'records' => [],
363 'cursor' => 0
364 ];
365 }
366
367 function getQuotes(string $post):object {
368 $ret = $this->getConstellationLinkData($post, "app.bsky.feed.post", "embed.record.record.uri");
369 if ($ret && property_exists($ret, 'records') && $ret->total !== 0) {
370 $quoteRecords = await(batch(
371 $ret->records,
372 function($batch) {
373 return async(function () use ($batch) {
374 return array_map(function ($rec) {
375 return $this->getPost($rec->did, $rec->rkey, true, false);
376 }, $batch);
377 });
378 }, 50, 2));
379 if ($ret && property_exists($ret, 'total')) return (object) ['total' => $ret->total, 'records' => $quoteRecords, 'cursor' => $ret->cursor];
380 }
381 return (object) [
382 'total' => 0,
383 'records' => [],
384 'cursor' => 0
385 ];
386 }
387
388 function getReplies(string $post, int $recurseLevel = 0):object {
389 $ret = $this->getConstellationLinkData($post, "app.bsky.feed.post", "reply.parent.uri");
390 if ($ret && property_exists($ret, 'records') && is_array($ret->records) && $ret->total !== 0) {
391 $replyRecords = await(batch(
392 $ret->records, function ($batch) {
393 return async(function () use ($batch) {
394 return array_map(fn($rec) => $this->getPost($rec->did, $rec->rkey, true, false), $batch);
395 });
396 }, 50, 2));
397 if ($ret && property_exists($ret, 'total')) return (object) ['total' => $ret->total, 'records' => $replyRecords, 'cursor' => $ret->cursor];
398 }
399 return (object) [
400 'total' => 0,
401 'records' => [],
402 'cursor' => 0
403 ];
404 }
405
406 function getUserPosts(string $did, ?string $cursor = null, bool $auth = false, bool $newer = false): object|bool {
407 $userData = $this->getUserInfo($did, 'did');
408 if (!$userData) return false;
409 $postData = await(async(function () use ($did, $cursor, $userData) {
410 return $this->getPdsData($userData->pds, 'com.atproto.repo.listRecords', [
411 'repo' => $did,
412 'collection' => 'app.bsky.feed.post',
413 'limit' => 20,
414 'cursor' => $cursor
415 ]);
416 }));
417 if (!$postData) return false;
418 return $postData;
419 }
420
421 function getSearchResults(string $query):array {
422 $ret = $this->getPublicApiData('app.bsky.feed.searchPosts', [
423 'q' => $query,
424 'tag' => $query
425 ]);
426 return $this->sanitizePosts($ret->posts);
427 }
428
429 function getFeedInfo(string $atUri): object|bool {
430 $cache = \requestFeedCache($atUri);
431 if ($cache) {
432 $uriComponents = $this->splitAtUri($cache->atUri);
433 $authorInfo = $this->getUserInfo($cache->creatorDid, 'did');
434 if (!$authorInfo) return false;
435 return (object) [
436 'title' => $cache->title,
437 'url' => '/f/'.$cache->creatorDid.'/'.$uriComponents->rkey,
438 'description' => $cache->description,
439 'avatar' => $cache->avatar,
440 'creatorDisplay' => $authorInfo->displayName,
441 'creatorDid' => $uriComponents->did,
442 'creatorPds' => $authorInfo->pds,
443 'creatorHandle' => $authorInfo->handle
444 ];
445 }
446
447 $uriComponents = $this->splitAtUri($atUri);
448 if (!$uriComponents) return false;
449 $feedData = $this->getSlingshotData($uriComponents->did, $uriComponents->collection, $uriComponents->rkey);
450 $authorInfo = $this->getUserInfo($uriComponents->did, 'did');
451 if (!$authorInfo) return false;
452 \updateFeedCache($atUri, $feedData->value->displayName, $feedData->value->description, $this->getMediaUrl($authorInfo->pds, $uriComponents->did, $feedData->value->avatar->ref->{'$link'}), $uriComponents->did);
453 return (object) [
454 'title' => $feedData->value->displayName,
455 'url' => '/f/'.$uriComponents->did.'/'.$uriComponents->rkey,
456 'description' => $feedData->value->description,
457 'avatar' => $this->getMediaUrl($authorInfo->pds, $uriComponents->did, $feedData->value->avatar->ref->{'$link'}),
458 'creatorDisplay' => $authorInfo->displayName,
459 'creatorDid' => $uriComponents->did,
460 'creatorPds' => $authorInfo->pds,
461 'creatorHandle' => $authorInfo->handle
462 ];
463 return $feedData;
464 }
465
466 function getFeed(string $atUri, ?string $cursor = null, ?string $userAuth = null, ?bool $newer = false, int $limit = 15):object|bool {
467 preg_match('/^at:\/\/(did:plc:[a-z0-9\.]+)\/app.bsky.feed.generator\/([a-z0-9]+)$/', $atUri, $uriComponents);
468 $did = $uriComponents[1];
469 $rkey = $uriComponents[2];
470 $userInfo = $this->getUserInfo($did, 'did');
471
472 if (!$userInfo) return false;
473
474 $feedInfo = await(async(function () use ($did, $rkey, $userInfo) {
475 return $this->getPdsData($userInfo->pds, 'com.atproto.repo.getRecord', [
476 'repo' => $did,
477 'collection' => 'app.bsky.feed.generator',
478 'rkey' => $rkey
479 ]);
480 }));
481
482 if (!$feedInfo) return false;
483
484 if ($userAuth) {
485 // do something. i don't care rn tho
486
487 /*$feedData = $this->getPdsData($pds, 'app.bsky.feed.getFeed', [
488 'feed' => FRONTPAGE_FEED,
489 'limit' => $limit,
490 'cursor' => $cursor
491 ]);*/
492 return false;
493 }
494
495 $feedData = $this->getFeedSkeleton($feedInfo->value->did, $atUri, $cursor);
496
497 if ($feedData) {
498 return $feedData;
499 }
500
501 return false;
502 }
503
504 /* SANITIZATION */
505
506 function sanitizePost(object $post, ?bool $slingshot = false, bool $getReplies = true):object|bool {
507 if ($slingshot) {
508 return $this->sanitizeSlingshotRecordPost($post, false);
509 }
510 return $this->sanitizePublicApiPost($post, false);
511 }
512
513 function sanitizeSlingshotRecordPost(object $post, ?string $cursor = null, $getReplies = true):object|bool {
514 preg_match('/at:\/\/(did:plc:[a-z0-9]+)\/[a-zA-Z\.]+\/([a-z0-9]+)/', $post->uri, $uriComponents);
515 if (!$uriComponents) return false;
516 $did = $uriComponents[1];
517 $rkey = $uriComponents[2];
518 $authorInfo = $this->getUserInfo($did, 'did');
519 $facets = property_exists($post->value, 'facets') ? $this->sanitizeFacets($post->value->facets) : [];
520 $ret = (object) [
521 'author' => (object) [
522 'displayName' => $authorInfo->displayName,
523 'handle' => $authorInfo->handle,
524 'avatar' => $authorInfo->avatar,
525 'did' => $did,
526 'profileLink' => '/u/'.$authorInfo->handle,
527 ],
528 'uri' => $post->uri,
529 'pds' => $authorInfo->pds,
530 'postId' => $rkey,
531 'postLink' => '/u/'.$authorInfo->handle.'/'.$rkey,
532 'content' => property_exists($post->value, 'text') ? $this->applyFacets($post->value->text, $facets) : '',
533 'createdAt' => $post->value->createdAt,
534 'embedType' => property_exists($post->value, 'embed') ? $post->value->embed->{'$type'} : null,
535 'embeds' => property_exists($post->value, 'embed') ? $this->sanitizeEmbeds($post->value->embed, $authorInfo) : [],
536 'cursor' => $cursor
537 ];
538 \updatePostCache($rkey, $did, $ret->content, $ret->embedType ? $ret->embedType : '', json_encode($ret->embeds), $post->value->createdAt);
539 return $ret;
540 }
541
542 function sanitizePublicApiPost(object $post, ?string $cursor = null, bool $getReplies = true):object|bool {
543 preg_match('/at:\/\/(did:plc:[a-z0-9]+)\/[a-zA-Z\.]+\/([a-z0-9]+)/', $post->uri, $rkeyMatch);
544 if (!$rkeyMatch) return false;
545 $authorData = $this->getUserInfo($post->author->did, 'did');
546 $facets = property_exists($post->record, 'facets') ? $this->sanitizeFacets($post->record->facets) : [];
547 $processedText = $this->applyFacets($post->record->text, $facets);
548 $embedType = property_exists($post->record, 'embed') ? $post->record->embed->{'$type'} : '';
549 $embeds = property_exists($post->record, 'embed') ? $this->sanitizeEmbeds($post->record->embed, $authorData) : [];
550 \updatePostCache($rkeyMatch[2], $authorData->did, $processedText, $embedType, json_encode($embeds), $post->record->createdAt);
551 return (object) [
552 'author' => (object) [
553 'displayName' => $authorData->displayName,
554 'handle' => $authorData->handle,
555 'did' => $authorData->did,
556 'avatar' => $authorData->avatar,
557 'profileLink' => '/u/'.$authorData->handle,
558 ],
559 'uri' => $post->uri,
560 'pds' => $authorData->pds,
561 'postId' => $rkeyMatch[2],
562 'postLink' => '/u/'.$authorData->handle.'/'.$rkeyMatch[2],
563 'createdAt' => $post->record->createdAt,
564 'embedType' => $embedType,
565 'embeds' => $embeds
566 ];
567 }
568
569 function sanitizeCachedPost(object $post, bool $getReplies = true): object {
570 $uri = 'at://'.$post->did.'/app.bsky.feed.post/'.$post->rkey;
571 $authorData = $this->getUserInfo($post->did, 'did');
572 return (object) [
573 'author' => (object) [
574 'displayName' => $authorData->displayName,
575 'handle' => $authorData->handle,
576 'did' => $authorData->did,
577 'avatar' => $authorData->avatar,
578 'profileLink' => '/u/'.$authorData->handle,
579 ],
580 'uri' => $uri,
581 'pds' => $authorData->pds,
582 'postId' => $post->rkey,
583 'postLink' => '/u/'.$authorData->handle.'/'.$post->rkey,
584 'content' => $post->text,
585 'createdAt' => $post->createdAt,
586 'embedType' => $post->embedType,
587 'embeds' => json_decode($post->embeds)
588 ];
589 }
590
591 function sanitizeEmbeds(object $embeds, object $authorData):array|bool {
592 if ($embeds->{'$type'} === 'app.bsky.embed.images') {
593 return array_map(function ($im) use ($authorData) {
594 return (object) [
595 'imgUrl' => $this->getMediaUrl($authorData->pds, $authorData->did, $im->image->ref->{'$link'}),
596 'alt' => $im->alt,
597 'width' => property_exists($im, 'aspectRatio') ? $im->aspectRatio->width : "auto",
598 'height' => property_exists($im, 'aspectRatio') ? $im->aspectRatio->height : "auto"
599 ];
600 }, $embeds->images);
601 } else if ($embeds->{'$type'} === 'app.bsky.embed.video') {
602 return [
603 (object) [
604 'thumb' => property_exists($embeds->video, 'thumbnail') ? $embeds->video->thumbnail : '',
605 'videoUrl' => $this->getMediaUrl($authorData->pds, $authorData->did, $embeds->video->ref->{'$link'}),
606 'width' => property_exists($embeds, 'aspectRatio') ? $embeds->aspectRatio->width : "auto",
607 'height' => property_exists($embeds, 'aspectRatio') ? $embeds->aspectRatio->height : "auto"
608 ]
609 ];
610 } else if ($embeds->{'$type'} === 'app.bsky.embed.record') {
611 $uriComponents = $this->splitAtUri($embeds->record->uri);
612 $post = $this->getSlingshotData($uriComponents->did, $uriComponents->collection, $uriComponents->rkey);
613 $sanitizedPost = $this->sanitizePost($post, true);
614 return [
615 (object) [
616 'post' => $sanitizedPost
617 ]
618 ];
619 } else if ($embeds->{'$type'} === 'app.bsky.embed.external') {
620 return [
621 (object) [
622 'uri' => $embeds->external->uri,
623 'title' => $embeds->external->title,
624 'description' => $embeds->external->description,
625 'thumb' => property_exists($embeds->external, 'thumb') ? $this->getMediaUrl($authorData->pds, $authorData->did, $embeds->external->thumb->ref->{'$link'}) : null
626 ]
627 ];
628 }
629 return false;
630 }
631
632 function sanitizeUserList(array $users):array {
633 $normalized = array_map(function ($rec) {
634 $hydratedRec = $rec;
635 return $this->getUserInfo($rec->did, 'did');
636 }, $users);
637 $ret = array_values(array_filter($normalized));
638 return $ret;
639 }
640
641 function sanitizeFacets(array $facets):array {
642 usort($facets, function($a, $b) {
643 if ($a->index->byteStart > $b->index->byteStart) {
644 return 1;
645 }
646 return -1;
647 });
648 return array_map(function ($facet) {
649 if ($facet->features[0]->{'$type'} === "app.bsky.richtext.facet#mention") {
650 $did = $facet->features[0]->did;
651 $userInfo = $this->getPlcInfoFromRecord($did);
652 if ($userInfo) {
653 $handle = $userInfo->handle;
654 return (object) [
655 'type' => 'mention',
656 'did' => $did,
657 'handle' => $handle,
658 'start' => $facet->index->byteStart,
659 'end' => $facet->index->byteEnd
660 ];
661 }
662 return (object) [];
663 } else if ($facet->features[0]->{'$type'} === "app.bsky.richtext.facet#tag") {
664 return (object) [
665 'type' => 'tag',
666 'tag' => $facet->features[0]->tag,
667 'start' => $facet->index->byteStart,
668 'end' => $facet->index->byteEnd
669 ];
670 } else if ($facet->features[0]->{'$type'} === "app.bsky.richtext.facet#link") {
671 return (object) [
672 'type' => 'link',
673 'link' => $facet->features[0]->uri,
674 'start' => $facet->index->byteStart,
675 'end' => $facet->index->byteEnd
676 ];
677 }
678 return (object) [];
679 }, $facets);
680 }
681
682 /* App-Level Ban/Timeout Implementation */
683
684 function userBanCheck(string $did): bool {
685 }
686
687 function filterRecords(array $records, string $userField):array {
688 }
689}
690
691?>