Helpers.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511
  1. <?php
  2. namespace App\Util\ActivityPub;
  3. use DB, Cache, Purify, Storage, Request, Validator;
  4. use App\{
  5. Activity,
  6. Follower,
  7. Like,
  8. Media,
  9. Notification,
  10. Profile,
  11. Status
  12. };
  13. use Zttp\Zttp;
  14. use Carbon\Carbon;
  15. use GuzzleHttp\Client;
  16. use Illuminate\Http\File;
  17. use Illuminate\Validation\Rule;
  18. use App\Jobs\AvatarPipeline\CreateAvatar;
  19. use App\Jobs\RemoteFollowPipeline\RemoteFollowImportRecent;
  20. use App\Jobs\ImageOptimizePipeline\{ImageOptimize,ImageThumbnail};
  21. use App\Jobs\StatusPipeline\NewStatusPipeline;
  22. use App\Util\ActivityPub\HttpSignature;
  23. use Illuminate\Support\Str;
  24. use App\Services\ActivityPubFetchService;
  25. use App\Services\ActivityPubDeliveryService;
  26. use App\Services\MediaPathService;
  27. use App\Services\MediaStorageService;
  28. use App\Jobs\MediaPipeline\MediaStoragePipeline;
  29. use App\Jobs\AvatarPipeline\RemoteAvatarFetch;
  30. class Helpers {
  31. public static function validateObject($data)
  32. {
  33. $verbs = ['Create', 'Announce', 'Like', 'Follow', 'Delete', 'Accept', 'Reject', 'Undo', 'Tombstone'];
  34. $valid = Validator::make($data, [
  35. 'type' => [
  36. 'required',
  37. 'string',
  38. Rule::in($verbs)
  39. ],
  40. 'id' => 'required|string',
  41. 'actor' => 'required|string|url',
  42. 'object' => 'required',
  43. 'object.type' => 'required_if:type,Create',
  44. 'object.attributedTo' => 'required_if:type,Create|url',
  45. 'published' => 'required_if:type,Create|date'
  46. ])->passes();
  47. return $valid;
  48. }
  49. public static function verifyAttachments($data)
  50. {
  51. if(!isset($data['object']) || empty($data['object'])) {
  52. $data = ['object'=>$data];
  53. }
  54. $activity = $data['object'];
  55. $mimeTypes = explode(',', config('pixelfed.media_types'));
  56. $mediaTypes = in_array('video/mp4', $mimeTypes) ? ['Document', 'Image', 'Video'] : ['Document', 'Image'];
  57. if(!isset($activity['attachment']) || empty($activity['attachment'])) {
  58. return false;
  59. }
  60. $attachment = $activity['attachment'];
  61. $valid = Validator::make($attachment, [
  62. '*.type' => [
  63. 'required',
  64. 'string',
  65. Rule::in($mediaTypes)
  66. ],
  67. '*.url' => 'required|url|max:255',
  68. '*.mediaType' => [
  69. 'required',
  70. 'string',
  71. Rule::in($mimeTypes)
  72. ],
  73. '*.name' => 'nullable|string|max:255'
  74. ])->passes();
  75. return $valid;
  76. }
  77. public static function normalizeAudience($data, $localOnly = true)
  78. {
  79. if(!isset($data['to'])) {
  80. return;
  81. }
  82. $audience = [];
  83. $audience['to'] = [];
  84. $audience['cc'] = [];
  85. $scope = 'private';
  86. if(is_array($data['to']) && !empty($data['to'])) {
  87. foreach ($data['to'] as $to) {
  88. if($to == 'https://www.w3.org/ns/activitystreams#Public') {
  89. $scope = 'public';
  90. continue;
  91. }
  92. $url = $localOnly ? self::validateLocalUrl($to) : self::validateUrl($to);
  93. if($url != false) {
  94. array_push($audience['to'], $url);
  95. }
  96. }
  97. }
  98. if(is_array($data['cc']) && !empty($data['cc'])) {
  99. foreach ($data['cc'] as $cc) {
  100. if($cc == 'https://www.w3.org/ns/activitystreams#Public') {
  101. $scope = 'unlisted';
  102. continue;
  103. }
  104. $url = $localOnly ? self::validateLocalUrl($cc) : self::validateUrl($cc);
  105. if($url != false) {
  106. array_push($audience['cc'], $url);
  107. }
  108. }
  109. }
  110. $audience['scope'] = $scope;
  111. return $audience;
  112. }
  113. public static function userInAudience($profile, $data)
  114. {
  115. $audience = self::normalizeAudience($data);
  116. $url = $profile->permalink();
  117. return in_array($url, $audience['to']) || in_array($url, $audience['cc']);
  118. }
  119. public static function validateUrl($url)
  120. {
  121. if(is_array($url)) {
  122. $url = $url[0];
  123. }
  124. $hash = hash('sha256', $url);
  125. $key = "helpers:url:valid:sha256-{$hash}";
  126. $ttl = now()->addMinutes(5);
  127. $valid = Cache::remember($key, $ttl, function() use($url) {
  128. $localhosts = [
  129. '127.0.0.1', 'localhost', '::1'
  130. ];
  131. if(mb_substr($url, 0, 8) !== 'https://') {
  132. return false;
  133. }
  134. $valid = filter_var($url, FILTER_VALIDATE_URL);
  135. if(!$valid) {
  136. return false;
  137. }
  138. $host = parse_url($valid, PHP_URL_HOST);
  139. if(count(dns_get_record($host, DNS_A | DNS_AAAA)) == 0) {
  140. return false;
  141. }
  142. if(config('costar.enabled') == true) {
  143. if(
  144. (config('costar.domain.block') != null && Str::contains($host, config('costar.domain.block')) == true) ||
  145. (config('costar.actor.block') != null && in_array($url, config('costar.actor.block')) == true)
  146. ) {
  147. return false;
  148. }
  149. }
  150. if(in_array($host, $localhosts)) {
  151. return false;
  152. }
  153. return $url;
  154. });
  155. return $valid;
  156. }
  157. public static function validateLocalUrl($url)
  158. {
  159. $url = self::validateUrl($url);
  160. if($url == true) {
  161. $domain = config('pixelfed.domain.app');
  162. $host = parse_url($url, PHP_URL_HOST);
  163. $url = $domain === $host ? $url : false;
  164. return $url;
  165. }
  166. return false;
  167. }
  168. public static function zttpUserAgent()
  169. {
  170. $version = config('pixelfed.version');
  171. $url = config('app.url');
  172. return [
  173. 'Accept' => 'application/activity+json',
  174. 'User-Agent' => "(Pixelfed/{$version}; +{$url})",
  175. ];
  176. }
  177. public static function fetchFromUrl($url = false)
  178. {
  179. if(self::validateUrl($url) == false) {
  180. return;
  181. }
  182. $hash = hash('sha256', $url);
  183. $key = "helpers:url:fetcher:sha256-{$hash}";
  184. $ttl = now()->addMinutes(5);
  185. return Cache::remember($key, $ttl, function() use($url) {
  186. $res = ActivityPubFetchService::get($url);
  187. $res = json_decode($res, true, 8);
  188. if(json_last_error() == JSON_ERROR_NONE) {
  189. return $res;
  190. } else {
  191. return false;
  192. }
  193. });
  194. }
  195. public static function fetchProfileFromUrl($url)
  196. {
  197. return self::fetchFromUrl($url);
  198. }
  199. public static function statusFirstOrFetch($url, $replyTo = false)
  200. {
  201. $url = self::validateUrl($url);
  202. if($url == false) {
  203. return;
  204. }
  205. $host = parse_url($url, PHP_URL_HOST);
  206. $local = config('pixelfed.domain.app') == $host ? true : false;
  207. if($local) {
  208. $id = (int) last(explode('/', $url));
  209. return Status::whereNotIn('scope', ['draft','archived'])->findOrFail($id);
  210. }
  211. $cached = Status::whereNotIn('scope', ['draft','archived'])
  212. ->whereUri($url)
  213. ->orWhere('object_url', $url)
  214. ->first();
  215. if($cached) {
  216. return $cached;
  217. }
  218. $res = self::fetchFromUrl($url);
  219. if(!$res || empty($res)) {
  220. return;
  221. }
  222. if(isset($res['object'])) {
  223. $activity = $res;
  224. } else {
  225. $activity = ['object' => $res];
  226. }
  227. $scope = 'private';
  228. $cw = isset($res['sensitive']) ? (bool) $res['sensitive'] : false;
  229. if(isset($res['to']) == true) {
  230. if(is_array($res['to']) && in_array('https://www.w3.org/ns/activitystreams#Public', $res['to'])) {
  231. $scope = 'public';
  232. }
  233. if(is_string($res['to']) && 'https://www.w3.org/ns/activitystreams#Public' == $res['to']) {
  234. $scope = 'public';
  235. }
  236. }
  237. if(isset($res['cc']) == true) {
  238. if(is_array($res['cc']) && in_array('https://www.w3.org/ns/activitystreams#Public', $res['cc'])) {
  239. $scope = 'unlisted';
  240. }
  241. if(is_string($res['cc']) && 'https://www.w3.org/ns/activitystreams#Public' == $res['cc']) {
  242. $scope = 'unlisted';
  243. }
  244. }
  245. if(config('costar.enabled') == true) {
  246. $blockedKeywords = config('costar.keyword.block');
  247. if($blockedKeywords !== null) {
  248. $keywords = config('costar.keyword.block');
  249. foreach($keywords as $kw) {
  250. if(Str::contains($res['content'], $kw) == true) {
  251. return;
  252. }
  253. }
  254. }
  255. $unlisted = config('costar.domain.unlisted');
  256. if(in_array(parse_url($url, PHP_URL_HOST), $unlisted) == true) {
  257. $unlisted = true;
  258. $scope = 'unlisted';
  259. } else {
  260. $unlisted = false;
  261. }
  262. $cwDomains = config('costar.domain.cw');
  263. if(in_array(parse_url($url, PHP_URL_HOST), $cwDomains) == true) {
  264. $cw = true;
  265. }
  266. }
  267. $id = isset($res['id']) ? $res['id'] : $url;
  268. $idDomain = parse_url($id, PHP_URL_HOST);
  269. $urlDomain = parse_url($url, PHP_URL_HOST);
  270. if(!self::validateUrl($id)) {
  271. return;
  272. }
  273. if(isset($activity['object']['attributedTo'])) {
  274. $actorDomain = parse_url($activity['object']['attributedTo'], PHP_URL_HOST);
  275. if(!self::validateUrl($activity['object']['attributedTo']) ||
  276. $idDomain !== $actorDomain)
  277. {
  278. return;
  279. }
  280. }
  281. if(
  282. $idDomain !== $urlDomain ||
  283. $actorDomain !== $urlDomain
  284. ) {
  285. return;
  286. }
  287. $profile = self::profileFirstOrNew($activity['object']['attributedTo']);
  288. if(isset($activity['object']['inReplyTo']) && !empty($activity['object']['inReplyTo']) && $replyTo == true) {
  289. $reply_to = self::statusFirstOrFetch($activity['object']['inReplyTo'], false);
  290. $reply_to = optional($reply_to)->id;
  291. } else {
  292. $reply_to = null;
  293. }
  294. $ts = is_array($res['published']) ? $res['published'][0] : $res['published'];
  295. $status = DB::transaction(function() use($profile, $res, $url, $ts, $reply_to, $cw, $scope, $id) {
  296. $status = new Status;
  297. $status->profile_id = $profile->id;
  298. $status->url = isset($res['url']) ? $res['url'] : $url;
  299. $status->uri = isset($res['url']) ? $res['url'] : $url;
  300. $status->object_url = $id;
  301. $status->caption = strip_tags($res['content']);
  302. $status->rendered = Purify::clean($res['content']);
  303. $status->created_at = Carbon::parse($ts);
  304. $status->in_reply_to_id = $reply_to;
  305. $status->local = false;
  306. $status->is_nsfw = $cw;
  307. $status->scope = $scope;
  308. $status->visibility = $scope;
  309. $status->cw_summary = $cw == true && isset($res['summary']) ?
  310. Purify::clean(strip_tags($res['summary'])) : null;
  311. $status->save();
  312. if($reply_to == null) {
  313. self::importNoteAttachment($res, $status);
  314. }
  315. return $status;
  316. });
  317. return $status;
  318. }
  319. public static function statusFetch($url)
  320. {
  321. return self::statusFirstOrFetch($url);
  322. }
  323. public static function importNoteAttachment($data, Status $status)
  324. {
  325. if(self::verifyAttachments($data) == false) {
  326. return;
  327. }
  328. $attachments = isset($data['object']) ? $data['object']['attachment'] : $data['attachment'];
  329. $user = $status->profile;
  330. $storagePath = MediaPathService::get($user, 2);
  331. $allowed = explode(',', config('pixelfed.media_types'));
  332. foreach($attachments as $media) {
  333. $type = $media['mediaType'];
  334. $url = $media['url'];
  335. $blurhash = isset($media['blurhash']) ? $media['blurhash'] : null;
  336. $valid = self::validateUrl($url);
  337. if(in_array($type, $allowed) == false || $valid == false) {
  338. continue;
  339. }
  340. $media = new Media();
  341. $media->blurhash = $blurhash;
  342. $media->remote_media = true;
  343. $media->status_id = $status->id;
  344. $media->profile_id = $status->profile_id;
  345. $media->user_id = null;
  346. $media->media_path = $url;
  347. $media->remote_url = $url;
  348. $media->mime = $type;
  349. $media->version = 3;
  350. $media->save();
  351. if(config('pixelfed.cloud_storage') == true) {
  352. MediaStoragePipeline::dispatch($media);
  353. }
  354. }
  355. $status->viewType();
  356. return;
  357. }
  358. public static function profileFirstOrNew($url, $runJobs = false)
  359. {
  360. $url = self::validateUrl($url);
  361. if($url == false || strlen($url) > 190) {
  362. return;
  363. }
  364. $hash = base64_encode($url);
  365. $key = 'ap:profile:by_url:' . $hash;
  366. $ttl = now()->addMinutes(5);
  367. $profile = Cache::remember($key, $ttl, function() use($url, $runJobs) {
  368. $host = parse_url($url, PHP_URL_HOST);
  369. $local = config('pixelfed.domain.app') == $host ? true : false;
  370. if($local == true) {
  371. $id = last(explode('/', $url));
  372. return Profile::whereNull('status')
  373. ->whereNull('domain')
  374. ->whereUsername($id)
  375. ->firstOrFail();
  376. }
  377. $res = self::fetchProfileFromUrl($url);
  378. if(isset($res['id']) == false) {
  379. return;
  380. }
  381. $domain = parse_url($res['id'], PHP_URL_HOST);
  382. if(!isset($res['preferredUsername']) && !isset($res['nickname'])) {
  383. return;
  384. }
  385. $username = (string) Purify::clean($res['preferredUsername'] ?? $res['nickname']);
  386. if(empty($username)) {
  387. return;
  388. }
  389. $remoteUsername = $username;
  390. $webfinger = "@{$username}@{$domain}";
  391. abort_if(!self::validateUrl($res['inbox']), 400);
  392. abort_if(!self::validateUrl($res['id']), 400);
  393. $profile = Profile::whereRemoteUrl($res['id'])->first();
  394. if(!$profile) {
  395. $profile = DB::transaction(function() use($domain, $webfinger, $res, $runJobs) {
  396. $profile = new Profile();
  397. $profile->domain = strtolower($domain);
  398. $profile->username = strtolower(Purify::clean($webfinger));
  399. $profile->name = isset($res['name']) ? Purify::clean($res['name']) : 'user';
  400. $profile->bio = isset($res['summary']) ? Purify::clean($res['summary']) : null;
  401. $profile->sharedInbox = isset($res['endpoints']) && isset($res['endpoints']['sharedInbox']) ? $res['endpoints']['sharedInbox'] : null;
  402. $profile->inbox_url = strtolower($res['inbox']);
  403. $profile->outbox_url = strtolower($res['outbox']);
  404. $profile->remote_url = strtolower($res['id']);
  405. $profile->public_key = $res['publicKey']['publicKeyPem'];
  406. $profile->key_id = $res['publicKey']['id'];
  407. $profile->webfinger = strtolower(Purify::clean($webfinger));
  408. $profile->last_fetched_at = now();
  409. $profile->save();
  410. RemoteAvatarFetch::dispatch($profile);
  411. return $profile;
  412. });
  413. } else {
  414. // Update info after 24 hours
  415. if($profile->last_fetched_at == null ||
  416. $profile->last_fetched_at->lt(now()->subHours(24)) == true
  417. ) {
  418. $profile->name = isset($res['name']) ? Purify::clean($res['name']) : 'user';
  419. $profile->bio = isset($res['summary']) ? Purify::clean($res['summary']) : null;
  420. $profile->last_fetched_at = now();
  421. $profile->sharedInbox = isset($res['endpoints']) && isset($res['endpoints']['sharedInbox']) && Helpers::validateUrl($res['endpoints']['sharedInbox']) ? $res['endpoints']['sharedInbox'] : null;
  422. $profile->save();
  423. }
  424. RemoteAvatarFetch::dispatch($profile);
  425. }
  426. return $profile;
  427. });
  428. return $profile;
  429. }
  430. public static function profileFetch($url)
  431. {
  432. return self::profileFirstOrNew($url);
  433. }
  434. public static function sendSignedObject($profile, $url, $body)
  435. {
  436. ActivityPubDeliveryService::queue()
  437. ->from($profile)
  438. ->to($url)
  439. ->payload($body)
  440. ->send();
  441. }
  442. }