Helpers.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455
  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. class Helpers {
  25. public static function validateObject($data)
  26. {
  27. $verbs = ['Create', 'Announce', 'Like', 'Follow', 'Delete', 'Accept', 'Reject', 'Undo', 'Tombstone'];
  28. $valid = Validator::make($data, [
  29. 'type' => [
  30. 'required',
  31. 'string',
  32. Rule::in($verbs)
  33. ],
  34. 'id' => 'required|string',
  35. 'actor' => 'required|string|url',
  36. 'object' => 'required',
  37. 'object.type' => 'required_if:type,Create',
  38. 'object.attributedTo' => 'required_if:type,Create|url',
  39. 'published' => 'required_if:type,Create|date'
  40. ])->passes();
  41. return $valid;
  42. }
  43. public static function verifyAttachments($data)
  44. {
  45. if(!isset($data['object']) || empty($data['object'])) {
  46. $data = ['object'=>$data];
  47. }
  48. $activity = $data['object'];
  49. $mimeTypes = explode(',', config('pixelfed.media_types'));
  50. $mediaTypes = in_array('video/mp4', $mimeTypes) ? ['Document', 'Image', 'Video'] : ['Document', 'Image'];
  51. if(!isset($activity['attachment']) || empty($activity['attachment'])) {
  52. return false;
  53. }
  54. $attachment = $activity['attachment'];
  55. $valid = Validator::make($attachment, [
  56. '*.type' => [
  57. 'required',
  58. 'string',
  59. Rule::in($mediaTypes)
  60. ],
  61. '*.url' => 'required|url|max:255',
  62. '*.mediaType' => [
  63. 'required',
  64. 'string',
  65. Rule::in($mimeTypes)
  66. ],
  67. '*.name' => 'nullable|string|max:255'
  68. ])->passes();
  69. return $valid;
  70. }
  71. public static function normalizeAudience($data, $localOnly = true)
  72. {
  73. if(!isset($data['to'])) {
  74. return;
  75. }
  76. $audience = [];
  77. $audience['to'] = [];
  78. $audience['cc'] = [];
  79. $scope = 'private';
  80. if(is_array($data['to']) && !empty($data['to'])) {
  81. foreach ($data['to'] as $to) {
  82. if($to == 'https://www.w3.org/ns/activitystreams#Public') {
  83. $scope = 'public';
  84. continue;
  85. }
  86. $url = $localOnly ? self::validateLocalUrl($to) : self::validateUrl($to);
  87. if($url != false) {
  88. array_push($audience['to'], $url);
  89. }
  90. }
  91. }
  92. if(is_array($data['cc']) && !empty($data['cc'])) {
  93. foreach ($data['cc'] as $cc) {
  94. if($cc == 'https://www.w3.org/ns/activitystreams#Public') {
  95. $scope = 'unlisted';
  96. continue;
  97. }
  98. $url = $localOnly ? self::validateLocalUrl($cc) : self::validateUrl($cc);
  99. if($url != false) {
  100. array_push($audience['cc'], $url);
  101. }
  102. }
  103. }
  104. $audience['scope'] = $scope;
  105. return $audience;
  106. }
  107. public static function userInAudience($profile, $data)
  108. {
  109. $audience = self::normalizeAudience($data);
  110. $url = $profile->permalink();
  111. return in_array($url, $audience['to']) || in_array($url, $audience['cc']);
  112. }
  113. public static function validateUrl($url)
  114. {
  115. $localhosts = [
  116. '127.0.0.1', 'localhost', '::1'
  117. ];
  118. if(mb_substr($url, 0, 8) !== 'https://') {
  119. return false;
  120. }
  121. $valid = filter_var($url, FILTER_VALIDATE_URL);
  122. if(!$valid) {
  123. return false;
  124. }
  125. $host = parse_url($valid, PHP_URL_HOST);
  126. if(count(dns_get_record($host, DNS_A | DNS_AAAA)) == 0) {
  127. return false;
  128. }
  129. if(config('costar.enabled') == true) {
  130. if(
  131. (config('costar.domain.block') != null && Str::contains($host, config('costar.domain.block')) == true) ||
  132. (config('costar.actor.block') != null && in_array($url, config('costar.actor.block')) == true)
  133. ) {
  134. return false;
  135. }
  136. }
  137. if(in_array($host, $localhosts)) {
  138. return false;
  139. }
  140. return $valid;
  141. }
  142. public static function validateLocalUrl($url)
  143. {
  144. $url = self::validateUrl($url);
  145. if($url == true) {
  146. $domain = config('pixelfed.domain.app');
  147. $host = parse_url($url, PHP_URL_HOST);
  148. $url = $domain === $host ? $url : false;
  149. return $url;
  150. }
  151. return false;
  152. }
  153. public static function zttpUserAgent()
  154. {
  155. return [
  156. 'Accept' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
  157. 'User-Agent' => 'PixelfedBot - https://pixelfed.org',
  158. ];
  159. }
  160. public static function fetchFromUrl($url)
  161. {
  162. $url = self::validateUrl($url);
  163. if($url == false) {
  164. return;
  165. }
  166. $res = Zttp::withHeaders(self::zttpUserAgent())->get($url);
  167. $res = json_decode($res->body(), true, 8);
  168. if(json_last_error() == JSON_ERROR_NONE) {
  169. return $res;
  170. } else {
  171. return false;
  172. }
  173. }
  174. public static function fetchProfileFromUrl($url)
  175. {
  176. return self::fetchFromUrl($url);
  177. }
  178. public static function statusFirstOrFetch($url, $replyTo = false)
  179. {
  180. $url = self::validateUrl($url);
  181. if($url == false) {
  182. return;
  183. }
  184. $host = parse_url($url, PHP_URL_HOST);
  185. $local = config('pixelfed.domain.app') == $host ? true : false;
  186. if($local) {
  187. $id = (int) last(explode('/', $url));
  188. return Status::findOrFail($id);
  189. } else {
  190. $cached = Status::whereUrl($url)->first();
  191. if($cached) {
  192. return $cached;
  193. }
  194. $res = self::fetchFromUrl($url);
  195. if(!$res || empty($res)) {
  196. return;
  197. }
  198. if(isset($res['object'])) {
  199. $activity = $res;
  200. } else {
  201. $activity = ['object' => $res];
  202. }
  203. if(isset($res['content']) == false) {
  204. abort(400, 'Invalid object');
  205. }
  206. $scope = 'private';
  207. $cw = isset($activity['sensitive']) ? (bool) $activity['sensitive'] : false;
  208. if(isset($res['to']) == true) {
  209. if(is_array($res['to']) && in_array('https://www.w3.org/ns/activitystreams#Public', $res['to'])) {
  210. $scope = 'public';
  211. }
  212. if(is_string($res['to']) && 'https://www.w3.org/ns/activitystreams#Public' == $res['to']) {
  213. $scope = 'public';
  214. }
  215. }
  216. if(isset($res['cc']) == true) {
  217. if(is_array($res['cc']) && in_array('https://www.w3.org/ns/activitystreams#Public', $res['cc'])) {
  218. $scope = 'unlisted';
  219. }
  220. if(is_string($res['cc']) && 'https://www.w3.org/ns/activitystreams#Public' == $res['cc']) {
  221. $scope = 'unlisted';
  222. }
  223. }
  224. if(config('costar.enabled') == true) {
  225. $blockedKeywords = config('costar.keyword.block');
  226. if($blockedKeywords !== null) {
  227. $keywords = config('costar.keyword.block');
  228. foreach($keywords as $kw) {
  229. if(Str::contains($res['content'], $kw) == true) {
  230. abort(400, 'Invalid object');
  231. }
  232. }
  233. }
  234. $unlisted = config('costar.domain.unlisted');
  235. if(in_array(parse_url($url, PHP_URL_HOST), $unlisted) == true) {
  236. $unlisted = true;
  237. $scope = 'unlisted';
  238. } else {
  239. $unlisted = false;
  240. }
  241. $cw = config('costar.domain.cw');
  242. if(in_array(parse_url($url, PHP_URL_HOST), $cw) == true) {
  243. $cw = true;
  244. } else {
  245. $cw = isset($activity['sensitive']) ? (bool) $activity['sensitive'] : false;
  246. }
  247. }
  248. if(!self::validateUrl($res['id']) ||
  249. !self::validateUrl($activity['object']['attributedTo'])
  250. ) {
  251. abort(400, 'Invalid object url');
  252. }
  253. $idDomain = parse_url($res['id'], PHP_URL_HOST);
  254. $urlDomain = parse_url($url, PHP_URL_HOST);
  255. $actorDomain = parse_url($activity['object']['attributedTo'], PHP_URL_HOST);
  256. if(
  257. $idDomain !== $urlDomain ||
  258. $actorDomain !== $urlDomain ||
  259. $idDomain !== $actorDomain
  260. ) {
  261. abort(400, 'Invalid object');
  262. }
  263. $profile = self::profileFirstOrNew($activity['object']['attributedTo']);
  264. if(isset($activity['object']['inReplyTo']) && !empty($activity['object']['inReplyTo']) && $replyTo == true) {
  265. $reply_to = self::statusFirstOrFetch($activity['object']['inReplyTo'], false);
  266. $reply_to = $reply_to->id;
  267. } else {
  268. $reply_to = null;
  269. }
  270. $ts = is_array($res['published']) ? $res['published'][0] : $res['published'];
  271. $status = DB::transaction(function() use($profile, $res, $url, $ts, $reply_to, $cw, $scope) {
  272. $status = new Status;
  273. $status->profile_id = $profile->id;
  274. $status->url = isset($res['url']) ? $res['url'] : $url;
  275. $status->uri = isset($res['url']) ? $res['url'] : $url;
  276. $status->caption = strip_tags($res['content']);
  277. $status->rendered = Purify::clean($res['content']);
  278. $status->created_at = Carbon::parse($ts);
  279. $status->in_reply_to_id = $reply_to;
  280. $status->local = false;
  281. $status->is_nsfw = $cw;
  282. $status->scope = $scope;
  283. $status->visibility = $scope;
  284. $status->save();
  285. if($reply_to == null) {
  286. self::importNoteAttachment($res, $status);
  287. }
  288. return $status;
  289. });
  290. return $status;
  291. }
  292. }
  293. public static function statusFetch($url)
  294. {
  295. return self::statusFirstOrFetch($url);
  296. }
  297. public static function importNoteAttachment($data, Status $status)
  298. {
  299. if(self::verifyAttachments($data) == false) {
  300. return;
  301. }
  302. $attachments = isset($data['object']) ? $data['object']['attachment'] : $data['attachment'];
  303. $user = $status->profile;
  304. $monthHash = hash('sha1', date('Y').date('m'));
  305. $userHash = hash('sha1', $user->id.(string) $user->created_at);
  306. $storagePath = "public/m/{$monthHash}/{$userHash}";
  307. $allowed = explode(',', config('pixelfed.media_types'));
  308. foreach($attachments as $media) {
  309. $type = $media['mediaType'];
  310. $url = $media['url'];
  311. $valid = self::validateUrl($url);
  312. if(in_array($type, $allowed) == false || $valid == false) {
  313. continue;
  314. }
  315. $media = new Media();
  316. $media->remote_media = true;
  317. $media->status_id = $status->id;
  318. $media->profile_id = $status->profile_id;
  319. $media->user_id = null;
  320. $media->media_path = $url;
  321. $media->remote_url = $url;
  322. $media->mime = $type;
  323. $media->save();
  324. }
  325. $status->viewType();
  326. return;
  327. }
  328. public static function profileFirstOrNew($url, $runJobs = false)
  329. {
  330. $url = self::validateUrl($url);
  331. if($url == false) {
  332. abort(400, 'Invalid url');
  333. }
  334. $host = parse_url($url, PHP_URL_HOST);
  335. $local = config('pixelfed.domain.app') == $host ? true : false;
  336. if($local == true) {
  337. $id = last(explode('/', $url));
  338. return Profile::whereNull('status')
  339. ->whereNull('domain')
  340. ->whereUsername($id)
  341. ->firstOrFail();
  342. }
  343. $res = self::fetchProfileFromUrl($url);
  344. if(isset($res['id']) == false) {
  345. return;
  346. }
  347. $domain = parse_url($res['id'], PHP_URL_HOST);
  348. $username = (string) Purify::clean($res['preferredUsername']);
  349. if(empty($username)) {
  350. return;
  351. }
  352. $remoteUsername = "@{$username}@{$domain}";
  353. abort_if(!self::validateUrl($res['inbox']), 400);
  354. abort_if(!self::validateUrl($res['outbox']), 400);
  355. abort_if(!self::validateUrl($res['id']), 400);
  356. $profile = Profile::whereRemoteUrl($res['id'])->first();
  357. if(!$profile) {
  358. $profile = new Profile();
  359. $profile->domain = $domain;
  360. $profile->username = (string) Purify::clean($remoteUsername);
  361. $profile->name = Purify::clean($res['name']) ?? 'user';
  362. $profile->bio = Purify::clean($res['summary']);
  363. $profile->sharedInbox = isset($res['endpoints']) && isset($res['endpoints']['sharedInbox']) ? $res['endpoints']['sharedInbox'] : null;
  364. $profile->inbox_url = $res['inbox'];
  365. $profile->outbox_url = $res['outbox'];
  366. $profile->remote_url = $res['id'];
  367. $profile->public_key = $res['publicKey']['publicKeyPem'];
  368. $profile->key_id = $res['publicKey']['id'];
  369. $profile->save();
  370. if($runJobs == true) {
  371. // RemoteFollowImportRecent::dispatch($res, $profile);
  372. CreateAvatar::dispatch($profile);
  373. }
  374. }
  375. return $profile;
  376. }
  377. public static function profileFetch($url)
  378. {
  379. return self::profileFirstOrNew($url);
  380. }
  381. public static function sendSignedObject($senderProfile, $url, $body)
  382. {
  383. abort_if(!self::validateUrl($url), 400);
  384. $payload = json_encode($body);
  385. $headers = HttpSignature::sign($senderProfile, $url, $body);
  386. $ch = curl_init($url);
  387. curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
  388. curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
  389. curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
  390. curl_setopt($ch, CURLOPT_HEADER, true);
  391. $response = curl_exec($ch);
  392. return;
  393. }
  394. }