environment('testing') || config('database.connections.pgsql.host') !== '127.0.0.1' || config('database.connections.pgsql.url') || (int) config('database.connections.pgsql.port') !== 55441) { return false; } $actual = DB::selectOne('SELECT current_database() AS database, inet_server_port() AS port'); return $actual && str_ends_with($actual->database, '_test') && (int) $actual->port === 55441 && $actual->database === config('database.connections.pgsql.database'); } /** @return array */ public function article(string $slug = 'agronegocio', bool $includeRelated = true): array { abort_unless($this->available(), 404); $identity = config('legacy_content.articles.'.$slug); abort_unless(is_array($identity) && ($identity['publishable'] ?? false), 404); $row = $this->identityResources()->get($identity['external_id']); abort_unless($row !== null, 404); $binary = app(VerifiedDocumentBinary::class)->respond($row); $bytes = $binary instanceof BinaryFileResponse ? file_get_contents($binary->getFile()->getPathname()) : $binary->getContent(); abort_unless(is_string($bytes) && strlen($bytes) <= 10485760 && hash_equals(trim($row->sha256), hash('sha256', $bytes)), 404); try { $source = json_decode($bytes, true, 64, JSON_THROW_ON_ERROR); } catch (\JsonException) { abort(404); } abort_unless(is_array($source) && ($source['format'] ?? '') === 'investmoz-legacy-article-v1' && ($source['source_url'] ?? '') === $row->source_url && $row->source_url === $identity['source_url'] && is_string($source['content_html'] ?? null) && trim(strip_tags($source['content_html'])) !== '', 404); foreach (['title', 'author', 'legacy_category', 'published_at', 'modified_at'] as $key) { abort_unless(is_string($source[$key] ?? null), 404); } abort_unless(is_int($source['legacy_id'] ?? null), 404); $image = $this->publicResources()->where('d.sha256', $source['image_sha256'] ?? '')->where('d.media_type', 'image/png')->first(); $sector = $slug === 'energia-mineracao' ? null : Sector::where('slug', $identity['sector_slug'])->where('is_active', true)->first(); $relatedSectors = $slug === 'energia-mineracao' ? Sector::whereIn('slug', ['energia-especifica', 'mineracao'])->where('is_active', true)->get(['id', 'name', 'slug']) : collect(); // These are independently published records in the same sector, not claims of article/project linkage. $opportunities = $sector ? SectorOrganization::opportunities($sector)->latest('published_at') ->limit(12)->get(['title', 'slug', 'province', 'summary']) : []; if ($relatedSectors->isNotEmpty()) { $opportunities = Opportunity::published()->whereIn('sector_id', $relatedSectors->pluck('id'))->latest('published_at')->limit(12)->get(['id', 'title', 'slug', 'province', 'summary']); } $localHtml = $this->localLinks($this->sanitize($source['content_html'])); $editorial = Schema::hasColumn('portal_resources', 'editorial_content') ? DB::table('portal_resources')->where('id', $row->resource_id)->value('editorial_content') : null; if ($editorial) { $adaptation = json_decode($editorial, true, 64, JSON_THROW_ON_ERROR); $localHtml = implode('', array_map(fn ($p) => '

'.nl2br(e($p)).'

', CheckedPattern::split('/\n\s*\n/', $adaptation['body']))); $source['title'] = $row->title; if (! empty($adaptation['image_resource_id'])) { $image = $this->publicResources()->where('r.id', $adaptation['image_resource_id'])->whereIn('d.media_type', ['image/png', 'image/jpeg', 'image/webp'])->first(); } } if ($slug === 'agronegocio') { $localHtml = app(CorridorAgribusiness::class)->enrich($localHtml, app(CorridorAgribusinessOpportunities::class)->articleLinks()); } preg_match_all('~href="([^"]+)"~i', $localHtml, $existingLinks); $alreadyLinked = array_map(fn ($href) => html_entity_decode($href, ENT_QUOTES | ENT_HTML5, 'UTF-8'), $existingLinks[1]); $relatedArticles = []; if ($includeRelated) { foreach ($this->related($identity['group'], $slug) as $item) { if (in_array($item['href'], $alreadyLinked, true)) { continue; } try { $relatedArticles[] = $this->article($item['slug'], false); } catch (HttpException $error) { if ($error->getStatusCode() !== 404) { throw $error; } } } } return ['id' => (int) $row->resource_id, 'document_id' => $row->id, 'version' => (int) $row->metadata_version, 'sha256' => trim($row->sha256), 'title' => $source['title'], 'author' => $source['author'], 'legacy_id' => $source['legacy_id'], 'legacy_category' => $source['legacy_category'], 'published_at' => $source['published_at'], 'modified_at' => $source['modified_at'], 'source_url' => $source['source_url'], 'href' => '/'.$slug, 'html' => $localHtml, 'summary' => self::excerpt($localHtml), 'opportunities_href' => $sector ? '/oportunidades?sector='.rawurlencode($sector->slug) : null, 'opportunities_label' => $sector ? 'Ver oportunidades de '.$sector->name : null, 'has_opportunities' => count($opportunities) > 0, 'landing_href' => '/explorar/'.$identity['group'], 'start_href' => '/investimentos/a-partir-de/'.$slug, 'group' => config('legacy_content.groups.'.$identity['group']), 'related_articles' => $relatedArticles, 'image' => $image ? ['href' => '/documentos/'.$image->resource_id.'/view', 'sha256' => trim($image->sha256), 'alt' => $slug === 'agronegocio' ? 'Campo agrícola ao pôr do sol; fotografia ilustrativa do artigo Agronegocio' : 'Imagem ilustrativa original do artigo '.$source['title'], 'source_url' => $image->source_url] : null, 'related_sectors' => $relatedSectors, 'sector' => $sector?->only(['id', 'name', 'slug']), 'opportunities' => $opportunities, 'territorial_references' => array_values(array_filter($slug === 'agronegocio' ? ['Pemba–Lichinga', 'Nacala', 'Vale do Zambeze', 'Beira', 'Limpopo', 'Maputo'] : [], fn ($name) => str_contains(strip_tags($source['content_html']), $name))), 'territorial_status' => 'Menções territoriais preservadas no artigo. Limites administrativos, parcelas e geometrias não associados automaticamente.', 'library_href' => '/documentos/'.$row->resource_id, 'consultation_state' => 'public', 'legal_state' => 'not_applicable', 'calculation_use' => false]; } /** An excerpt of the displayed edition, never a claim about returns or availability. */ public static function excerpt(string $html): string { $plain = html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8'); if (str_contains($plain, 'Hotelaria e resorts') && str_contains($plain, 'Ecoturismo') && str_contains($plain, 'Infraestruturas turísticas') && str_contains($plain, 'Oportunidades Regionais')) { return 'Conheça os temas de turismo e hospitalidade abordados no guia: hotelaria, ecoturismo, infraestruturas de apoio e referências regionais. Use este enquadramento para preparar a sua ideia de investimento.'; } if (preg_match_all('~]*>(.*?)

