Files
Investidor-V2/app/Services/LegacyContent.php
T
nala 6a76aac80f
tests / ci (push) Canceled after 0s
versao compilada 1
2026-10-16 09:17:28 +02:00

267 lines
15 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'): array
{
abort_unless($this->available(), 404);
$identity = config('legacy_content.articles.'.$slug);
abort_unless(is_array($identity) && ($identity['publishable'] ?? false), 404);
$row = $this->publicResources()->where('d.external_id', $identity['external_id'])->first();
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 = Sector::where('slug', $identity['sector_slug'])->where('is_active', true)->first();
// These are independently published records in the same sector, not claims of article/project linkage.
$opportunities = $sector ? Opportunity::published()->where('sector_id', $sector->id)->latest('published_at')
->limit(12)->get(['title', 'slug', 'province', 'summary']) : [];
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' => $this->localLinks($this->sanitize($source['content_html'])),
'landing_href' => '/explorar/'.$identity['group'], 'start_href' => '/investimentos/a-partir-de/'.$slug,
'group' => config('legacy_content.groups.'.$identity['group']),
'related_articles' => $this->related($identity['group'], $slug),
'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,
'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];
}
/** 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->publicResources()->where('r.id', $resourceId)->first()?->external_id;
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->publicResources()->whereIn('d.external_id', array_column($identities, 'external_id'))->get()->keyBy('external_id');
$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->publicResources()->whereIn('d.external_id', array_column(config('legacy_content.articles', []), 'external_id'))
->get()->keyBy('external_id');
$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']]);
}
});
}
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);
}
}
}