onQueue('scans'); } /** * A day. Long enough that an overnight outage is survived by a * "hold" installation, short enough that a job for a file somebody * deleted does not live forever. */ public function retryUntil(): \DateTimeInterface { return now()->addDay(); } public function handle( VirusScanner $scanner, ScanPolicy $policy, ScanningConfig $config, ): void { $file = File::query()->find($this->fileId); if ($file === null) { return; } // A new upload is only scanned while it is still pending: this job // is dispatched from the upload and from the hourly sweep, and // both can land on the same file. if (! $this->rescan && $file->scan_status !== ScanStatus::Pending) { return; } // A rescan checks a file again whatever it said last — after new // definitions, or because somebody asked. The one state it leaves // alone is a file already waiting for its first verdict, which // belongs to the job above. if ($this->rescan && $file->scan_status === ScanStatus::Pending) { return; } if (! $config->enabled()) { $policy->markNeverScanned($file); return; } // A file identical to one already quarantined needs no second // opinion, and asking for one would send the same malware past // the scanner again. Checksums are already computed at upload. $known = File::query() ->where('checksum', $file->checksum) ->where('scan_status', ScanStatus::Infected) ->whereKeyNot($file->id) ->first(); if ($known !== null) { $policy->record($file, ScanVerdict::infected((string) $known->scan_note)); return; } $verdict = $this->read($file, $scanner); if ($verdict->outcome === ScanOutcome::Unavailable && $this->keepWaiting($file, $config)) { $file->forceFill(['scan_attempts' => $file->scan_attempts + 1])->save(); // 30 seconds, then a minute, then two, up to five. Long // enough not to hammer a scanner that is starting up; short // enough that a brief blip does not hold an upload for the // whole patience window. $this->release(min(300, 30 * (2 ** min(4, $file->scan_attempts)))); return; } $policy->record($file, $verdict); } /** * Whether the file should wait rather than be decided now. * * "Hold" waits forever, by design. Otherwise the wait is measured * from when the file was stored, not from this attempt: what the * setting promises is that nobody's upload sits unavailable for * longer than that, however many times the job has run. */ private function keepWaiting(File $file, ScanningConfig $config): bool { if ($config->holdsWhileUnavailable()) { return true; } $storedAt = $file->created_at ?? now(); return $storedAt->copy()->addMinutes($config->unavailableWaitMinutes())->isFuture(); } private function read(File $file, VirusScanner $scanner): ScanVerdict { try { $stream = Storage::disk($file->disk)->readStream($file->path); } catch (Throwable $e) { $stream = null; Log::warning("Could not open file {$file->id} for scanning: ".$e->getMessage()); } if ($stream === null) { // Not the scanner's fault, and not something waiting will fix // — an orphaned row, or storage that moved. It goes through // the same policy as a file the scanner could not open, and // deliberately not through the scanner-unavailable path, // which is retried hourly and would retry this forever. return ScanVerdict::unreadable(__('The file could not be read from storage.')); } try { return $scanner->scan($stream, $file->size); } finally { fclose($stream); } } }