~isu', $html, $paragraphs) === false) { throw new \RuntimeException('Invalid paragraph encoding; original preserved.'); } foreach ([...$paragraphs[1], $html] as $paragraph) { $text = trim(preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags($paragraph), ENT_QUOTES | ENT_HTML5, 'UTF-8'))); if (mb_strlen($text) >= 60) { return Str::limit($text, 320); } } return trim(html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8')); } /** Resolve a library identity, preserving the already stored source URLs and versions. * @return array */ public function articleForResource(int $resourceId): array { abort_unless($this->available(), 404); $externalId = $this->identityResources()->firstWhere('resource_id', $resourceId)?->legacy_identity; foreach (config('legacy_content.articles', []) as $slug => $identity) { if ($identity['external_id'] === $externalId && $identity['publishable']) { return $this->article($slug); } } abort(422, 'Escolha um artigo disponível para consulta; a referência não foi guardada.'); } /** @return list> */ public function articlesForGroup(string $group): array { abort_unless($this->available() && is_array(config('legacy_content.groups.'.$group)), 404); $articles = []; foreach ($this->related($group) as $item) { $articles[] = $this->article($item['slug']); } abort_unless($articles !== [], 404); return $articles; } /** @return list> */ private function related(string $group, ?string $except = null): array { $identities = array_filter(config('legacy_content.articles', []), fn ($identity, $slug) => $identity['group'] === $group && $identity['publishable'] && $slug !== $except, ARRAY_FILTER_USE_BOTH); $resources = $this->identityResources(); $result = []; foreach ($identities as $slug => $identity) { $row = $resources->get($identity['external_id']); if ($row && $row->source_url === $identity['source_url']) { $result[] = ['slug' => $slug, 'id' => (int) $row->resource_id, 'title' => $row->title, 'href' => '/'.$slug]; } } return $result; } private function localLinks(string $html): string { $public = $this->identityResources(); $links = []; foreach (config('legacy_content.articles', []) as $slug => $identity) { if ($identity['publishable'] && $public->get($identity['external_id'])?->source_url === $identity['source_url']) { $links[rtrim($identity['source_url'], '/')] = '/'.$slug; } } $doc = new DOMDocument; $old = libxml_use_internal_errors(true); try { $doc->loadHTML(''.$html.'', LIBXML_NONET); foreach ($doc->getElementsByTagName('a') as $anchor) { $href = $anchor->getAttribute('href'); if (isset($links[rtrim($href, '/')])) { $anchor->setAttribute('href', $links[rtrim($href, '/')]); } } $body = $doc->getElementsByTagName('body')->item(0); return $body ? implode('', array_map(fn ($child) => $doc->saveHTML($child), iterator_to_array($body->childNodes))) : ''; } finally { libxml_clear_errors(); libxml_use_internal_errors($old); } } /** @param array $input */ public function attach(User $actor, string $caseId, array $input): void { $data = Validator::make($input, ['version' => 'required|integer|min:1', 'resource_id' => 'required|integer|min:1'])->validate(); $article = $this->articleForResource((int) $data['resource_id']); abort_unless(app(InvestorPath::class)->available(), 404); DB::transaction(function () use ($actor, $caseId, $data, $article): void { $desk = app(VirtualDesk::class); $case = $desk->case($actor, $caseId, true, true); abort_if($case->closed_at, 409); abort_unless(DB::table('investor_journeys')->where('id', $case->journey_id)->whereNotNull('path_definition_id')->whereNull('deleted_at')->exists(), 409, 'Este dossier conserva outro roteiro; não foi convertido automaticamente.'); $payload = $desk->decode($case->payload); foreach ($payload['investor_path']['content_sources'] ?? [] as $existing) { if ($existing['resource_id'] === $article['id'] && $existing['sha256'] === $article['sha256']) { if ($article['sector']) { app(SectorInvestment::class)->attach($actor, $caseId, ['version' => $data['version'], 'sector_slug' => $article['sector']['slug']]); } return; // Same source/version replay: neither another reference nor another event. } } abort_unless((int) $case->version === $data['version'], 409, 'O dossier foi alterado; recupere a versão atual.'); $payload['investor_path']['content_sources'][] = ['resource_id' => $article['id'], 'document_id' => $article['document_id'], 'sha256' => $article['sha256'], 'metadata_version' => $article['version'], 'title' => $article['title'], 'source_url' => $article['source_url'], 'return_href' => $article['href'], 'actor_id' => $actor->id, 'saved_at' => now()->toIso8601String()]; DB::table('desk_cases')->where('id', $caseId)->update(['payload' => $desk->encode($payload), 'version' => $case->version + 1, 'updated_at' => now()]); $desk->event($desk->case($actor, $caseId), $actor, 'legacy_content_referenced', ['resource_id' => $article['id'], 'sha256' => $article['sha256']]); if ($article['sector']) { app(SectorInvestment::class)->attach($actor, $caseId, ['version' => $case->version + 1, 'sector_slug' => $article['sector']['slug']]); } }); } /** Resolve explicit transport aliases without changing original IDs, versions or relations. * @return Collection */ private function identityResources(): Collection { $identities = array_column(config('legacy_content.articles', []), 'external_id'); $aliases = config('legacy_content_aliases', []); $rows = $this->publicResources()->where(function ($query) use ($identities, $aliases) { $query->whereIn('d.external_id', $identities); if ($aliases !== []) { $query->orWhereIn('r.id', array_column($aliases, 'resource_id')); } })->get(); $resolved = collect(); foreach ($rows as $row) { $identity = in_array($row->external_id, $identities, true) ? $row->external_id : null; foreach ($aliases as $original => $alias) { if ((int) $row->resource_id === (int) $alias['resource_id'] && $row->external_id === $alias['external_id'] && hash_equals($alias['sha256'], trim($row->sha256)) && $row->source_url === $alias['source_url']) { abort_if($identity !== null && $identity !== $original, 409, 'Correspondência de artigo em reconciliação.'); $identity = $original; } } if ($identity === null) { continue; } abort_if($resolved->has($identity), 409, 'Correspondência de artigo em reconciliação.'); $row->legacy_identity = $identity; $resolved->put($identity, $row); } return $resolved; } private function publicResources(): Builder { return DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id') ->where('r.published', true)->where('r.rights_status', 'authorized')->where('d.rights_status', 'authorized') ->where('d.status', 'active')->where('d.data_classification', 'public')->whereNull('d.deleted_at') ->where('d.malware_scan_status', 'clean')->whereColumn('d.malware_scanned_sha256', 'd.sha256') ->select(['d.*', 'r.id as resource_id', 'r.metadata_version', 'r.source_url', 'r.title']); } /** Allow presentation markup only. No scripts, styles, events, embeds or remote image execution. */ public function sanitize(string $html, bool $preserveStructure = false): string { $doc = new DOMDocument; $old = libxml_use_internal_errors(true); try { $doc->loadHTML(''.$html.'', LIBXML_NONET); $body = $doc->getElementsByTagName('body')->item(0); // Preserve the source text and relative sections, but start the displayed // article below the page h1. Originals and their hashes remain untouched. $sourceLevels = []; foreach (['h1', 'h2', 'h3', 'h4'] as $heading) { if ($doc->getElementsByTagName($heading)->length > 0) { $sourceLevels[] = (int) substr($heading, 1); } } $headingOffset = 2 - (empty($sourceLevels) ? 2 : min($sourceLevels)); $previousHeading = 1; $clean = function (DOMNode $node) use (&$clean, $doc, $headingOffset, &$previousHeading, $preserveStructure): void { foreach (iterator_to_array($node->childNodes) as $child) { if (! $child instanceof DOMElement) { continue; } if (preg_match('/^h([1-4])$/i', $child->tagName, $level)) { $displayLevel = max(2, min(6, (int) $level[1] + $headingOffset, $previousHeading + 1)); $previousHeading = $displayLevel; $section = $doc->createElement('h'.$displayLevel); while ($child->firstChild !== null) { $section->appendChild($child->firstChild); } $node->replaceChild($section, $child); $child = $section; } if (! in_array(strtolower($child->tagName), array_merge(['p', 'strong', 'b', 'em', 'i', 'ul', 'ol', 'li', 'br', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'a'], $preserveStructure ? ['div', 'span', 'small', 'section', 'table', 'caption', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'figure', 'figcaption'] : []), true)) { $node->removeChild($child); continue; } $href = $child->tagName === 'a' ? $child->getAttribute('href') : ''; foreach (iterator_to_array($child->attributes) as $attribute) { $child->removeAttribute($attribute->nodeName); } if ($href !== '') { if (str_starts_with($href, '/') && ! str_starts_with($href, '//')) { $href = 'https://investemoz.tempo.co.mz'.$href; } if (filter_var($href, FILTER_VALIDATE_URL) && strtolower((string) parse_url($href, PHP_URL_SCHEME)) === 'https') { $child->setAttribute('href', $href); $child->setAttribute('rel', 'noopener noreferrer'); } } $clean($child); } }; if (! $body) { return ''; } $clean($body); return implode('', array_map(fn ($child) => $doc->saveHTML($child), iterator_to_array($body->childNodes))); } finally { libxml_clear_errors(); libxml_use_internal_errors($old); } } }