Helpers.php 37 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316
  1. <?php
  2. namespace App\Util\ActivityPub;
  3. use App\Instance;
  4. use App\Jobs\AvatarPipeline\RemoteAvatarFetch;
  5. use App\Jobs\HomeFeedPipeline\FeedInsertRemotePipeline;
  6. use App\Jobs\MediaPipeline\MediaStoragePipeline;
  7. use App\Jobs\StatusPipeline\StatusReplyPipeline;
  8. use App\Jobs\StatusPipeline\StatusTagsPipeline;
  9. use App\Media;
  10. use App\Models\ModeratedProfile;
  11. use App\Models\Poll;
  12. use App\Profile;
  13. use App\Services\Account\AccountStatService;
  14. use App\Services\ActivityPubDeliveryService;
  15. use App\Services\ActivityPubFetchService;
  16. use App\Services\DomainService;
  17. use App\Services\InstanceService;
  18. use App\Services\MediaPathService;
  19. use App\Services\NetworkTimelineService;
  20. use App\Services\SanitizeService;
  21. use App\Services\UserFilterService;
  22. use App\Status;
  23. use App\Util\Media\License;
  24. use Cache;
  25. use Carbon\Carbon;
  26. use Illuminate\Validation\Rule;
  27. use League\Uri\Exceptions\UriException;
  28. use League\Uri\Uri;
  29. use Purify;
  30. use Validator;
  31. class Helpers
  32. {
  33. private const PUBLIC_TIMELINE = 'https://www.w3.org/ns/activitystreams#Public';
  34. private const CACHE_TTL = 14440;
  35. private const URL_CACHE_PREFIX = 'helpers:url:';
  36. private const FETCH_CACHE_TTL = 15;
  37. private const LOCALHOST_DOMAINS = [
  38. 'localhost',
  39. '127.0.0.1',
  40. '::1',
  41. 'broadcasthost',
  42. 'ip6-localhost',
  43. 'ip6-loopback',
  44. ];
  45. /**
  46. * Validate an ActivityPub object
  47. */
  48. public static function validateObject(array $data): bool
  49. {
  50. $verbs = ['Create', 'Announce', 'Like', 'Follow', 'Delete', 'Accept', 'Reject', 'Undo', 'Tombstone'];
  51. return Validator::make($data, [
  52. 'type' => ['required', 'string', Rule::in($verbs)],
  53. 'id' => 'required|string',
  54. 'actor' => 'required|string|url',
  55. 'object' => 'required',
  56. 'object.type' => 'required_if:type,Create',
  57. 'object.attributedTo' => 'required_if:type,Create|url',
  58. 'published' => 'required_if:type,Create|date',
  59. ])->passes();
  60. }
  61. /**
  62. * Validate media attachments
  63. */
  64. public static function verifyAttachments(array $data): bool
  65. {
  66. if (! isset($data['object']) || empty($data['object'])) {
  67. $data = ['object' => $data];
  68. }
  69. $activity = $data['object'];
  70. $mimeTypes = explode(',', config_cache('pixelfed.media_types'));
  71. $mediaTypes = in_array('video/mp4', $mimeTypes) ?
  72. ['Document', 'Image', 'Video'] :
  73. ['Document', 'Image'];
  74. if (! isset($activity['attachment']) || empty($activity['attachment'])) {
  75. return false;
  76. }
  77. return Validator::make($activity['attachment'], [
  78. '*.type' => ['required', 'string', Rule::in($mediaTypes)],
  79. '*.url' => 'required|url',
  80. '*.mediaType' => ['required', 'string', Rule::in($mimeTypes)],
  81. '*.name' => 'sometimes|nullable|string',
  82. '*.blurhash' => 'sometimes|nullable|string|min:6|max:164',
  83. '*.width' => 'sometimes|nullable|integer|min:1|max:5000',
  84. '*.height' => 'sometimes|nullable|integer|min:1|max:5000',
  85. ])->passes();
  86. }
  87. /**
  88. * Normalize ActivityPub audience
  89. */
  90. public static function normalizeAudience(array $data, bool $localOnly = true): ?array
  91. {
  92. if (! isset($data['to'])) {
  93. return null;
  94. }
  95. $audience = [
  96. 'to' => [],
  97. 'cc' => [],
  98. 'scope' => 'private',
  99. ];
  100. if (is_array($data['to']) && ! empty($data['to'])) {
  101. foreach ($data['to'] as $to) {
  102. if ($to == self::PUBLIC_TIMELINE) {
  103. $audience['scope'] = 'public';
  104. continue;
  105. }
  106. $url = $localOnly ? self::validateLocalUrl($to) : self::validateUrl($to);
  107. if ($url) {
  108. $audience['to'][] = $url;
  109. }
  110. }
  111. }
  112. if (is_array($data['cc']) && ! empty($data['cc'])) {
  113. foreach ($data['cc'] as $cc) {
  114. if ($cc == self::PUBLIC_TIMELINE) {
  115. $audience['scope'] = 'unlisted';
  116. continue;
  117. }
  118. $url = $localOnly ? self::validateLocalUrl($cc) : self::validateUrl($cc);
  119. if ($url) {
  120. $audience['cc'][] = $url;
  121. }
  122. }
  123. }
  124. return $audience;
  125. }
  126. /**
  127. * Check if user is in audience
  128. */
  129. public static function userInAudience(Profile $profile, array $data): bool
  130. {
  131. $audience = self::normalizeAudience($data);
  132. $url = $profile->permalink();
  133. return in_array($url, $audience['to']) || in_array($url, $audience['cc']);
  134. }
  135. /**
  136. * Validate URL with various security and format checks
  137. */
  138. public static function validateUrl(?string $url, bool $disableDNSCheck = false, bool $forceBanCheck = false): string|bool
  139. {
  140. if (! $normalizedUrl = self::normalizeUrl($url)) {
  141. return false;
  142. }
  143. try {
  144. $uri = Uri::new($normalizedUrl);
  145. if (! self::isValidUri($uri)) {
  146. return false;
  147. }
  148. $host = $uri->getHost();
  149. if (! self::isValidHost($host)) {
  150. return false;
  151. }
  152. if (! $disableDNSCheck && ! self::passesSecurityChecks($host, $disableDNSCheck, $forceBanCheck)) {
  153. return false;
  154. }
  155. return $uri->toString();
  156. } catch (UriException $e) {
  157. return false;
  158. }
  159. }
  160. /**
  161. * Normalize URL input
  162. */
  163. public static function normalizeUrl(?string $url): ?string
  164. {
  165. if (is_array($url) && ! empty($url)) {
  166. $url = $url[0];
  167. }
  168. return (! $url || strlen($url) === 0) ? null : $url;
  169. }
  170. /**
  171. * Validate basic URI requirements
  172. */
  173. public static function isValidUri(Uri $uri): bool
  174. {
  175. return $uri && $uri->getScheme() === 'https';
  176. }
  177. /**
  178. * Validate host requirements
  179. */
  180. public static function isValidHost(?string $host): bool
  181. {
  182. if (! $host || $host === '') {
  183. return false;
  184. }
  185. if (! filter_var($host, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME)) {
  186. return false;
  187. }
  188. if (! str_contains($host, '.')) {
  189. return false;
  190. }
  191. if (in_array($host, self::LOCALHOST_DOMAINS)) {
  192. return false;
  193. }
  194. return true;
  195. }
  196. /**
  197. * Check DNS and banned status if required
  198. */
  199. public static function passesSecurityChecks(string $host, bool $disableDNSCheck, bool $forceBanCheck): bool
  200. {
  201. if ($disableDNSCheck !== true && self::shouldCheckDNS()) {
  202. if (! self::hasValidDNS($host)) {
  203. return false;
  204. }
  205. }
  206. if ($forceBanCheck || self::shouldCheckBans()) {
  207. if (self::isHostBanned($host)) {
  208. return false;
  209. }
  210. }
  211. return true;
  212. }
  213. /**
  214. * Check if DNS validation is required
  215. */
  216. public static function shouldCheckDNS(): bool
  217. {
  218. return app()->environment() === 'production' &&
  219. (bool) config('security.url.verify_dns');
  220. }
  221. /**
  222. * Validate domain DNS records
  223. */
  224. public static function hasValidDNS(string $host): bool
  225. {
  226. $hash = hash('sha256', $host);
  227. $key = self::URL_CACHE_PREFIX."valid-dns:sha256-{$hash}";
  228. return Cache::remember($key, self::CACHE_TTL, function () use ($host) {
  229. return DomainService::hasValidDns($host);
  230. });
  231. }
  232. /**
  233. * Check if domain bans should be validated
  234. */
  235. public static function shouldCheckBans(): bool
  236. {
  237. return app()->environment() === 'production';
  238. }
  239. /**
  240. * Check if host is in banned domains list
  241. */
  242. public static function isHostBanned(string $host): bool
  243. {
  244. $bannedInstances = InstanceService::getBannedDomains();
  245. return in_array($host, $bannedInstances);
  246. }
  247. /**
  248. * Validate local URL
  249. */
  250. public static function validateLocalUrl(string $url): string|bool
  251. {
  252. $url = self::validateUrl($url);
  253. if ($url) {
  254. $domain = config('pixelfed.domain.app');
  255. $uri = Uri::new($url);
  256. $host = $uri->getHost();
  257. if (! $host || empty($host)) {
  258. return false;
  259. }
  260. return strtolower($domain) === strtolower($host) ? $url : false;
  261. }
  262. return false;
  263. }
  264. /**
  265. * Get user agent string
  266. */
  267. public static function zttpUserAgent(): array
  268. {
  269. $version = config('pixelfed.version');
  270. $url = config('app.url');
  271. return [
  272. 'Accept' => 'application/activity+json',
  273. 'User-Agent' => "(Pixelfed/{$version}; +{$url})",
  274. ];
  275. }
  276. public static function fetchFromUrl($url = false)
  277. {
  278. if (self::validateUrl($url) == false) {
  279. return;
  280. }
  281. $hash = hash('sha256', $url);
  282. $key = "helpers:url:fetcher:sha256-{$hash}";
  283. $ttl = now()->addMinutes(15);
  284. return Cache::remember($key, $ttl, function () use ($url) {
  285. $res = ActivityPubFetchService::get($url);
  286. if (! $res || empty($res)) {
  287. return false;
  288. }
  289. $res = json_decode($res, true, 8);
  290. if (json_last_error() == JSON_ERROR_NONE) {
  291. return $res;
  292. } else {
  293. return false;
  294. }
  295. });
  296. }
  297. public static function fetchProfileFromUrl($url)
  298. {
  299. return self::fetchFromUrl($url);
  300. }
  301. public static function pluckval($val)
  302. {
  303. if (is_string($val)) {
  304. return $val;
  305. }
  306. if (is_array($val)) {
  307. return ! empty($val) ? head($val) : null;
  308. }
  309. return null;
  310. }
  311. public static function validateTimestamp($timestamp)
  312. {
  313. try {
  314. $date = Carbon::parse($timestamp);
  315. $now = Carbon::now();
  316. $tenYearsAgo = $now->copy()->subYears(20);
  317. $isMoreThanTenYearsOld = $date->lt($tenYearsAgo);
  318. $tomorrow = $now->copy()->addDay();
  319. $isMoreThanOneDayFuture = $date->gt($tomorrow);
  320. return ! ($isMoreThanTenYearsOld || $isMoreThanOneDayFuture);
  321. } catch (\Exception $e) {
  322. return false;
  323. }
  324. }
  325. /**
  326. * Fetch or create a status from URL
  327. */
  328. public static function statusFirstOrFetch(string $url, bool $replyTo = false): ?Status
  329. {
  330. if (! $validUrl = self::validateUrl($url)) {
  331. return null;
  332. }
  333. if ($status = self::findExistingStatus($url)) {
  334. return $status;
  335. }
  336. return self::createStatusFromUrl($url, $replyTo);
  337. }
  338. /**
  339. * Find existing status by URL
  340. */
  341. public static function findExistingStatus(string $url): ?Status
  342. {
  343. $host = parse_url($url, PHP_URL_HOST);
  344. if (self::isLocalDomain($host)) {
  345. $id = (int) last(explode('/', $url));
  346. return Status::whereNotIn('scope', ['draft', 'archived'])
  347. ->findOrFail($id);
  348. }
  349. return Status::whereNotIn('scope', ['draft', 'archived'])
  350. ->where(function ($query) use ($url) {
  351. $query->whereUri($url)
  352. ->orWhere('object_url', $url);
  353. })
  354. ->first();
  355. }
  356. /**
  357. * Create a new status from ActivityPub data
  358. */
  359. public static function createStatusFromUrl(string $url, bool $replyTo): ?Status
  360. {
  361. $res = self::fetchFromUrl($url);
  362. if (! $res || ! self::isValidStatusData($res)) {
  363. return null;
  364. }
  365. if (! self::validateTimestamp($res['published'])) {
  366. return null;
  367. }
  368. if (! self::passesContentFilters($res)) {
  369. return null;
  370. }
  371. $activity = isset($res['object']) ? $res : ['object' => $res];
  372. if (! $profile = self::getStatusProfile($activity)) {
  373. return null;
  374. }
  375. if (! self::validateStatusUrls($url, $activity)) {
  376. return null;
  377. }
  378. $reply_to = self::getReplyToId($activity, $profile, $replyTo);
  379. $scope = self::getScope($activity, $url);
  380. $cw = self::getSensitive($activity, $url);
  381. if ($res['type'] === 'Question') {
  382. return self::storePoll(
  383. $profile,
  384. $res,
  385. $url,
  386. $res['published'],
  387. $reply_to,
  388. $cw,
  389. $scope,
  390. $activity['id'] ?? $url
  391. );
  392. }
  393. return self::storeStatus($url, $profile, $res);
  394. }
  395. /**
  396. * Validate status data
  397. */
  398. public static function isValidStatusData(?array $res): bool
  399. {
  400. return $res &&
  401. ! empty($res) &&
  402. ! isset($res['error']) &&
  403. isset($res['@context']) &&
  404. isset($res['published']);
  405. }
  406. /**
  407. * Check if content passes filters
  408. */
  409. public static function passesContentFilters(array $res): bool
  410. {
  411. if (! config('autospam.live_filters.enabled')) {
  412. return true;
  413. }
  414. $filters = config('autospam.live_filters.filters');
  415. if (empty($filters) || ! isset($res['content']) || strlen($filters) <= 3) {
  416. return true;
  417. }
  418. $filters = array_map('trim', explode(',', $filters));
  419. $content = strtolower($res['content']);
  420. foreach ($filters as $filter) {
  421. $filter = trim(strtolower($filter));
  422. if ($filter && str_contains($content, $filter)) {
  423. return false;
  424. }
  425. }
  426. return true;
  427. }
  428. /**
  429. * Get profile for status
  430. */
  431. public static function getStatusProfile(array $activity): ?Profile
  432. {
  433. if (! isset($activity['object']['attributedTo'])) {
  434. return null;
  435. }
  436. $attributedTo = self::extractAttributedTo($activity['object']['attributedTo']);
  437. return $attributedTo ? self::profileFirstOrNew($attributedTo) : null;
  438. }
  439. /**
  440. * Extract attributed to value
  441. */
  442. public static function extractAttributedTo(string|array $attributedTo): ?string
  443. {
  444. if (is_string($attributedTo)) {
  445. return $attributedTo;
  446. }
  447. if (is_array($attributedTo)) {
  448. return collect($attributedTo)
  449. ->filter(fn ($o) => $o && isset($o['type']) && $o['type'] == 'Person')
  450. ->pluck('id')
  451. ->first();
  452. }
  453. return null;
  454. }
  455. /**
  456. * Validate status URLs match
  457. */
  458. public static function validateStatusUrls(string $url, array $activity): bool
  459. {
  460. $id = isset($activity['id']) ?
  461. self::pluckval($activity['id']) :
  462. self::pluckval($url);
  463. $idDomain = parse_url($id, PHP_URL_HOST);
  464. $urlDomain = parse_url($url, PHP_URL_HOST);
  465. return $idDomain && $urlDomain;
  466. }
  467. /**
  468. * Get reply-to status ID
  469. */
  470. public static function getReplyToId(array $activity, Profile $profile, bool $replyTo): ?int
  471. {
  472. $inReplyTo = $activity['object']['inReplyTo'] ?? null;
  473. if (! $inReplyTo && ! $replyTo) {
  474. return null;
  475. }
  476. $reply = self::statusFirstOrFetch(self::pluckval($inReplyTo), false);
  477. if (! $reply) {
  478. return null;
  479. }
  480. $blocks = UserFilterService::blocks($reply->profile_id);
  481. return in_array($profile->id, $blocks) ? null : $reply->id;
  482. }
  483. /**
  484. * Store a new regular status
  485. */
  486. public static function storeStatus(string $url, Profile $profile, array $activity): Status
  487. {
  488. $id = self::getStatusId($activity, $url);
  489. $url = self::getStatusUrl($activity, $id);
  490. if ((! isset($activity['type']) ||
  491. in_array($activity['type'], ['Create', 'Note'])) &&
  492. ! self::validateStatusDomains($id, $url)) {
  493. throw new \Exception('Invalid status domains');
  494. }
  495. $reply_to = self::getReplyTo($activity);
  496. $ts = self::pluckval($activity['published']);
  497. $scope = self::getScope($activity, $url);
  498. $commentsDisabled = isset($activity['commentsEnabled']) ? (bool) $activity['commentsEnabled'] == false : false;
  499. $cw = self::getSensitive($activity, $url);
  500. if ($profile->unlisted) {
  501. $scope = 'unlisted';
  502. }
  503. $status = self::createOrUpdateStatus($url, $profile, $id, $activity, $ts, $reply_to, $cw, $scope, $commentsDisabled);
  504. if ($reply_to === null) {
  505. self::importNoteAttachment($activity, $status);
  506. } else {
  507. if (isset($activity['attachment']) && ! empty($activity['attachment'])) {
  508. self::importNoteAttachment($activity, $status);
  509. }
  510. StatusReplyPipeline::dispatch($status);
  511. }
  512. if (isset($activity['tag']) && is_array($activity['tag']) && ! empty($activity['tag'])) {
  513. StatusTagsPipeline::dispatch($activity, $status);
  514. }
  515. self::handleStatusPostProcessing($status, $profile->id, $url);
  516. return $status;
  517. }
  518. /**
  519. * Get status ID from activity
  520. */
  521. public static function getStatusId(array $activity, string $url): string
  522. {
  523. return isset($activity['id']) ?
  524. self::pluckval($activity['id']) :
  525. self::pluckval($url);
  526. }
  527. /**
  528. * Get status URL from activity
  529. */
  530. public static function getStatusUrl(array $activity, string $id): string
  531. {
  532. return isset($activity['url']) && is_string($activity['url']) ?
  533. self::pluckval($activity['url']) :
  534. self::pluckval($id);
  535. }
  536. /**
  537. * Validate the status URL and ID are valid
  538. */
  539. public static function validateStatusDomains(string $id, string $url): bool
  540. {
  541. return self::validateUrl($id) && self::validateUrl($url);
  542. }
  543. /**
  544. * Create or update status record
  545. */
  546. public static function createOrUpdateStatus(
  547. string $url,
  548. Profile $profile,
  549. string $id,
  550. array $activity,
  551. string $ts,
  552. ?int $reply_to,
  553. bool $cw,
  554. string $scope,
  555. bool $commentsDisabled
  556. ): Status {
  557. $caption = isset($activity['content']) ?
  558. app(SanitizeService::class)->html($activity['content']) :
  559. '';
  560. $cwSummary = ($cw && isset($activity['summary'])) ?
  561. app(SanitizeService::class)->html($activity['summary']) :
  562. null;
  563. return Status::updateOrCreate(
  564. ['uri' => $url],
  565. [
  566. 'profile_id' => $profile->id,
  567. 'url' => $url,
  568. 'object_url' => $id,
  569. 'caption' => strip_tags($caption),
  570. 'rendered' => $caption,
  571. 'created_at' => Carbon::parse($ts)->tz('UTC'),
  572. 'in_reply_to_id' => $reply_to,
  573. 'local' => false,
  574. 'is_nsfw' => $cw,
  575. 'scope' => $scope,
  576. 'visibility' => $scope,
  577. 'cw_summary' => $cwSummary ? strip_tags($cwSummary) : null,
  578. 'comments_disabled' => $commentsDisabled,
  579. ]
  580. );
  581. }
  582. /**
  583. * Handle post-creation status processing
  584. */
  585. public static function handleStatusPostProcessing(Status $status, int $profileId, string $url): void
  586. {
  587. if (config('instance.timeline.network.cached') &&
  588. self::isEligibleForNetwork($status)
  589. ) {
  590. $urlDomain = parse_url($url, PHP_URL_HOST);
  591. $filteredDomains = self::getFilteredDomains();
  592. if (! in_array($urlDomain, $filteredDomains)) {
  593. NetworkTimelineService::add($status->id);
  594. }
  595. }
  596. AccountStatService::incrementPostCount($profileId);
  597. if ($status->in_reply_to_id === null &&
  598. in_array($status->type, ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album'])
  599. ) {
  600. FeedInsertRemotePipeline::dispatch($status->id, $profileId)
  601. ->onQueue('feed');
  602. }
  603. }
  604. /**
  605. * Check if status is eligible for network timeline
  606. */
  607. public static function isEligibleForNetwork(Status $status): bool
  608. {
  609. return $status->in_reply_to_id === null &&
  610. $status->reblog_of_id === null &&
  611. in_array($status->type, ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album']) &&
  612. $status->created_at->gt(now()->subHours(config('instance.timeline.network.max_hours_old'))) &&
  613. (config('instance.hide_nsfw_on_public_feeds') ? ! $status->is_nsfw : true);
  614. }
  615. /**
  616. * Get filtered domains list
  617. */
  618. public static function getFilteredDomains(): array
  619. {
  620. return collect(InstanceService::getBannedDomains())
  621. ->merge(InstanceService::getUnlistedDomains())
  622. ->unique()
  623. ->values()
  624. ->toArray();
  625. }
  626. public static function getSensitive($activity, $url)
  627. {
  628. if (! $url || ! strlen($url)) {
  629. return true;
  630. }
  631. $urlDomain = parse_url($url, PHP_URL_HOST);
  632. $cw = isset($activity['sensitive']) ? (bool) $activity['sensitive'] : false;
  633. if (in_array($urlDomain, InstanceService::getNsfwDomains())) {
  634. $cw = true;
  635. }
  636. return $cw;
  637. }
  638. public static function getReplyTo($activity)
  639. {
  640. $reply_to = null;
  641. $inReplyTo = isset($activity['inReplyTo']) && ! empty($activity['inReplyTo']) ?
  642. self::pluckval($activity['inReplyTo']) :
  643. false;
  644. if ($inReplyTo) {
  645. $reply_to = self::statusFirstOrFetch($inReplyTo);
  646. if ($reply_to) {
  647. $reply_to = optional($reply_to)->id;
  648. }
  649. } else {
  650. $reply_to = null;
  651. }
  652. return $reply_to;
  653. }
  654. public static function getScope($activity, $url)
  655. {
  656. $id = isset($activity['id']) ? self::pluckval($activity['id']) : self::pluckval($url);
  657. $url = isset($activity['url']) ? self::pluckval($activity['url']) : self::pluckval($id);
  658. $urlDomain = parse_url(self::pluckval($url), PHP_URL_HOST);
  659. $scope = 'private';
  660. if (isset($activity['to']) == true) {
  661. if (is_array($activity['to']) && in_array('https://www.w3.org/ns/activitystreams#Public', $activity['to'])) {
  662. $scope = 'public';
  663. }
  664. if (is_string($activity['to']) && $activity['to'] == 'https://www.w3.org/ns/activitystreams#Public') {
  665. $scope = 'public';
  666. }
  667. }
  668. if (isset($activity['cc']) == true) {
  669. if (is_array($activity['cc']) && in_array('https://www.w3.org/ns/activitystreams#Public', $activity['cc'])) {
  670. $scope = 'unlisted';
  671. }
  672. if (is_string($activity['cc']) && $activity['cc'] == 'https://www.w3.org/ns/activitystreams#Public') {
  673. $scope = 'unlisted';
  674. }
  675. }
  676. if ($scope == 'public' && in_array($urlDomain, InstanceService::getUnlistedDomains())) {
  677. $scope = 'unlisted';
  678. }
  679. return $scope;
  680. }
  681. public static function storePoll($profile, $res, $url, $ts, $reply_to, $cw, $scope, $id)
  682. {
  683. if (! isset($res['endTime']) || ! isset($res['oneOf']) || ! is_array($res['oneOf']) || count($res['oneOf']) > 4) {
  684. return;
  685. }
  686. $options = collect($res['oneOf'])->map(function ($option) {
  687. return $option['name'];
  688. })->toArray();
  689. $cachedTallies = collect($res['oneOf'])->map(function ($option) {
  690. return $option['replies']['totalItems'] ?? 0;
  691. })->toArray();
  692. $defaultCaption = '';
  693. $cleanedCaption = ! empty($res['content']) ?
  694. app(SanitizeService::class)->html($res['content']) :
  695. null;
  696. $status = new Status;
  697. $status->profile_id = $profile->id;
  698. $status->url = isset($res['url']) ? $res['url'] : $url;
  699. $status->uri = isset($res['url']) ? $res['url'] : $url;
  700. $status->object_url = $id;
  701. $status->caption = $cleanedCaption ? strip_tags($cleanedCaption) : $defaultCaption;
  702. $status->rendered = Purify::clean($res['content'] ?? $defaultCaption);
  703. $status->created_at = Carbon::parse($ts)->tz('UTC');
  704. $status->in_reply_to_id = null;
  705. $status->local = false;
  706. $status->is_nsfw = $cw;
  707. $status->scope = 'draft';
  708. $status->visibility = 'draft';
  709. $status->cw_summary = $cw == true && isset($res['summary']) ?
  710. Purify::clean(strip_tags($res['summary'])) : null;
  711. $status->save();
  712. $poll = new Poll;
  713. $poll->status_id = $status->id;
  714. $poll->profile_id = $status->profile_id;
  715. $poll->poll_options = $options;
  716. $poll->cached_tallies = $cachedTallies;
  717. $poll->votes_count = array_sum($cachedTallies);
  718. $poll->expires_at = now()->parse($res['endTime']);
  719. $poll->last_fetched_at = now();
  720. $poll->save();
  721. $status->type = 'poll';
  722. $status->scope = $scope;
  723. $status->visibility = $scope;
  724. $status->save();
  725. return $status;
  726. }
  727. public static function statusFetch($url)
  728. {
  729. return self::statusFirstOrFetch($url);
  730. }
  731. /**
  732. * Process and store note attachments
  733. */
  734. public static function importNoteAttachment(array $data, Status $status): void
  735. {
  736. if (! self::verifyAttachments($data)) {
  737. $status->viewType();
  738. return;
  739. }
  740. $attachments = self::getAttachments($data);
  741. $profile = $status->profile;
  742. $storagePath = MediaPathService::get($profile, 2);
  743. $allowedTypes = explode(',', config_cache('pixelfed.media_types'));
  744. foreach ($attachments as $key => $media) {
  745. if (! self::isValidAttachment($media, $allowedTypes)) {
  746. continue;
  747. }
  748. $mediaModel = self::createMediaAttachment($media, $status, $key);
  749. self::handleMediaStorage($mediaModel);
  750. }
  751. $status->viewType();
  752. }
  753. /**
  754. * Get attachments from ActivityPub data
  755. */
  756. public static function getAttachments(array $data): array
  757. {
  758. return isset($data['object']) ?
  759. $data['object']['attachment'] :
  760. $data['attachment'];
  761. }
  762. /**
  763. * Validate individual attachment
  764. */
  765. public static function isValidAttachment(array $media, array $allowedTypes): bool
  766. {
  767. $type = $media['mediaType'];
  768. $url = $media['url'];
  769. return in_array($type, $allowedTypes) &&
  770. self::validateUrl($url);
  771. }
  772. /**
  773. * Create media attachment record
  774. */
  775. public static function createMediaAttachment(array $media, Status $status, int $key): Media
  776. {
  777. $mediaModel = new Media;
  778. self::setBasicMediaAttributes($mediaModel, $media, $status, $key);
  779. self::setOptionalMediaAttributes($mediaModel, $media);
  780. $mediaModel->save();
  781. return $mediaModel;
  782. }
  783. /**
  784. * Set basic media attributes
  785. */
  786. public static function setBasicMediaAttributes(Media $media, array $data, Status $status, int $key): void
  787. {
  788. $media->remote_media = true;
  789. $media->status_id = $status->id;
  790. $media->profile_id = $status->profile_id;
  791. $media->user_id = null;
  792. $media->media_path = $data['url'];
  793. $media->remote_url = $data['url'];
  794. $media->mime = $data['mediaType'];
  795. $media->version = 3;
  796. $media->order = $key + 1;
  797. }
  798. /**
  799. * Set optional media attributes
  800. */
  801. public static function setOptionalMediaAttributes(Media $media, array $data): void
  802. {
  803. $media->blurhash = $data['blurhash'] ?? null;
  804. $media->caption = isset($data['name']) ?
  805. Purify::clean($data['name']) :
  806. null;
  807. if (isset($data['width'])) {
  808. $media->width = $data['width'];
  809. }
  810. if (isset($data['height'])) {
  811. $media->height = $data['height'];
  812. }
  813. if (isset($data['license'])) {
  814. $media->license = License::nameToId($data['license']);
  815. }
  816. }
  817. /**
  818. * Handle media storage processing
  819. */
  820. public static function handleMediaStorage(Media $media): void
  821. {
  822. if ((bool) config_cache('pixelfed.cloud_storage')) {
  823. MediaStoragePipeline::dispatch($media);
  824. }
  825. }
  826. /**
  827. * Validate attachment collection
  828. */
  829. public static function validateAttachmentCollection(array $attachments, array $mediaTypes, array $mimeTypes): bool
  830. {
  831. return Validator::make($attachments, [
  832. '*.type' => [
  833. 'required',
  834. 'string',
  835. Rule::in($mediaTypes),
  836. ],
  837. '*.url' => 'required|url',
  838. '*.mediaType' => [
  839. 'required',
  840. 'string',
  841. Rule::in($mimeTypes),
  842. ],
  843. '*.name' => 'sometimes|nullable|string',
  844. '*.blurhash' => 'sometimes|nullable|string|min:6|max:164',
  845. '*.width' => 'sometimes|nullable|integer|min:1|max:5000',
  846. '*.height' => 'sometimes|nullable|integer|min:1|max:5000',
  847. ])->passes();
  848. }
  849. /**
  850. * Get supported media types
  851. */
  852. public static function getSupportedMediaTypes(): array
  853. {
  854. $mimeTypes = explode(',', config_cache('pixelfed.media_types'));
  855. return in_array('video/mp4', $mimeTypes) ?
  856. ['Document', 'Image', 'Video'] :
  857. ['Document', 'Image'];
  858. }
  859. /**
  860. * Process specific media type attachment
  861. */
  862. public static function processMediaTypeAttachment(array $media, Status $status, int $order): ?Media
  863. {
  864. if (! self::isValidMediaType($media)) {
  865. return null;
  866. }
  867. $mediaModel = new Media;
  868. self::setMediaAttributes($mediaModel, $media, $status, $order);
  869. $mediaModel->save();
  870. return $mediaModel;
  871. }
  872. /**
  873. * Validate media type
  874. */
  875. public static function isValidMediaType(array $media): bool
  876. {
  877. $requiredFields = ['mediaType', 'url'];
  878. foreach ($requiredFields as $field) {
  879. if (! isset($media[$field]) || empty($media[$field])) {
  880. return false;
  881. }
  882. }
  883. return true;
  884. }
  885. /**
  886. * Set media attributes
  887. */
  888. public static function setMediaAttributes(Media $media, array $data, Status $status, int $order): void
  889. {
  890. $media->remote_media = true;
  891. $media->status_id = $status->id;
  892. $media->profile_id = $status->profile_id;
  893. $media->user_id = null;
  894. $media->media_path = $data['url'];
  895. $media->remote_url = $data['url'];
  896. $media->mime = $data['mediaType'];
  897. $media->version = 3;
  898. $media->order = $order;
  899. // Optional attributes
  900. if (isset($data['blurhash'])) {
  901. $media->blurhash = $data['blurhash'];
  902. }
  903. if (isset($data['name'])) {
  904. $media->caption = Purify::clean($data['name']);
  905. }
  906. if (isset($data['width'])) {
  907. $media->width = $data['width'];
  908. }
  909. if (isset($data['height'])) {
  910. $media->height = $data['height'];
  911. }
  912. if (isset($data['license'])) {
  913. $media->license = License::nameToId($data['license']);
  914. }
  915. }
  916. /**
  917. * Fetch or create a profile from a URL
  918. */
  919. public static function profileFirstOrNew(string $url): ?Profile
  920. {
  921. if (! $validatedUrl = self::validateUrl($url)) {
  922. return null;
  923. }
  924. $host = parse_url($validatedUrl, PHP_URL_HOST);
  925. if (self::isLocalDomain($host)) {
  926. return self::getLocalProfile($validatedUrl);
  927. }
  928. return self::getOrFetchRemoteProfile($validatedUrl);
  929. }
  930. /**
  931. * Check if domain is local
  932. */
  933. public static function isLocalDomain(string $host): bool
  934. {
  935. return config('pixelfed.domain.app') == $host;
  936. }
  937. /**
  938. * Get local profile from URL
  939. */
  940. public static function getLocalProfile(string $url): ?Profile
  941. {
  942. $username = last(explode('/', $url));
  943. return Profile::whereNull('status')
  944. ->whereNull('domain')
  945. ->whereUsername($username)
  946. ->firstOrFail();
  947. }
  948. /**
  949. * Get existing or fetch new remote profile
  950. */
  951. public static function getOrFetchRemoteProfile(string $url): ?Profile
  952. {
  953. $profile = Profile::whereRemoteUrl($url)->first();
  954. if ($profile && ! self::needsFetch($profile)) {
  955. return $profile;
  956. }
  957. return self::profileUpdateOrCreate($url);
  958. }
  959. /**
  960. * Check if profile needs to be fetched
  961. */
  962. public static function needsFetch(?Profile $profile): bool
  963. {
  964. return ! $profile?->last_fetched_at ||
  965. $profile->last_fetched_at->lt(now()->subHours(24));
  966. }
  967. /**
  968. * Update or create a profile from ActivityPub data
  969. */
  970. public static function profileUpdateOrCreate(string $url, bool $movedToCheck = false): ?Profile
  971. {
  972. $res = self::fetchProfileFromUrl($url);
  973. if (! $res || ! self::isValidProfileData($res, $url)) {
  974. return null;
  975. }
  976. $domain = parse_url($res['id'], PHP_URL_HOST);
  977. $username = self::extractUsername($res);
  978. if (! $username || self::isProfileBanned($res['id'])) {
  979. return null;
  980. }
  981. $webfinger = "@{$username}@{$domain}";
  982. $instance = self::getOrCreateInstance($domain);
  983. $movedToPid = $movedToCheck ? null : self::handleMovedTo($res);
  984. $profile = Profile::updateOrCreate(
  985. [
  986. 'domain' => strtolower($domain),
  987. 'username' => Purify::clean($webfinger),
  988. ],
  989. self::buildProfileData($res, $webfinger, $movedToPid)
  990. );
  991. self::handleProfileAvatar($profile);
  992. return $profile;
  993. }
  994. /**
  995. * Validate profile data from ActivityPub
  996. */
  997. public static function isValidProfileData(?array $res, string $url): bool
  998. {
  999. if (! $res || ! isset($res['id']) || ! isset($res['inbox'])) {
  1000. return false;
  1001. }
  1002. if (! self::validateUrl($res['inbox']) || ! self::validateUrl($res['id'])) {
  1003. return false;
  1004. }
  1005. $urlDomain = parse_url($url, PHP_URL_HOST);
  1006. $domain = parse_url($res['id'], PHP_URL_HOST);
  1007. return strtolower($urlDomain) === strtolower($domain);
  1008. }
  1009. /**
  1010. * Extract username from profile data
  1011. */
  1012. public static function extractUsername(array $res): ?string
  1013. {
  1014. $username = $res['preferredUsername'] ?? $res['nickname'] ?? null;
  1015. if (! $username || ! ctype_alnum(str_replace(['_', '.', '-'], '', $username))) {
  1016. return null;
  1017. }
  1018. return Purify::clean($username);
  1019. }
  1020. /**
  1021. * Check if profile is banned
  1022. */
  1023. public static function isProfileBanned(string $profileUrl): bool
  1024. {
  1025. return ModeratedProfile::whereProfileUrl($profileUrl)
  1026. ->whereIsBanned(true)
  1027. ->exists();
  1028. }
  1029. /**
  1030. * Get or create federation instance
  1031. */
  1032. public static function getOrCreateInstance(string $domain): Instance
  1033. {
  1034. $instance = Instance::updateOrCreate(['domain' => $domain]);
  1035. if ($instance->wasRecentlyCreated) {
  1036. \App\Jobs\InstancePipeline\FetchNodeinfoPipeline::dispatch($instance)
  1037. ->onQueue('low');
  1038. }
  1039. return $instance;
  1040. }
  1041. /**
  1042. * Handle moved profile references
  1043. */
  1044. public static function handleMovedTo(array $res): ?int
  1045. {
  1046. if (! isset($res['movedTo']) || ! self::validateUrl($res['movedTo'])) {
  1047. return null;
  1048. }
  1049. $movedTo = self::profileUpdateOrCreate($res['movedTo'], true);
  1050. return $movedTo?->id;
  1051. }
  1052. /**
  1053. * Build profile data array for database
  1054. */
  1055. public static function buildProfileData(array $res, string $webfinger, ?int $movedToPid): array
  1056. {
  1057. return [
  1058. 'webfinger' => Purify::clean($webfinger),
  1059. 'key_id' => $res['publicKey']['id'],
  1060. 'remote_url' => $res['id'],
  1061. 'name' => isset($res['name']) ? Purify::clean($res['name']) : 'user',
  1062. 'bio' => isset($res['summary']) ? app(SanitizeService::class)->html($res['summary']) : null,
  1063. 'sharedInbox' => $res['endpoints']['sharedInbox'] ?? null,
  1064. 'inbox_url' => $res['inbox'],
  1065. 'outbox_url' => $res['outbox'] ?? null,
  1066. 'public_key' => $res['publicKey']['publicKeyPem'],
  1067. 'indexable' => isset($res['indexable']) ? (bool) $res['indexable'] : false,
  1068. 'moved_to_profile_id' => $movedToPid,
  1069. 'is_private' => isset($res['manuallyApprovesFollowers']) ? (bool) $res['manuallyApprovesFollowers'] : true,
  1070. ];
  1071. }
  1072. /**
  1073. * Handle profile avatar updates
  1074. */
  1075. public static function handleProfileAvatar(Profile $profile): void
  1076. {
  1077. if (! $profile->last_fetched_at ||
  1078. $profile->last_fetched_at->lt(now()->subMonths(3))
  1079. ) {
  1080. RemoteAvatarFetch::dispatch($profile);
  1081. }
  1082. $profile->last_fetched_at = now();
  1083. $profile->save();
  1084. }
  1085. public static function profileFetch($url): ?Profile
  1086. {
  1087. return self::profileFirstOrNew($url);
  1088. }
  1089. public static function getSignedFetch($url)
  1090. {
  1091. return ActivityPubFetchService::get($url);
  1092. }
  1093. public static function sendSignedObject($profile, $url, $body)
  1094. {
  1095. if (app()->environment() !== 'production') {
  1096. return;
  1097. }
  1098. ActivityPubDeliveryService::queue()
  1099. ->from($profile)
  1100. ->to($url)
  1101. ->payload($body)
  1102. ->send();
  1103. }
  1104. }