StoryFetch.php 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788
  1. <?php
  2. namespace App\Jobs\StoryPipeline;
  3. use App\Services\MediaPathService;
  4. use App\Services\StoryIndexService;
  5. use App\Services\StoryService;
  6. use App\Story;
  7. use App\Util\ActivityPub\Helpers;
  8. use App\Util\ActivityPub\Validator\StoryValidator;
  9. use App\Util\Lexer\Bearcap;
  10. use Cache;
  11. use Exception;
  12. use Illuminate\Bus\Queueable;
  13. use Illuminate\Contracts\Queue\ShouldQueue;
  14. use Illuminate\Foundation\Bus\Dispatchable;
  15. use Illuminate\Http\Client\ConnectionException;
  16. use Illuminate\Http\Client\RequestException;
  17. use Illuminate\Http\File;
  18. use Illuminate\Queue\InteractsWithQueue;
  19. use Illuminate\Queue\SerializesModels;
  20. use Illuminate\Support\Facades\DB;
  21. use Illuminate\Support\Facades\Http;
  22. use Illuminate\Support\Facades\Storage;
  23. use Illuminate\Support\Facades\Validator;
  24. use Illuminate\Support\Str;
  25. use Log;
  26. class StoryFetch implements ShouldQueue
  27. {
  28. use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
  29. protected $activity;
  30. private const MAX_DURATION = 300;
  31. private const REQUEST_TIMEOUT = 30;
  32. private const MAX_REDIRECTS = 3;
  33. // Rate limiting
  34. public $tries = 3;
  35. public $maxExceptions = 2;
  36. public $backoff = [30, 60, 120];
  37. /**
  38. * Create a new job instance.
  39. */
  40. public function __construct($activity)
  41. {
  42. $this->activity = $activity;
  43. }
  44. /**
  45. * Execute the job.
  46. */
  47. public function handle()
  48. {
  49. if (config('app.dev_log')) {
  50. Log::info('StoryFetch job started', ['activity_id' => $this->activity['id'] ?? 'unknown']);
  51. }
  52. try {
  53. $this->processStoryFetch();
  54. } catch (Exception $e) {
  55. if (config('app.dev_log')) {
  56. Log::error('StoryFetch job failed', [
  57. 'activity_id' => $this->activity['id'] ?? 'unknown',
  58. 'error' => $e->getMessage(),
  59. 'trace' => $e->getTraceAsString(),
  60. ]);
  61. }
  62. throw $e;
  63. }
  64. }
  65. /**
  66. * Main processing logic
  67. */
  68. private function processStoryFetch()
  69. {
  70. if (! $this->validateActivityStructure()) {
  71. if (config('app.dev_log')) {
  72. Log::warning('Invalid activity structure', ['activity' => $this->activity]);
  73. }
  74. return;
  75. }
  76. $activity = $this->activity;
  77. $activityId = $activity['id'];
  78. $activityActor = $activity['actor'];
  79. if (! $this->validateDomainConsistency($activityId, $activityActor)) {
  80. if (config('app.dev_log')) {
  81. Log::warning('Domain mismatch detected', [
  82. 'activity_id' => $activityId,
  83. 'actor' => $activityActor,
  84. ]);
  85. }
  86. return;
  87. }
  88. // Rate limiting check
  89. if ($this->isRateLimited($activityActor)) {
  90. if (config('app.dev_log')) {
  91. Log::info('Rate limited', ['actor' => $activityActor]);
  92. }
  93. $this->release(3600); // Retry in 1 hour
  94. return;
  95. }
  96. // Decode and validate bearcap token
  97. $bearcap = $this->validateBearcap($activity['object']['object'] ?? null);
  98. if (! $bearcap) {
  99. return;
  100. }
  101. $url = $bearcap['url'];
  102. $token = $bearcap['token'];
  103. // Additional domain validation for bearcap URL
  104. if (! $this->validateDomainConsistency($activityId, $url)) {
  105. if (config('app.dev_log')) {
  106. Log::warning('Bearcap URL domain mismatch', [
  107. 'activity_id' => $activityId,
  108. 'bearcap_url' => $url,
  109. ]);
  110. }
  111. return;
  112. }
  113. // Fetch and validate story data
  114. $payload = $this->fetchStoryPayload($url, $token);
  115. if (! $payload) {
  116. return;
  117. }
  118. // Validate payload structure and security
  119. if (! $this->validatePayload($payload)) {
  120. return;
  121. }
  122. // Fetch and validate profile
  123. $profile = $this->fetchAndValidateProfile($payload['attributedTo']);
  124. if (! $profile) {
  125. return;
  126. }
  127. // Download and process media with security checks
  128. $mediaResult = $this->downloadAndValidateMedia($payload, $profile);
  129. if (! $mediaResult) {
  130. return;
  131. }
  132. // Create story record with transaction
  133. $this->createStoryRecord($payload, $profile, $mediaResult);
  134. }
  135. /**
  136. * Validate basic activity structure
  137. */
  138. private function validateActivityStructure(): bool
  139. {
  140. $validator = Validator::make($this->activity, [
  141. 'id' => 'required|url|max:2000',
  142. 'actor' => 'required|url|max:2000',
  143. 'object.object' => 'required|string|max:1000',
  144. ]);
  145. return ! $validator->fails();
  146. }
  147. /**
  148. * Enhanced domain consistency validation
  149. */
  150. private function validateDomainConsistency(string $url1, string $url2): bool
  151. {
  152. $host1 = parse_url($url1, PHP_URL_HOST);
  153. $host2 = parse_url($url2, PHP_URL_HOST);
  154. if (! $host1 || ! $host2) {
  155. return false;
  156. }
  157. // Normalize hosts (remove www prefix if present)
  158. $host1 = ltrim(strtolower($host1), 'www.');
  159. $host2 = ltrim(strtolower($host2), 'www.');
  160. return $host1 === $host2;
  161. }
  162. /**
  163. * Rate limiting check
  164. */
  165. private function isRateLimited(string $actor): bool
  166. {
  167. $domain = parse_url($actor, PHP_URL_HOST);
  168. $cacheKey = "story_fetch_rate_limit:{$domain}";
  169. $currentCount = Cache::get($cacheKey, 0);
  170. // Allow 5000 story fetches per hour per domain
  171. if ($currentCount >= 5000) {
  172. return true;
  173. }
  174. Cache::put($cacheKey, $currentCount + 1, 3600);
  175. return false;
  176. }
  177. /**
  178. * Enhanced bearcap validation
  179. */
  180. private function validateBearcap(?string $bearcapString): ?array
  181. {
  182. if (! $bearcapString) {
  183. if (config('app.dev_log')) {
  184. Log::warning('Empty bearcap string');
  185. }
  186. return null;
  187. }
  188. try {
  189. $bearcap = Bearcap::decode($bearcapString);
  190. if (! $bearcap || ! isset($bearcap['url'], $bearcap['token'])) {
  191. if (config('app.dev_log')) {
  192. Log::warning('Invalid bearcap structure');
  193. }
  194. return null;
  195. }
  196. // Validate URL format
  197. if (! filter_var($bearcap['url'], FILTER_VALIDATE_URL)) {
  198. if (config('app.dev_log')) {
  199. Log::warning('Invalid bearcap URL', ['url' => $bearcap['url']]);
  200. }
  201. return null;
  202. }
  203. // Validate token format (should be non-empty)
  204. if (empty($bearcap['token']) || strlen($bearcap['token']) < 10) {
  205. if (config('app.dev_log')) {
  206. Log::warning('Invalid bearcap token');
  207. }
  208. return null;
  209. }
  210. return $bearcap;
  211. } catch (Exception $e) {
  212. if (config('app.dev_log')) {
  213. Log::warning('Bearcap decode failed', ['error' => $e->getMessage()]);
  214. }
  215. return null;
  216. }
  217. }
  218. /**
  219. * Enhanced story payload fetching with security
  220. */
  221. private function fetchStoryPayload(string $url, string $token): ?array
  222. {
  223. $version = config('pixelfed.version');
  224. $appUrl = config('app.url');
  225. $headers = [
  226. 'Accept' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
  227. 'Authorization' => 'Bearer '.$token,
  228. 'User-Agent' => "(Pixelfed/{$version}; +{$appUrl})",
  229. ];
  230. try {
  231. $response = Http::withHeaders($headers)
  232. ->timeout(self::REQUEST_TIMEOUT)
  233. ->connectTimeout(10)
  234. ->retry(2, 1000)
  235. ->withOptions([
  236. 'verify' => true,
  237. 'max_redirects' => self::MAX_REDIRECTS,
  238. ])
  239. ->get($url);
  240. if (! $response->successful()) {
  241. if (config('app.dev_log')) {
  242. Log::warning('Story fetch failed', [
  243. 'url' => $url,
  244. 'status' => $response->status(),
  245. ]);
  246. }
  247. return null;
  248. }
  249. $payload = $response->json();
  250. if (! is_array($payload)) {
  251. if (config('app.dev_log')) {
  252. Log::warning('Invalid JSON payload received');
  253. }
  254. return null;
  255. }
  256. return $payload;
  257. } catch (RequestException|ConnectionException $e) {
  258. if (config('app.dev_log')) {
  259. Log::warning('HTTP request failed', [
  260. 'url' => $url,
  261. 'error' => $e->getMessage(),
  262. ]);
  263. }
  264. return null;
  265. } catch (Exception $e) {
  266. if (config('app.dev_log')) {
  267. Log::error('Unexpected error in story fetch', [
  268. 'url' => $url,
  269. 'error' => $e->getMessage(),
  270. ]);
  271. }
  272. return null;
  273. }
  274. }
  275. /**
  276. * Enhanced payload validation
  277. */
  278. private function validatePayload(array $payload): bool
  279. {
  280. if (! StoryValidator::validate($payload)) {
  281. if (config('app.dev_log')) {
  282. Log::warning('Story validator failed');
  283. }
  284. return false;
  285. }
  286. // Payload security validations
  287. $validator = Validator::make($payload, [
  288. 'id' => 'required|url|max:2000',
  289. 'attributedTo' => 'required|url|max:2000',
  290. 'attachment.url' => 'required|url|max:2000',
  291. 'attachment.type' => 'required|in:Image,Video',
  292. 'attachment.mediaType' => 'required|string|max:100',
  293. 'duration' => 'nullable|integer|min:0|max:'.self::MAX_DURATION,
  294. 'published' => 'required|date',
  295. 'expiresAt' => 'required|date|after:published',
  296. 'can_reply' => 'boolean',
  297. 'can_react' => 'boolean',
  298. ]);
  299. if ($validator->fails()) {
  300. if (config('app.dev_log')) {
  301. Log::warning('Payload validation failed', ['errors' => $validator->errors()]);
  302. }
  303. return false;
  304. }
  305. // Validate media URL
  306. if (! Helpers::validateUrl($payload['attachment']['url'])) {
  307. if (config('app.dev_log')) {
  308. Log::warning('Invalid attachment URL');
  309. }
  310. return false;
  311. }
  312. // Validate MIME type
  313. $mimeType = $payload['attachment']['mediaType'];
  314. $allowedMimeTypes = $this->getAllowedMimeTypes();
  315. if (! in_array($mimeType, $allowedMimeTypes)) {
  316. if (config('app.dev_log')) {
  317. Log::warning('Invalid MIME type', [
  318. 'mime' => $mimeType,
  319. 'type' => $payload['attachment']['type'],
  320. 'allowed' => $allowedMimeTypes,
  321. ]);
  322. }
  323. return false;
  324. }
  325. return true;
  326. }
  327. /**
  328. * Enhanced profile fetching with validation
  329. */
  330. private function fetchAndValidateProfile(string $attributedTo)
  331. {
  332. try {
  333. $profile = Helpers::profileFetch($attributedTo);
  334. if (! $profile || ! $profile->id) {
  335. if (config('app.dev_log')) {
  336. Log::warning('Profile fetch failed', ['attributed_to' => $attributedTo]);
  337. }
  338. return null;
  339. }
  340. // Check if profile is blocked or suspended
  341. if ($profile->status !== null && in_array($profile->status, ['suspended', 'deleted'])) {
  342. if (config('app.dev_log')) {
  343. Log::info('Profile is suspended/deleted', ['profile_id' => $profile->id]);
  344. }
  345. return null;
  346. }
  347. return $profile;
  348. } catch (Exception $e) {
  349. if (config('app.dev_log')) {
  350. Log::error('Profile fetch error', [
  351. 'attributed_to' => $attributedTo,
  352. 'error' => $e->getMessage(),
  353. ]);
  354. }
  355. return null;
  356. }
  357. }
  358. /**
  359. * Enhanced media download with comprehensive security
  360. */
  361. private function downloadAndValidateMedia(array $payload, $profile): ?array
  362. {
  363. $mediaUrl = $payload['attachment']['url'];
  364. $ext = strtolower(pathinfo(parse_url($mediaUrl, PHP_URL_PATH), PATHINFO_EXTENSION));
  365. $allowedExtensions = $this->getAllowedExtensions();
  366. if (! in_array($ext, $allowedExtensions)) {
  367. if (config('app.dev_log')) {
  368. Log::warning('Invalid file extension', ['extension' => $ext, 'allowed' => $allowedExtensions]);
  369. }
  370. return null;
  371. }
  372. $fileName = $this->generateSecureFileName($ext);
  373. $storagePath = MediaPathService::story($profile);
  374. $tmpBase = storage_path('app/remcache/');
  375. $tmpPath = $profile->id.'-'.$fileName;
  376. $tmpName = $tmpBase.$tmpPath;
  377. if (! is_dir($tmpBase)) {
  378. mkdir($tmpBase, 0755, true);
  379. }
  380. try {
  381. $contextOptions = [
  382. 'ssl' => [
  383. 'verify_peer' => true,
  384. 'verify_peername' => true,
  385. 'allow_self_signed' => false,
  386. 'SNI_enabled' => true,
  387. ],
  388. 'http' => [
  389. 'timeout' => self::REQUEST_TIMEOUT,
  390. 'max_redirects' => self::MAX_REDIRECTS,
  391. 'user_agent' => 'Pixelfed/'.config('pixelfed.version'),
  392. ],
  393. ];
  394. $ctx = stream_context_create($contextOptions);
  395. $data = $this->downloadWithSizeLimit($mediaUrl, $ctx);
  396. if (! $data) {
  397. return null;
  398. }
  399. if (file_put_contents($tmpName, $data) === false) {
  400. if (config('app.dev_log')) {
  401. Log::error('Failed to write temp file', ['temp_name' => $tmpName]);
  402. }
  403. return null;
  404. }
  405. if (! $this->validateDownloadedFile($tmpName, $payload['attachment']['mediaType'])) {
  406. unlink($tmpName);
  407. return null;
  408. }
  409. $disk = Storage::disk(config('filesystems.default'));
  410. $path = $disk->putFileAs($storagePath, new File($tmpName), $fileName, 'public');
  411. $size = filesize($tmpName);
  412. unlink($tmpName);
  413. if (! $path) {
  414. if (config('app.dev_log')) {
  415. Log::error('Failed to store file permanently');
  416. }
  417. return null;
  418. }
  419. return [
  420. 'path' => $path,
  421. 'size' => $size,
  422. 'filename' => $fileName,
  423. ];
  424. } catch (Exception $e) {
  425. if (file_exists($tmpName)) {
  426. unlink($tmpName);
  427. }
  428. if (config('app.dev_log')) {
  429. Log::error('Media download failed', [
  430. 'url' => $mediaUrl,
  431. 'error' => $e->getMessage(),
  432. ]);
  433. }
  434. return null;
  435. }
  436. }
  437. /**
  438. * Download with size limit enforcement
  439. */
  440. private function downloadWithSizeLimit(string $url, $context): ?string
  441. {
  442. $maxFileSizeBytes = $this->getMaxFileSizeBytes();
  443. $handle = fopen($url, 'r', false, $context);
  444. if (! $handle) {
  445. if (config('app.dev_log')) {
  446. Log::warning('Failed to open URL stream', ['url' => $url]);
  447. }
  448. return null;
  449. }
  450. $data = '';
  451. $size = 0;
  452. while (! feof($handle) && $size < $maxFileSizeBytes) {
  453. $chunk = fread($handle, 8192);
  454. if ($chunk === false) {
  455. break;
  456. }
  457. $data .= $chunk;
  458. $size += strlen($chunk);
  459. }
  460. fclose($handle);
  461. if ($size >= $maxFileSizeBytes) {
  462. if (config('app.dev_log')) {
  463. Log::warning('File too large', ['size' => $size, 'limit' => $maxFileSizeBytes]);
  464. }
  465. return null;
  466. }
  467. return $data;
  468. }
  469. /**
  470. * Validate downloaded file
  471. */
  472. private function validateDownloadedFile(string $filePath, string $expectedMimeType): bool
  473. {
  474. // Check file exists and is readable
  475. if (! is_readable($filePath)) {
  476. if (config('app.dev_log')) {
  477. Log::warning('Downloaded file not readable', ['path' => $filePath]);
  478. }
  479. return false;
  480. }
  481. // Get actual MIME type
  482. $finfo = new \finfo(FILEINFO_MIME_TYPE);
  483. $actualMimeType = $finfo->file($filePath);
  484. if ($actualMimeType !== $expectedMimeType) {
  485. if (config('app.dev_log')) {
  486. Log::warning('MIME type mismatch', [
  487. 'expected' => $expectedMimeType,
  488. 'actual' => $actualMimeType,
  489. ]);
  490. }
  491. return false;
  492. }
  493. // Additional file type specific validations
  494. if (str_starts_with($actualMimeType, 'image/')) {
  495. return $this->validateImageFile($filePath);
  496. } elseif (str_starts_with($actualMimeType, 'video/')) {
  497. return $this->validateVideoFile($filePath);
  498. }
  499. return true;
  500. }
  501. /**
  502. * Validate image file
  503. */
  504. private function validateImageFile(string $filePath): bool
  505. {
  506. $imageInfo = getimagesize($filePath);
  507. if (! $imageInfo) {
  508. if (config('app.dev_log')) {
  509. Log::warning('Invalid image file', ['path' => $filePath]);
  510. }
  511. return false;
  512. }
  513. // Check reasonable dimensions (not too large, not too small)
  514. [$width, $height] = $imageInfo;
  515. if ($width < 1 || $height < 1 || $width != 1080 || $height != 1920) {
  516. if (config('app.dev_log')) {
  517. Log::warning('Image dimensions out of range', [
  518. 'width' => $width,
  519. 'height' => $height,
  520. ]);
  521. }
  522. return false;
  523. }
  524. return true;
  525. }
  526. /**
  527. * Basic video file validation
  528. */
  529. private function validateVideoFile(string $filePath): bool
  530. {
  531. // Todo: improved video file header checks
  532. $size = filesize($filePath);
  533. $maxSize = $this->getMaxFileSizeBytes();
  534. return $size > 0 && $size <= $maxSize;
  535. }
  536. /**
  537. * Get allowed MIME types from config
  538. */
  539. private function getAllowedMimeTypes(): array
  540. {
  541. $mediaTypes = config_cache('pixelfed.media_types', 'image/jpeg,image/png');
  542. return array_map('trim', explode(',', $mediaTypes));
  543. }
  544. /**
  545. * Get allowed file extensions based on MIME types from config
  546. */
  547. private function getAllowedExtensions(): array
  548. {
  549. $mimeTypes = $this->getAllowedMimeTypes();
  550. $extensions = [];
  551. $mimeToExtension = [
  552. 'image/jpeg' => ['jpg', 'jpeg'],
  553. 'image/png' => ['png'],
  554. 'image/gif' => ['gif'],
  555. 'image/webp' => ['webp'],
  556. 'image/heic' => ['heic', 'heif'],
  557. 'image/avif' => ['avif'],
  558. 'video/mp4' => ['mp4'],
  559. 'video/webm' => ['webm'],
  560. 'video/mov' => ['mov'],
  561. 'video/quicktime' => ['mov', 'qt'],
  562. ];
  563. foreach ($mimeTypes as $mimeType) {
  564. if (isset($mimeToExtension[$mimeType])) {
  565. $extensions = array_merge($extensions, $mimeToExtension[$mimeType]);
  566. }
  567. }
  568. return array_unique($extensions);
  569. }
  570. /**
  571. * Get max file size in bytes from config (config is in KB)
  572. */
  573. private function getMaxFileSizeBytes(): int
  574. {
  575. $maxSizeKb = config('pixelfed.max_photo_size', 15000);
  576. return $maxSizeKb * 1024;
  577. }
  578. /**
  579. * Generate cryptographically secure filename
  580. */
  581. private function generateSecureFileName(string $extension): string
  582. {
  583. $random1 = Str::random(random_int(2, 12));
  584. $random2 = Str::random(random_int(32, 35));
  585. $random3 = Str::random(random_int(1, 14));
  586. return $random1.'_'.$random2.'_'.$random3.'.'.$extension;
  587. }
  588. /**
  589. * Create story record with transaction safety
  590. */
  591. private function createStoryRecord(array $payload, $profile, array $mediaResult): void
  592. {
  593. DB::transaction(function () use ($payload, $profile, $mediaResult) {
  594. // Check for duplicate by object_id
  595. if (Story::where('object_id', $payload['id'])->exists()) {
  596. if (config('app.dev_log')) {
  597. Log::info('Story already exists', ['object_id' => $payload['id']]);
  598. }
  599. return;
  600. }
  601. $type = $payload['attachment']['type'] === 'Image' ? 'photo' : 'video';
  602. $story = new Story;
  603. $story->profile_id = $profile->id;
  604. $story->object_id = $payload['id'];
  605. $story->size = $mediaResult['size'];
  606. $story->mime = data_get($payload, 'attachment.mediaType');
  607. $story->duration = $payload['duration'] ?? null;
  608. $story->media_url = data_get($payload, 'attachment.url');
  609. $story->type = $type;
  610. $story->public = false;
  611. $story->local = false;
  612. $story->active = true;
  613. $story->path = $mediaResult['path'];
  614. $story->view_count = 0;
  615. $story->can_reply = $payload['can_reply'] ?? false;
  616. $story->can_react = $payload['can_react'] ?? false;
  617. $story->created_at = now()->parse($payload['published']);
  618. $story->expires_at = now()->parse($payload['expiresAt']);
  619. $story->save();
  620. // Index the story
  621. $index = app(StoryIndexService::class);
  622. $index->indexStory($story);
  623. // Clear cache
  624. StoryService::delLatest($story->profile_id);
  625. if (config('app.dev_log')) {
  626. Log::info('Story created successfully', [
  627. 'story_id' => $story->id,
  628. 'profile_id' => $profile->id,
  629. ]);
  630. }
  631. });
  632. }
  633. /**
  634. * Handle job failure
  635. */
  636. public function failed(Exception $exception)
  637. {
  638. if (config('app.dev_log')) {
  639. Log::error('StoryFetch job failed permanently', [
  640. 'activity_id' => $this->activity['id'] ?? 'unknown',
  641. 'error' => $exception->getMessage(),
  642. 'attempts' => $this->attempts(),
  643. ]);
  644. }
  645. }
  646. }