Files
fitme3070.pt/shared/platform-tools/functions_feedback.php
andreandClaude Opus 5 4d40adbf64 Vendor platform-tools: everything this site runs is now in one repo
shared/platform-tools was a git submodule. On this branch it is a plain
directory of tracked files, frozen at ea3616abae30106df283e73a3e2f9e5bb0e6ae90
— the same commit fotografia.andmore.pt pins. Contents are byte-identical to
that tree, verified blob by blob.

The submodule caused every deployment failure this site has had:

  - .gitmodules named no `branch`, so the update fell back to
    refs/remotes/origin/HEAD — a ref a plain server-side clone never writes.
    "Atualizar submódulo" failed with "Unable to find refs/remotes/origin/HEAD
    revision in submodule path".
  - Its URL was http://, which the origin answers with 523.
  - This install is deployed by uploading files, and a submodule is invisible
    to that. The tree moved forward while shared/platform-tools stayed on the
    old pin, config/functions_secrets.php died on a missing compat_secrets.php,
    and the whole site — public pages included — went down.

Each of those was separately fixable. Together they mean a second repository
nobody can see from the deployment path, gating whether the first one boots. A
plain directory deploys like every other file and cannot fall out of step.

The cost is that platform-tools updates no longer arrive by bumping a pin; they
are a copy-over-and-commit. shared/platform-tools/VENDORED.md records the
upstream SHA, the procedure, and which four files differ from upstream in line
endings only (social/*.js, .css, functions_publications.php — CRLF upstream,
normalised to LF here; content otherwise identical).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 23:06:23 +01:00

330 lines
13 KiB
PHP

<?php
// ─────────────────────────────────────────────────────────────────────────────
// Feedback da frota → balcão de tickets do hub
//
// Quem opera estas plataformas todos os dias é quem primeiro dá pelos defeitos
// — e até aqui não tinha por onde os dizer sem sair do trabalho, abrir outro
// domínio e autenticar-se outra vez. A esmagadora maioria desses relatos
// morria aí, ou chegava por mensagem solta sem página, sem versão e sem conta.
//
// Isto é o lado da plataforma: valida o que o administrador escreveu, junta o
// contexto que ele não tem de saber recolher (página, versão, quem é, que
// browser) e entrega ao `hub_ticket_create()`, que já existia no
// functions_hub.php e fala com o `/api/tickets.php` do hub.
//
// Três ficheiros, uma responsabilidade cada:
// functions_feedback.php — isto: regras, contexto e envio
// feedback_widget.php — a bolha e o formulário, incluídos pelo layout
// api/feedback.php — o endpoint JSON que a bolha chama
//
// ─── Porque é que a permissão se lê aqui e não se confia no host ─────────────
//
// A frota não tem um bootstrap só. O backoffice carrega o `config/functions.php`
// inteiro e tem `is_admin()`; as páginas públicas da fotografia arrancam com
// meia dúzia de requires e não têm. Como a bolha é para aparecer nos dois
// sítios (um administrador dá pelo defeito tanto no painel como no site), a
// leitura do papel cai para `$_SESSION['user']` quando os helpers do site não
// estão carregados. O formato é o mesmo em toda a frota: `role`, e `roles` nos
// sites com papéis múltiplos (dance).
//
// Isto decide o que se MOSTRA. Quem decide o que se ACEITA é o endpoint, que
// corre depois do bootstrap completo do site e volta a perguntar.
// ─────────────────────────────────────────────────────────────────────────────
require_once __DIR__ . '/functions_hub.php';
// As categorias do hub (config/functions_tickets.php lá) que fazem sentido para
// quem reporta sobre a própria plataforma. 'conta' e 'outro' ficam de fora: a
// primeira é para quem não consegue entrar — e quem está a ver esta bolha
// entrou —, a segunda é uma gaveta que não ajuda a triar.
function platform_feedback_categories(): array
{
return [
'problema' => 'Algo não funciona',
'alteracao' => 'Pedido de alteração',
'sugestao' => 'Sugestão',
'duvida' => 'Dúvida',
];
}
/**
* A conta com sessão aberta, seja qual for o bootstrap do site.
*
* A frota guarda a sessão de três maneiras e nenhuma é conversora das outras:
*
* 1. `current_user()` — dance, fotografia, design
* 2. `$_SESSION['user']` (array) — surf/silver, e os mesmos sem os helpers
* carregados (o site público da fotografia)
* 3. chaves soltas na sessão — quack: user_id, username, role
*
* A terceira não traz e-mail, e o balcão do hub exige um para poder responder;
* por isso, e só nesse caso, vai-se buscá-lo à tabela `users` — uma vez, com o
* resultado na própria sessão, para não custar uma query por página.
*
* Nunca abre sessão: as páginas públicas que a não têm aberta não a devem
* passar a ter por causa de um include no rodapé.
*/
function platform_feedback_user(): ?array
{
if (function_exists('current_user')) {
$user = current_user();
if (is_array($user) && $user) {
return $user;
}
}
if (session_status() !== PHP_SESSION_ACTIVE) {
return null;
}
$user = $_SESSION['user'] ?? null;
if (is_array($user) && $user) {
return $user;
}
$id = (int) ($_SESSION['user_id'] ?? 0);
if ($id <= 0) {
return null;
}
$cache = $_SESSION['__ptfb_user'] ?? null;
if (is_array($cache) && (int) ($cache['id'] ?? 0) === $id) {
return $cache;
}
$user = [
'id' => $id,
'name' => (string) ($_SESSION['username'] ?? ''),
'email' => '',
'role' => (string) ($_SESSION['role'] ?? ''),
];
// `SELECT *` e não uma lista de colunas: os nomes divergem entre sites
// (name / full_name / display_name / username) e uma coluna inexistente
// seria um erro de SQL em vez de um campo em falta. Da linha ficam dois
// campos; o resto — hash da password incluído — morre nesta função.
$pdo = hub_pdo();
if ($pdo instanceof PDO) {
try {
$st = $pdo->prepare('SELECT * FROM users WHERE id = ? LIMIT 1');
$st->execute([$id]);
$row = $st->fetch(PDO::FETCH_ASSOC) ?: [];
$user['email'] = trim((string) ($row['email'] ?? ''));
$nome = platform_feedback_name($row);
if ($nome !== '') {
$user['name'] = $nome;
}
} catch (Throwable $e) {
error_log('platform_feedback_user: ' . $e->getMessage());
}
}
$_SESSION['__ptfb_user'] = $user;
return $user;
}
/**
* O nome da pessoa, venha a coluna com o nome que vier.
*
* name (dance, fotografia), full_name (surf), display_name/username (quack).
* A ordem é do mais human-readable para o mais técnico: um ticket assinado
* "andre_m" diz menos do que um assinado "André Moreira".
*/
function platform_feedback_name(array $user): string
{
foreach (['name', 'full_name', 'display_name', 'nome', 'username'] as $campo) {
$v = trim((string) ($user[$campo] ?? ''));
if ($v !== '') {
return $v;
}
}
return '';
}
/** Os papéis da conta — `roles` onde existe (dance), `role` nos restantes. */
function platform_feedback_roles(?array $user = null): array
{
$user ??= platform_feedback_user();
if (!$user) {
return [];
}
$roles = $user['roles'] ?? [];
if (!is_array($roles)) {
$roles = [];
}
if (!$roles && !empty($user['role'])) {
$roles = [(string) $user['role']];
}
return array_values(array_unique(array_map('strval', $roles)));
}
function platform_feedback_is_admin(?array $user = null): bool
{
return (bool) array_intersect(['admin', 'sysadmin'], platform_feedback_roles($user));
}
function platform_feedback_is_sysadmin(?array $user = null): bool
{
return in_array('sysadmin', platform_feedback_roles($user), true);
}
/**
* O commit em que esta instalação está, lido do .git à mão.
*
* Sem shell e sem git: `git_local_state()` faz três chamadas ao binário, e num
* alojamento cPanel não há binário nenhum. Aqui, ou o ficheiro está lá e
* responde-se, ou devolve-se string vazia e o relato vai sem esta linha.
*/
function platform_feedback_commit(?string $root = null): string
{
$root = $root ?: dirname(__DIR__, 2);
$head = @file_get_contents($root . '/.git/HEAD');
if (!is_string($head)) {
return '';
}
$head = trim($head);
if (str_starts_with($head, 'ref: ')) {
$ref = trim(substr($head, 5));
$hash = @file_get_contents($root . '/.git/' . $ref);
if (!is_string($hash)) {
// Repositório com as refs empacotadas (git gc) — não há ficheiro por ref.
$packed = @file_get_contents($root . '/.git/packed-refs');
$hash = (is_string($packed)
&& preg_match('/^([0-9a-f]{40})\s+' . preg_quote($ref, '/') . '$/m', $packed, $m))
? $m[1] : '';
}
$head = trim((string) $hash);
}
return preg_match('/^[0-9a-f]{7,40}$/', $head) ? substr($head, 0, 7) : '';
}
/**
* O rodapé técnico que vai colado à descrição.
*
* Não é enfeite: sem a página não se sabe onde foi, sem a versão não se sabe
* se já está corrigido, e sem o browser metade dos defeitos de interface não
* se reproduzem. Pedir isto a quem reporta é garantir que não vem — mas vai
* escrito à vista no formulário, porque o que segue junto com um relato tem de
* ser sabido por quem o envia.
*/
function platform_feedback_context(array $user, string $pageUrl): string
{
$linhas = [];
$nome = platform_feedback_name($user);
$email = trim((string) ($user['email'] ?? ''));
$papel = implode(', ', platform_feedback_roles($user));
$linhas[] = 'Conta: ' . ($nome !== '' ? $nome . ' <' . $email . '>' : $email)
. ($papel !== '' ? ' · ' . $papel : '');
if ($pageUrl !== '') {
$linhas[] = 'Página: ' . $pageUrl;
}
$plataforma = hub_app_name();
$host = (string) ($_SERVER['HTTP_HOST'] ?? '');
$linhas[] = 'Plataforma: ' . $plataforma . ($host !== '' ? ' (' . $host . ')' : '');
// O changelog não vem no bootstrap de nenhum site — carrega-se aqui. É um
// ficheiro sem efeitos e a versão é o campo que mais depressa diz se um
// defeito relatado já está corrigido.
if (!function_exists('changelog_latest') && is_file(__DIR__ . '/functions_changelog.php')) {
require_once __DIR__ . '/functions_changelog.php';
}
$versao = function_exists('changelog_latest') ? (string) (changelog_latest()['version'] ?? '') : '';
$commit = platform_feedback_commit();
$partes = array_filter([$versao, $commit !== '' ? 'commit ' . $commit : '']);
if ($partes) {
$linhas[] = 'Versão: ' . implode(' · ', $partes);
}
$ua = trim((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''));
if ($ua !== '') {
$linhas[] = 'Browser: ' . mb_substr($ua, 0, 300);
}
$linhas[] = 'Enviado em: ' . date('Y-m-d H:i');
return "\n\n---\n" . implode("\n", $linhas);
}
/**
* Valida, monta e entrega. Devolve o mesmo formato do hub_ticket_create():
* ['ok'=>bool, 'error'=>string, 'ref'=>string, 'track_url'=>string].
*
* $in: ['title', 'description', 'category', 'blocking', 'page_url']
*/
function platform_feedback_submit(array $in, ?array $user = null): array
{
$falha = static fn(string $msg): array => ['ok' => false, 'error' => $msg, 'ref' => '', 'track_url' => ''];
$user ??= platform_feedback_user();
if (!$user || !platform_feedback_is_admin($user)) {
return $falha('Sessão sem permissão para reportar.');
}
if (!hub_ticket_ready()) {
return $falha('Esta plataforma ainda não está ligada ao balcão de apoio.');
}
$email = trim((string) ($user['email'] ?? ''));
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
return $falha('A tua conta não tem um e-mail válido — sem ele não há como responder ao pedido.');
}
$titulo = trim((string) ($in['title'] ?? ''));
$texto = trim((string) ($in['description'] ?? ''));
// As mesmas medidas do intake do hub, verificadas deste lado para a pessoa
// saber o que falta antes de a mensagem atravessar a rede.
if (mb_strlen($titulo) < 5) {
return $falha('O assunto precisa de pelo menos 5 letras.');
}
if (mb_strlen($texto) < 10) {
return $falha('Descreve o que aconteceu — pelo menos 10 letras.');
}
$titulo = mb_substr($titulo, 0, 190);
$texto = mb_substr($texto, 0, 5000);
$categorias = platform_feedback_categories();
$categoria = (string) ($in['category'] ?? 'problema');
if (!isset($categorias[$categoria])) {
$categoria = 'problema';
}
// Só o "está a bloquear" sobe a prioridade. Um formulário com as três à
// escolha enche-se de urgentes; uma pergunta fechada não.
$prioridade = !empty($in['blocking']) ? 1 : 2;
// O URL da página vem do browser, e por isso não se acredita nele: só passa
// se for do próprio site. Colado à descrição, seria uma ligação que quem
// lê o ticket clicaria.
$pageUrl = platform_feedback_safe_page_url((string) ($in['page_url'] ?? ''));
return hub_ticket_create([
'submitter_email' => $email,
'submitter_name' => mb_substr(platform_feedback_name($user), 0, 190),
'title' => $titulo,
'description' => $texto . platform_feedback_context($user, $pageUrl),
'category' => $categoria,
'priority' => $prioridade,
]);
}
/**
* Aceita o URL da página só se apontar para este mesmo site. Um valor de fora
* seria uma ligação para terceiros dentro de um ticket que alguém vai abrir.
*/
function platform_feedback_safe_page_url(string $url): string
{
$url = trim($url);
if ($url === '' || mb_strlen($url) > 500) {
return '';
}
$host = parse_url($url, PHP_URL_HOST);
$mine = (string) ($_SERVER['HTTP_HOST'] ?? '');
if (!is_string($host) || $host === '' || $mine === '' || strcasecmp($host, $mine) !== 0) {
return '';
}
return in_array(parse_url($url, PHP_URL_SCHEME), ['http', 'https'], true) ? $url : '';
}