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>
437 lines
22 KiB
PHP
437 lines
22 KiB
PHP
<?php
|
|
// ─── Shared backups view ─────────────────────────────────────────────────────
|
|
//
|
|
// Bootstrap contract — the stub at <host>/views/sysadmin/backups.php must:
|
|
// 1. Load $pdo (via $repo_root/config/db.php + functions.php)
|
|
// 2. Run the sysadmin auth guard
|
|
// 3. Set $repo_root
|
|
// Optional override variables (same as platform_update.php):
|
|
// $platform_layout_header / $platform_layout_navbar / $platform_layout_footer
|
|
// $platform_dashboard_url
|
|
|
|
require_once __DIR__ . '/functions_backup.php';
|
|
require_once __DIR__ . '/functions_csrf.php';
|
|
|
|
// Load BACKUP_KEEP_DAYS (and BACKUP_TOKEN) from the worker config if not yet defined.
|
|
if (!defined('BACKUP_KEEP_DAYS')) {
|
|
require_once __DIR__ . '/backups/config.php';
|
|
}
|
|
|
|
if (!isset($repo_root) || !is_string($repo_root)) {
|
|
$repo_root = realpath(dirname(__DIR__, 2)) ?: dirname(__DIR__, 2);
|
|
}
|
|
$repo_root = rtrim($repo_root, '/');
|
|
|
|
// ── Apagar backup (POST à própria página, PRG) ──────────────────────────────
|
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'delete_backup') {
|
|
if (!platform_csrf_ok()) {
|
|
$_SESSION['backup_flash'] = [
|
|
'type' => 'danger',
|
|
'message' => 'Pedido rejeitado (token CSRF ausente ou inválido).',
|
|
];
|
|
} else {
|
|
$fn = (string) ($_POST['filename'] ?? '');
|
|
$dir = backup_dir();
|
|
$real = realpath($dir . DIRECTORY_SEPARATOR . $fn);
|
|
if (!backup_filename_is_safe($fn) || $real === false
|
|
|| !str_starts_with($real, $dir . DIRECTORY_SEPARATOR)) {
|
|
$_SESSION['backup_flash'] = ['type' => 'danger', 'message' => 'Ficheiro inválido.'];
|
|
} elseif (@unlink($real)) {
|
|
$_SESSION['backup_flash'] = ['type' => 'success', 'message' => "Backup {$fn} apagado."];
|
|
} else {
|
|
$_SESSION['backup_flash'] = ['type' => 'danger', 'message' => "Não foi possível apagar {$fn} (permissões?)."];
|
|
}
|
|
}
|
|
header('Location: ' . $_SERVER['REQUEST_URI'], true, 303);
|
|
exit;
|
|
}
|
|
|
|
// ── Restaurar backup (POST à própria página, PRG) ───────────────────────────
|
|
// Destrutivo: substitui o estado actual. Exige CSRF + confirmação escrita, e
|
|
// restore_db()/restore_files() tiram sempre um backup de segurança antes de
|
|
// escrever, cujo nome é devolvido na flash para servir de "desfazer".
|
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'restore_backup') {
|
|
require_once __DIR__ . '/functions_restore.php';
|
|
|
|
if (!platform_csrf_ok()) {
|
|
$_SESSION['backup_flash'] = [
|
|
'type' => 'danger',
|
|
'message' => 'Pedido rejeitado (token CSRF ausente ou inválido).',
|
|
];
|
|
} elseif (trim((string) ($_POST['confirm'] ?? '')) !== 'RESTAURAR') {
|
|
// Guarda contra o clique por reflexo: um confirm() do browser é
|
|
// demasiado fácil de despachar sem ler.
|
|
$_SESSION['backup_flash'] = [
|
|
'type' => 'danger',
|
|
'message' => 'Confirmação incorrecta — restauro cancelado. Nada foi alterado.',
|
|
];
|
|
} else {
|
|
$fn = (string) ($_POST['filename'] ?? '');
|
|
$purge = !empty($_POST['purge']);
|
|
@set_time_limit(0);
|
|
ignore_user_abort(true);
|
|
|
|
$info = restore_inspect($fn);
|
|
if (!$info['ok']) {
|
|
$_SESSION['backup_flash'] = [
|
|
'type' => 'danger',
|
|
'message' => 'Restauro recusado: ' . (string) $info['error'],
|
|
];
|
|
} elseif ($info['type'] === 'db' && !isset($pdo)) {
|
|
$_SESSION['backup_flash'] = [
|
|
'type' => 'danger',
|
|
'message' => 'Restauro da base de dados indisponível: $pdo não foi carregado pelo stub.',
|
|
];
|
|
} else {
|
|
$r = ($info['type'] === 'db')
|
|
? restore_db($pdo, $fn)
|
|
: restore_files($fn, ['purge' => $purge]);
|
|
|
|
if (!empty($r['ok'])) {
|
|
if ($info['type'] === 'db') {
|
|
$detail = sprintf(
|
|
'%d statements aplicados. Tabelas: %s → %s.',
|
|
(int) $r['statements'],
|
|
$r['tables_before'] ?? '?',
|
|
$r['tables_after'] ?? '?'
|
|
);
|
|
if (!empty($r['dropped_extra'])) {
|
|
$detail .= ' Tabelas removidas por não existirem no backup: '
|
|
. implode(', ', $r['dropped_extra']) . '.';
|
|
}
|
|
} else {
|
|
$detail = sprintf(
|
|
'%d ficheiro(s) restaurado(s)%s.',
|
|
(int) $r['extracted'],
|
|
!empty($r['purged']) ? ', ' . (int) $r['purged'] . ' apagado(s)' : ''
|
|
);
|
|
}
|
|
// A flash é renderizada com htmlspecialchars(), por isso é texto
|
|
// simples: markup aqui apareceria literalmente ao utilizador, e
|
|
// escapar aqui daria escape duplo.
|
|
$undo = !empty($r['safety_backup'])
|
|
? ' Estado anterior guardado em ' . (string) $r['safety_backup']
|
|
. ' — restaura esse ficheiro para desfazer.'
|
|
: '';
|
|
$_SESSION['backup_flash'] = [
|
|
'type' => 'success',
|
|
'message' => 'Restaurado de ' . $fn . '. ' . $detail . $undo,
|
|
];
|
|
} else {
|
|
$safe = !empty($r['safety_backup'])
|
|
? ' O backup de segurança ' . (string) $r['safety_backup']
|
|
. ' foi criado antes da falha — usa-o para voltar atrás.'
|
|
: '';
|
|
$_SESSION['backup_flash'] = [
|
|
'type' => 'danger',
|
|
'message' => 'Restauro falhou: ' . (string) ($r['error'] ?? 'erro desconhecido') . $safe,
|
|
];
|
|
}
|
|
}
|
|
}
|
|
header('Location: ' . $_SERVER['REQUEST_URI'], true, 303);
|
|
exit;
|
|
}
|
|
|
|
$flash = $_SESSION['backup_flash'] ?? null;
|
|
unset($_SESSION['backup_flash']);
|
|
|
|
$backups = backup_list();
|
|
|
|
// ── Auto-refresh após "backup iniciado" ─────────────────────────────────────
|
|
// O trigger devolve o nº de backups antes do disparo; enquanto a contagem não
|
|
// subir, a página recarrega sozinha (limite de tentativas para não ficar em
|
|
// loop se o worker falhar).
|
|
$await_count = null;
|
|
$await_tries = 0;
|
|
if (is_array($flash) && isset($flash['await_count'])) {
|
|
$await_count = (int) $flash['await_count'];
|
|
} elseif (isset($_GET['pt_await'])) {
|
|
$await_count = (int) $_GET['pt_await'];
|
|
$await_tries = max(0, (int) ($_GET['pt_tries'] ?? 0));
|
|
}
|
|
$awaiting = $await_count !== null && count($backups) <= $await_count && $await_tries < 20;
|
|
|
|
$layout_header = $platform_layout_header ?? ($repo_root . '/includes/header.php');
|
|
$layout_navbar = $platform_layout_navbar ?? ($repo_root . '/includes/navbar.php');
|
|
$layout_footer = $platform_layout_footer ?? ($repo_root . '/includes/footer.php');
|
|
|
|
$base_url_fn = function_exists('base_url') ? 'base_url' : null;
|
|
|
|
$dashboard_url = $platform_dashboard_url
|
|
?? (function_exists('base_url') ? base_url('views/admin/dashboard.php') : 'dashboard.php');
|
|
|
|
$pageTitle = 'Backups';
|
|
if (is_file($layout_header)) include $layout_header;
|
|
if (is_file($layout_navbar)) include $layout_navbar;
|
|
?>
|
|
<div class="container py-4" style="max-width:1000px;">
|
|
|
|
<?php
|
|
// O estado da encriptação tem de estar à vista. Um backup em claro parece
|
|
// igual a um encriptado na listagem, e a diferença só se descobre no dia em
|
|
// que o ficheiro sai do servidor.
|
|
$bkEnc = function_exists('platform_backup_encryption_available') && platform_backup_encryption_available();
|
|
?>
|
|
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
|
|
<div>
|
|
<h2 class="mb-1"><i class="bi bi-archive me-2"></i>Backups</h2>
|
|
<p class="text-muted small mb-0">Gerir cópias de segurança dos uploads e da base de dados. O código reconstitui-se a partir do git — só a pasta <code>uploads/</code> tem informação externa.</p>
|
|
<p class="small mb-0 mt-1">
|
|
<?php if ($bkEnc): ?>
|
|
<span class="badge text-bg-success"><i class="bi bi-lock-fill me-1"></i>AES-256</span>
|
|
<span class="text-muted">Os dumps novos saem encriptados. A chave deriva da <code>APP_SECRET_KEY</code> —
|
|
<strong>perdê-la é perder os backups</strong>; guarde-a fora do servidor.</span>
|
|
<?php else: ?>
|
|
<span class="badge text-bg-warning text-dark"><i class="bi bi-unlock-fill me-1"></i>Sem encriptação</span>
|
|
<span class="text-muted">Sem <code>APP_SECRET_KEY</code> no <code>.env</code>, os dumps saem em claro —
|
|
e levam a tabela de utilizadores, os contactos e os contratos assinados.</span>
|
|
<?php endif; ?>
|
|
</p>
|
|
</div>
|
|
<div class="d-flex gap-2 flex-wrap align-items-center">
|
|
<a href="<?= htmlspecialchars($dashboard_url) ?>" class="btn btn-outline-secondary btn-sm">Voltar</a>
|
|
<form method="post" action="<?= htmlspecialchars($base_url_fn ? base_url('api/backup_trigger.php') : 'api/backup_trigger.php') ?>">
|
|
<?= platform_csrf_field() ?><input type="hidden" name="type" value="files">
|
|
<button type="submit" class="btn btn-outline-secondary">
|
|
<i class="bi bi-archive me-1"></i>Backup de uploads
|
|
</button>
|
|
</form>
|
|
<form method="post" action="<?= htmlspecialchars($base_url_fn ? base_url('api/backup_trigger.php') : 'api/backup_trigger.php') ?>">
|
|
<?= platform_csrf_field() ?><input type="hidden" name="type" value="db">
|
|
<button type="submit" class="btn btn-outline-primary">
|
|
<i class="bi bi-database-down me-1"></i>Backup da base de dados
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<?php if (is_array($flash) && !empty($flash['type'])): ?>
|
|
<?php $flash_type = (string) $flash['type']; ?>
|
|
<div class="alert alert-<?= htmlspecialchars($flash_type) ?> alert-dismissible fade show d-flex align-items-center gap-2" role="alert">
|
|
<i class="bi bi-<?= $flash_type === 'success' ? 'check-circle' : 'exclamation-triangle' ?> flex-shrink-0"></i>
|
|
<div><?= htmlspecialchars((string) ($flash['message'] ?? '')) ?></div>
|
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
|
</div>
|
|
<?php endif; ?>
|
|
|
|
<?php if ($awaiting): ?>
|
|
<div class="alert alert-info d-flex align-items-center gap-2" role="status">
|
|
<span class="spinner-border spinner-border-sm flex-shrink-0"></span>
|
|
<div>A aguardar o novo backup… a lista atualiza automaticamente
|
|
<span class="text-muted">(tentativa <?= $await_tries + 1 ?>/20)</span></div>
|
|
</div>
|
|
<?php elseif ($await_count !== null && $await_tries >= 20): ?>
|
|
<div class="alert alert-warning" role="alert">
|
|
O backup ainda não apareceu ao fim de <?= $await_tries ?> verificações — confirma os logs do servidor.
|
|
</div>
|
|
<?php endif; ?>
|
|
|
|
<div class="card shadow-sm">
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
<span class="fw-semibold">Cópias de segurança disponíveis</span>
|
|
<span class="badge text-bg-secondary"><?= count($backups) ?></span>
|
|
</div>
|
|
|
|
<?php if (empty($backups)): ?>
|
|
<div class="card-body text-center text-muted py-5">
|
|
<i class="bi bi-inbox fs-2 d-block mb-2 opacity-50"></i>
|
|
Nenhuma cópia de segurança encontrada.
|
|
</div>
|
|
<?php else: ?>
|
|
<div class="table-responsive">
|
|
<table class="table table-hover mb-0 small align-middle">
|
|
<thead class="table-dark">
|
|
<tr>
|
|
<th>Ficheiro</th>
|
|
<th>Tipo</th>
|
|
<th>Tamanho</th>
|
|
<th>Data de criação</th>
|
|
<th class="text-end">Ações</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<?php foreach ($backups as $b): ?>
|
|
<tr>
|
|
<td class="font-monospace text-break"><?= htmlspecialchars($b['filename']) ?></td>
|
|
<td>
|
|
<span class="badge text-bg-<?= backup_type_badge($b['type']) ?>">
|
|
<?= backup_type_label($b['type']) ?>
|
|
</span>
|
|
</td>
|
|
<td class="text-nowrap"><?= htmlspecialchars($b['size_human']) ?></td>
|
|
<td class="text-muted text-nowrap"><?= date('d/m/Y H:i', $b['created_at']) ?></td>
|
|
<td class="text-end">
|
|
<a href="<?= htmlspecialchars(($base_url_fn ? base_url('api/backup_download.php') : 'api/backup_download.php')) ?>?file=<?= urlencode($b['filename']) ?>"
|
|
class="btn btn-sm btn-outline-secondary"
|
|
title="Transferir" aria-label="Transferir <?= htmlspecialchars($b['filename']) ?>">
|
|
<i class="bi bi-download"></i>
|
|
</a>
|
|
<button type="button"
|
|
class="btn btn-sm btn-outline-warning js-restore"
|
|
data-filename="<?= htmlspecialchars($b['filename']) ?>"
|
|
data-type="<?= htmlspecialchars($b['type']) ?>"
|
|
data-label="<?= htmlspecialchars(backup_type_label($b['type'])) ?>"
|
|
data-created="<?= htmlspecialchars(date('d/m/Y H:i', $b['created_at'])) ?>"
|
|
title="Restaurar" aria-label="Restaurar <?= htmlspecialchars($b['filename']) ?>">
|
|
<i class="bi bi-arrow-counterclockwise"></i>
|
|
</button>
|
|
<form method="POST" class="d-inline m-0"
|
|
onsubmit="return confirm('Apagar <?= htmlspecialchars(addslashes($b['filename'])) ?>? Esta ação não pode ser desfeita.');">
|
|
<?= platform_csrf_field() ?><input type="hidden" name="action" value="delete_backup">
|
|
<input type="hidden" name="filename" value="<?= htmlspecialchars($b['filename']) ?>">
|
|
<button type="submit" class="btn btn-sm btn-outline-danger"
|
|
title="Apagar" aria-label="Apagar <?= htmlspecialchars($b['filename']) ?>">
|
|
<i class="bi bi-trash"></i>
|
|
</button>
|
|
</form>
|
|
</td>
|
|
</tr>
|
|
<?php endforeach; ?>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<?php endif; ?>
|
|
|
|
<div class="card-footer text-muted small">
|
|
<i class="bi bi-info-circle me-1"></i>
|
|
Os backups com mais de <?= (int) BACKUP_KEEP_DAYS ?> dias são eliminados automaticamente.
|
|
Os ficheiros estão armazenados na pasta <code>backups/</code> no servidor.
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<!-- ── Modal de restauro ───────────────────────────────────────────────────
|
|
Um único modal para todas as linhas, preenchido a partir dos data-* do
|
|
botão clicado. Um confirm() do browser não chega aqui: é preciso mostrar
|
|
o que vai ser substituído, oferecer o purge só para ficheiros, e exigir
|
|
uma confirmação escrita antes de activar o botão. -->
|
|
<div class="modal fade" id="ptRestoreModal" tabindex="-1" aria-labelledby="ptRestoreLabel" aria-hidden="true">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<form method="POST" class="modal-content">
|
|
<?= platform_csrf_field() ?>
|
|
<input type="hidden" name="action" value="restore_backup">
|
|
<input type="hidden" name="filename" id="ptRestoreFilename" value="">
|
|
|
|
<div class="modal-header">
|
|
<h5 class="modal-title" id="ptRestoreLabel">
|
|
<i class="bi bi-arrow-counterclockwise me-2"></i>Restaurar backup
|
|
</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Fechar"></button>
|
|
</div>
|
|
|
|
<div class="modal-body">
|
|
<p class="mb-2">Vai restaurar:</p>
|
|
<p class="font-monospace small text-break mb-1" id="ptRestoreName"></p>
|
|
<p class="text-muted small mb-3">
|
|
<span class="badge text-bg-secondary" id="ptRestoreType"></span>
|
|
<span id="ptRestoreCreated" class="ms-2"></span>
|
|
</p>
|
|
|
|
<div class="alert alert-warning small mb-3" role="alert">
|
|
<strong>Isto substitui o estado actual.</strong>
|
|
<span id="ptRestoreScopeDb" class="d-none">
|
|
Toda a base de dados é substituída pelo conteúdo do backup. Tabelas que
|
|
não existam no backup são removidas.
|
|
</span>
|
|
<span id="ptRestoreScopeFiles" class="d-none">
|
|
A pasta de uploads é reposta a partir do backup.
|
|
</span>
|
|
<br>
|
|
É tirado automaticamente um backup do estado actual antes de escrever —
|
|
o nome aparece no fim para poderes desfazer.
|
|
</div>
|
|
|
|
<div class="form-check mb-3 d-none" id="ptRestorePurgeWrap">
|
|
<input class="form-check-input" type="checkbox" name="purge" value="1" id="ptRestorePurge">
|
|
<label class="form-check-label small" for="ptRestorePurge">
|
|
Apagar ficheiros que não existam no backup (espelho exacto).
|
|
<span class="text-danger">Desligado por omissão: um backup parcial apagaria uploads em uso.</span>
|
|
</label>
|
|
</div>
|
|
|
|
<label for="ptRestoreConfirm" class="form-label small mb-1">
|
|
Escreve <code>RESTAURAR</code> para confirmar:
|
|
</label>
|
|
<input type="text" class="form-control" name="confirm" id="ptRestoreConfirm"
|
|
autocomplete="off" placeholder="RESTAURAR">
|
|
</div>
|
|
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancelar</button>
|
|
<button type="submit" class="btn btn-warning" id="ptRestoreSubmit" disabled>
|
|
<i class="bi bi-arrow-counterclockwise me-1"></i>Restaurar
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
(function () {
|
|
var modalEl = document.getElementById('ptRestoreModal');
|
|
if (!modalEl || typeof bootstrap === 'undefined') return;
|
|
var modal = new bootstrap.Modal(modalEl);
|
|
var confirmInput = document.getElementById('ptRestoreConfirm');
|
|
var submitBtn = document.getElementById('ptRestoreSubmit');
|
|
|
|
document.querySelectorAll('.js-restore').forEach(function (btn) {
|
|
btn.addEventListener('click', function () {
|
|
var isDb = btn.dataset.type === 'db';
|
|
document.getElementById('ptRestoreFilename').value = btn.dataset.filename;
|
|
document.getElementById('ptRestoreName').textContent = btn.dataset.filename;
|
|
document.getElementById('ptRestoreType').textContent = btn.dataset.label || btn.dataset.type;
|
|
document.getElementById('ptRestoreCreated').textContent = btn.dataset.created || '';
|
|
document.getElementById('ptRestoreScopeDb').classList.toggle('d-none', !isDb);
|
|
document.getElementById('ptRestoreScopeFiles').classList.toggle('d-none', isDb);
|
|
// purge só faz sentido para ficheiros; garante que não vai marcado
|
|
// de uma abertura anterior.
|
|
document.getElementById('ptRestorePurgeWrap').classList.toggle('d-none', isDb);
|
|
document.getElementById('ptRestorePurge').checked = false;
|
|
confirmInput.value = '';
|
|
submitBtn.disabled = true;
|
|
modal.show();
|
|
});
|
|
});
|
|
|
|
confirmInput.addEventListener('input', function () {
|
|
submitBtn.disabled = confirmInput.value.trim() !== 'RESTAURAR';
|
|
});
|
|
})();
|
|
</script>
|
|
|
|
<script>
|
|
// Anti-duplo-clique: bloqueia reenvios e mostra spinner no botão submetido.
|
|
document.addEventListener('submit', function (e) {
|
|
var form = e.target;
|
|
if (e.defaultPrevented || !(form instanceof HTMLFormElement)) return;
|
|
if (form.dataset.ptSubmitted) { e.preventDefault(); return; }
|
|
form.dataset.ptSubmitted = '1';
|
|
form.querySelectorAll('button[type="submit"]').forEach(function (btn) {
|
|
btn.disabled = true;
|
|
btn.insertAdjacentHTML('afterbegin',
|
|
'<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>');
|
|
});
|
|
});
|
|
|
|
<?php if ($awaiting): ?>
|
|
// Recarrega até o novo backup aparecer (contagem no servidor decide).
|
|
setTimeout(function () {
|
|
var url = new URL(window.location.href);
|
|
url.searchParams.set('pt_await', '<?= (int) $await_count ?>');
|
|
url.searchParams.set('pt_tries', '<?= (int) ($await_tries + 1) ?>');
|
|
window.location.replace(url.toString());
|
|
}, 8000);
|
|
<?php else: ?>
|
|
// Poll terminado (ou nunca ativo): limpa os parâmetros de acompanhamento.
|
|
if (window.location.search.indexOf('pt_await') !== -1) {
|
|
var cleanUrl = new URL(window.location.href);
|
|
cleanUrl.searchParams.delete('pt_await');
|
|
cleanUrl.searchParams.delete('pt_tries');
|
|
window.history.replaceState(null, '', cleanUrl.toString());
|
|
}
|
|
<?php endif; ?>
|
|
</script>
|
|
<?php if (is_file($layout_footer)) include $layout_footer; ?>
|