Files
Investidor-V2/app/Services/LegacyContent.php
T

347 lines
20 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Services;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Models\User;
use DOMDocument;
use DOMElement;
use DOMNode;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
/** A reading adapter over the existing library, never a second content/opportunity register. */
final class LegacyContent
{
public function available(): bool
{
// Explicit delivery gate; document publication and access checks remain independent.
if (config('portal.legacy_content_enabled', false)) {
return true;
}
if (! config('portal.legacy_content_qa_enabled') || ! app()->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<string, mixed> */
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 = \Illuminate\Support\Facades\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) => '<p>'.nl2br(e($p)).'</p>', preg_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();
}
}
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 (\Symfony\Component\HttpKernel\Exception\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.';
}
preg_match_all('~<p\b[^>]*>(.*?)</p>~isu', $html, $paragraphs);
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 \Illuminate\Support\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<string, mixed> */
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<array<string,mixed>> */
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<array<string,mixed>> */
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('<?xml encoding="UTF-8"><html><body>'.$html.'</body></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<string, mixed> $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. */
private function identityResources(): \Illuminate\Support\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('<?xml encoding="UTF-8"><html><body>'.$html.'</body></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);
}
}
}