347 lines
20 KiB
PHP
347 lines
20 KiB
PHP
<?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): 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): 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), ['p', 'strong', 'b', 'em', 'i', 'ul', 'ol', 'li', 'br', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'a'], 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);
|
||
}
|
||
}
|
||
}
|