fail('https_only'); } $host = trim((string) $host, '[]'); $port = (int) ($parts['port'] ?? 443); $address = $this->publicAddressFor($host); $maxBytes = (int) config('media.upload.max_kb') * 1024; try { $response = Http::withOptions([ 'allow_redirects' => false, 'stream' => true, 'connect_timeout' => 5, 'timeout' => (int) config('media.upload.remote_timeout', 15), 'curl' => [CURLOPT_RESOLVE => [sprintf( '%s:%d:%s', $host, $port, str_contains($address, ':') ? "[{$address}]" : $address, )]], ]) ->withHeaders(['Accept' => 'image/*']) ->withUserAgent($this->userAgent()) ->get($url); } catch (ConnectionException) { $this->fail('unreachable'); } if ($response->redirect()) { $this->fail('redirect'); } if ($response->status() !== 200) { $this->fail('status', ['status' => $response->status()]); } if ((int) $response->header('Content-Length') > $maxBytes) { $this->fail('too_large', ['max' => (int) config('media.upload.max_kb')]); } $body = $response->toPsrResponse()->getBody(); $contents = ''; while (! $body->eof()) { $contents .= $body->read(65536); if (strlen($contents) > $maxBytes) { $this->fail('too_large', ['max' => (int) config('media.upload.max_kb')]); } } return $contents; } /** * `pireon-admin/1.0 (+https://admin.example.org; media fetcher)`: a product * token without spaces, and the installation's own address to reach it by. */ protected function userAgent(): string { return sprintf( '%s/1.0 (+%s; media fetcher)', Str::slug((string) config('app.name')) ?: 'landing', rtrim((string) config('app.url'), '/'), ); } /** * The address to connect to, once every address the host has is known to * be public. */ protected function publicAddressFor(string $host): string { $addresses = filter_var($host, FILTER_VALIDATE_IP) ? [$host] : $this->resolver->resolve($host); if ($addresses === []) { $this->fail('unresolvable'); } foreach ($addresses as $address) { // GLOBAL_RANGE is the strict one: besides the private and reserved // blocks it refuses shared (100.64/10), link-local and the // IPv4-mapped IPv6 spelling of any of them. if (! filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_GLOBAL_RANGE)) { $this->fail('private'); } } return $addresses[0]; } /** * @param array $replace */ protected function fail(string $reason, array $replace = []): never { throw ValidationException::withMessages([ 'url' => [__("rules.media.remote.{$reason}", $replace)], ]); } }