1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 07:58:10 +03:00

footer changes, remote access changes

This commit is contained in:
Anton Afanasyeu
2026-06-04 15:14:08 +02:00
parent f818ec4f0c
commit a29d84038e
30 changed files with 1612 additions and 92 deletions

View File

@@ -345,6 +345,41 @@
position: relative;
z-index: 1;
}
.hub-page .platform-footer {
flex-direction: column;
align-items: stretch;
justify-content: center;
height: auto;
min-height: 40px;
padding: 6px 16px;
gap: 4px;
}
.hub-page .platform-footer__top,
.hub-page .platform-footer__bottom {
width: 100%;
text-align: center;
line-height: 1.35;
}
.hub-page .platform-footer__main {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
width: 100%;
min-height: 24px;
}
.hub-page .platform-footer--single-row .platform-footer__main {
justify-content: flex-start;
}
.hub-page .platform-footer__left {
flex: 1 1 auto;
min-width: 0;
}
.hub-page .platform-footer__right {
flex: 0 0 auto;
text-align: right;
max-width: 50%;
}
.hub-clock-widget {
--cw-lcd-x: 24px;

View File

@@ -58,15 +58,18 @@ Loads `public/assets/js/analytics.js` from `layout.php` and `login.php`. The pro
Events include `page_view`, `console_view` (home, tickets, reports, …), and hub `hub_nav_click` / `hub_open_docs`.
## RBAC (phase 1)
## RBAC (phase 1 + admin UI)
Multi-tenant foundation (no admin UI yet):
Multi-tenant foundation:
- **Global roles** on `users.role`: `root`, `platform_admin` (legacy `admin` works), `viewer`
- **Companies** + **company_memberships** (`owner` / `admin` / `member`)
- **Companies** + **company_memberships** (`owner` / `admin` / `member`)**slug admin** = `owner` or `admin` on a company (graphs company scope)
- **Privilege sets** on `company_memberships.permissions_json`: `remote_access_user`, `remote_access_admin` (extra grants for `member` rows)
- **Devices** auto-registered on upload; reports get `company_id` + `device_id`
- Default company slug `default` — existing rows migrate on first request (`Database::ensureRbacSchema()`)
**Admin UI:** `?view=rbac` (nav **Access**) — company owner/admin and global admins. **Root only** can change global roles. API: `GET/POST …/api/rbac.php` (`panel`, `set_global_role`, `set_company_role`, `apply_privilege_set`). Smoke: `./scripts/test_rbac_api.sh`.
Config (`config.php`):
```php
@@ -269,6 +272,36 @@ Payload (`schema_version=1`): `session_id`, `company_id`, `user_id`, `device_id`
NTP heartbeat (`POST …/api/heartbeat.php`, `type=ntp`) returns `time_source` and `ntp_correction_s`
for clients that sync clocks before upload.
## Remote access (WireGuard v1)
On-demand debug tunnels — **same crashes vhost**, no new nginx HTTP locations. Full notes: [docs/REMOTE_ACCESS_IMPL.md](../../../docs/REMOTE_ACCESS_IMPL.md).
- Dashboard: `https://apps.f0xx.org/app/androidcast_project/crashes/?view=remote_access`
- Hub card: **Remote access**
- Device poll: `POST …/api/heartbeat.php` with `heartbeat.type = ra`
- Admin API: `…/api/remote_access.php` (session cookie + RBAC)
**MariaDB (existing server):** run once as root if not already applied:
```sh
mysql -u root -p androidcast_crashes < sql/migrations/007_remote_access.sql
```
**Production `config.php`** (not in git) — set `remote_access.wg_server_public_key` from `wg show wg0 public-key`, `require_wg_tools => true`, and `wg_endpoint` (public UDP DNAT hostname). See `config.example.php`.
**Cron:** `php scripts/purge_remote_access.php --hours=24` (hourly).
Smoke / verify:
```bash
./scripts/test_rbac_api.sh
./scripts/test_remote_access_api.sh
BASE=https://apps.f0xx.org/app/androidcast_project/crashes ./scripts/test_remote_access_api.sh
./scripts/verify_remote_access_prod.sh
```
Android: Developer settings → **Remote access** → WireGuard (RSSH reserved). Requires VPN permission; polls BE every 17 min while enabled.
## Default accounts
| User | Password | Role |

View File

@@ -45,4 +45,18 @@ return [
'measurement_id' => '', // e.g. G-XXXXXXXXXX
'debug' => false,
],
// On-demand remote access (WireGuard v1) — see docs/REMOTE_ACCESS_IMPL.md
'remote_access' => [
// Public UDP endpoint shown to devices (FE DNAT → BE wg0)
'wg_endpoint' => 'ra.apps.f0xx.org:51820',
// Server WG public key (wg show wg0 public-key on BE)
'wg_server_public_key' => '',
'wg_interface' => 'wg0',
// Run `wg set wg0 peer …` when device receives connect (requires wireguard-tools on BE)
'provision_peers' => true,
// Reject connect if wg genkey/pubkey unavailable (set true on production BE)
'require_wg_tools' => false,
'session_ttl_s' => 3600,
'min_poll_interval_s' => 45,
],
];

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
require_once __DIR__ . '/../../src/RbacAdminRepository.php';
header('Content-Type: application/json; charset=utf-8');
Auth::check();
if (!Rbac::canManageRbac()) {
json_out(['ok' => false, 'error' => 'forbidden'], 403);
}
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$action = trim((string) ($_GET['action'] ?? ''));
if ($method === 'GET' && $action === 'panel') {
$panel = RbacAdminRepository::buildPanel();
json_out($panel, empty($panel['ok']) ? 403 : 200);
}
if ($method !== 'POST') {
json_out(['ok' => false, 'error' => 'method_not_allowed'], 405);
}
$raw = file_get_contents('php://input') ?: '';
$body = json_decode($raw, true);
if (!is_array($body)) {
json_out(['ok' => false, 'error' => 'invalid_json'], 400);
}
if ($action === 'set_global_role') {
$result = RbacAdminRepository::setGlobalRole(
(int) ($body['user_id'] ?? 0),
(string) ($body['role'] ?? '')
);
json_out($result, empty($result['ok']) ? 400 : 200);
}
if ($action === 'set_company_role') {
$result = RbacAdminRepository::setCompanyRole(
(int) ($body['user_id'] ?? 0),
(int) ($body['company_id'] ?? 0),
(string) ($body['role'] ?? '')
);
json_out($result, empty($result['ok']) ? 400 : 200);
}
if ($action === 'apply_privilege_set') {
$result = RbacAdminRepository::applyPrivilegeSet(
(int) ($body['user_id'] ?? 0),
(int) ($body['company_id'] ?? 0),
(string) ($body['privilege_set'] ?? '')
);
json_out($result, empty($result['ok']) ? 400 : 200);
}
json_out(['ok' => false, 'error' => 'unknown_action'], 400);

View File

@@ -41,6 +41,13 @@ if ($method === 'GET') {
'active_sessions' => $active,
'inactive_sessions' => array_slice($inactive, 0, 50),
'recent_events' => RemoteAccessRepository::listEvents(30),
'permissions' => [
'can_operate' => Rbac::can('remote_access_operate'),
'can_admin' => Rbac::can('remote_access_admin'),
],
'config' => [
'wg_endpoint' => (string) cfg('remote_access.wg_endpoint', ''),
],
]);
}
json_out(['ok' => false, 'error' => 'unknown_action'], 400);
@@ -67,7 +74,12 @@ if ($action === 'whitelist') {
if ($deviceId === '') {
json_out(['ok' => false, 'error' => 'missing_device_id'], 400);
}
RemoteAccessRepository::setDeviceWhitelist($deviceId, !empty($body['whitelisted']), $body['notes'] ?? null);
if (!RemoteAccessRepository::canAccessDevice($deviceId)) {
json_out(['ok' => false, 'error' => 'forbidden'], 403);
}
if (!RemoteAccessRepository::setDeviceWhitelist($deviceId, !empty($body['whitelisted']), $body['notes'] ?? null)) {
json_out(['ok' => false, 'error' => 'forbidden'], 403);
}
RemoteAccessRepository::logEvent($deviceId, null, $userId, 'whitelist_update', null, [
'whitelisted' => !empty($body['whitelisted']),
], '');
@@ -82,9 +94,13 @@ if ($action === 'open_session') {
if ($deviceId === '') {
json_out(['ok' => false, 'error' => 'missing_device_id'], 400);
}
if (!RemoteAccessRepository::canAccessDevice($deviceId)) {
json_out(['ok' => false, 'error' => 'forbidden'], 403);
}
$result = RemoteAccessRepository::openSession($deviceId, $userId);
if (empty($result['ok'])) {
json_out(['ok' => false, 'error' => $result['error'] ?? 'failed'], 400);
$err = $result['error'] ?? 'failed';
json_out(['ok' => false, 'error' => $err], $err === 'forbidden' ? 403 : 400);
}
json_out(['ok' => true, 'session_id' => $result['session_id'] ?? '']);
}
@@ -97,6 +113,9 @@ if ($action === 'close_session') {
if ($sessionId === '') {
json_out(['ok' => false, 'error' => 'missing_session_id'], 400);
}
if (!RemoteAccessRepository::canOperatorCloseSession($sessionId, $userId)) {
json_out(['ok' => false, 'error' => 'forbidden'], 403);
}
RemoteAccessRepository::closeSession($sessionId, RemoteAccessRepository::STATUS_CLOSED, 'operator_closed');
RemoteAccessRepository::logEvent((string)($body['device_id'] ?? ''), $sessionId, $userId, 'session_close', 'operator', null, '');
json_out(['ok' => true]);

View File

@@ -438,6 +438,41 @@ a:hover { text-decoration: underline; }
background: var(--surface);
z-index: 40;
}
.platform-footer {
flex-direction: column;
align-items: stretch;
justify-content: center;
height: auto;
min-height: 40px;
padding: 6px 16px;
gap: 4px;
}
.platform-footer__top,
.platform-footer__bottom {
width: 100%;
text-align: center;
line-height: 1.35;
}
.platform-footer__main {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
width: 100%;
min-height: 24px;
}
.platform-footer--single-row .platform-footer__main {
justify-content: flex-start;
}
.platform-footer__left {
flex: 1 1 auto;
min-width: 0;
}
.platform-footer__right {
flex: 0 0 auto;
text-align: right;
max-width: 50%;
}
.login-page {
display: flex;
align-items: center;

View File

@@ -119,7 +119,7 @@
"login.register": "Register",
"login.register_soon": "(coming soon)",
"login.error": "Invalid credentials",
"footer.copyright": "(c) Anton Afanaasyeu, {year}",
"footer.copyright": "© Anton Afanaasyeu, {year}",
"tickets.title": "Tickets",
"tickets.empty": "No tickets yet.",
"tickets.col_issue": "Issue",

View File

@@ -120,7 +120,7 @@
"login.register": "Регистрация",
"login.register_soon": "(скоро)",
"login.error": "Неверные учётные данные",
"footer.copyright": "(c) Anton Afanaasyeu, {year}",
"footer.copyright": "© Anton Afanaasyeu, {year}",
"nav.tickets": "Тикеты",
"ticket.lifecycle": "Жизненный цикл",
"ticket.assignees": "Исполнители",

View File

@@ -93,11 +93,10 @@
if (k) opt.textContent = t(k);
});
});
const footer = document.querySelector('[data-i18n="footer.copyright"]');
if (footer) {
scope.querySelectorAll('.platform-footer__left[data-i18n], footer[data-i18n="footer.copyright"]').forEach((footer) => {
const year = footer.getAttribute('data-year') || String(new Date().getFullYear());
footer.textContent = t('footer.copyright', { year });
}
footer.textContent = t(footer.getAttribute('data-i18n') || 'footer.copyright', { year });
});
scope.querySelectorAll('[data-i18n="detail.report_meta"]').forEach((el) => {
el.textContent = t('detail.report_meta', {
id: el.getAttribute('data-i18n-id') || '',

View File

@@ -0,0 +1,161 @@
(function () {
function basePath() {
return document.body.getAttribute('data-base-path') || '';
}
function canEditGlobal() {
return document.body.getAttribute('data-can-rbac-root') === '1';
}
function setStatus(msg, isError) {
const el = document.getElementById('rbac-status');
if (!el) return;
el.textContent = msg;
el.classList.toggle('error', !!isError);
}
async function fetchJson(url, opts) {
const res = await fetch(url, Object.assign({ credentials: 'same-origin' }, opts || {}));
const data = await res.json().catch(() => ({}));
if (!res.ok || data.ok === false) {
throw new Error(data.error || 'HTTP ' + res.status);
}
return data;
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s == null ? '' : String(s);
return d.innerHTML;
}
function renderPanel(data) {
const tbody = document.getElementById('rbac-users-tbody');
if (!tbody) return;
tbody.innerHTML = '';
const companies = data.companies || [];
const companyRoles = data.company_roles || [];
const globalRoles = data.global_roles || [];
const sets = [{ key: '', label: '(role defaults only)' }].concat(data.privilege_sets || []);
(data.users || []).forEach((u) => {
(u.memberships || []).forEach((m) => {
const tr = document.createElement('tr');
const globalSelect = canEditGlobal()
? '<select class="rbac-global-role" data-user-id="' + u.id + '">' +
globalRoles.map((r) => '<option value="' + esc(r) + '"' + (u.global_role === r ? ' selected' : '') + '>' + esc(r) + '</option>').join('') +
'</select>'
: '<code>' + esc(u.global_role) + '</code>';
const roleSelect =
'<select class="rbac-company-role" data-user-id="' + u.id + '" data-company-id="' + m.company_id + '">' +
companyRoles
.map((r) => '<option value="' + esc(r) + '"' + (m.role === r ? ' selected' : '') + '>' + esc(r) + '</option>')
.join('') +
'</select>';
const setSelect =
'<select class="rbac-privilege-set" data-user-id="' + u.id + '" data-company-id="' + m.company_id + '">' +
sets
.map((s) => {
const key = s.key != null ? s.key : '';
const label = s.label || key || '(none)';
const sel = (m.privilege_set || '') === key ? ' selected' : '';
return '<option value="' + esc(key) + '"' + sel + '>' + esc(label) + '</option>';
})
.join('') +
(m.privilege_set === 'custom' ? '<option value="" disabled>custom grants (edit via API)</option>' : '') +
'</select>';
tr.innerHTML =
'<td>' + esc(u.username) + '</td>' +
'<td>' + globalSelect + '</td>' +
'<td><code>' + esc(m.slug) + '</code> ' + esc(m.name) + '</td>' +
'<td>' + roleSelect + '</td>' +
'<td>' + setSelect + '</td>';
tbody.appendChild(tr);
});
if (!(u.memberships || []).length && canEditGlobal()) {
const tr = document.createElement('tr');
tr.innerHTML =
'<td>' + esc(u.username) + '</td>' +
'<td><code>' + esc(u.global_role) + '</code></td>' +
'<td colspan="3" class="muted">No company membership</td>';
tbody.appendChild(tr);
}
});
if (!tbody.children.length) {
tbody.innerHTML = '<tr><td colspan="5" class="muted">No users in scope.</td></tr>';
}
const hint = document.getElementById('rbac-scope-hint');
if (hint) {
hint.textContent =
'Slug admin = company owner/admin (graphs company scope). Root may change global roles. Privilege sets add remote-access grants to members.';
if (companies.length) {
hint.textContent += ' Companies: ' + companies.map((c) => c.slug).join(', ');
}
}
}
async function postAction(action, body) {
await fetchJson(basePath() + '/api/rbac.php?action=' + encodeURIComponent(action), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}
function bindChanges() {
document.addEventListener('change', async (ev) => {
const t = ev.target;
if (!t || !t.classList) return;
try {
if (t.classList.contains('rbac-global-role') && canEditGlobal()) {
await postAction('set_global_role', {
user_id: Number(t.getAttribute('data-user-id')),
role: t.value,
});
setStatus('Global role updated.');
return;
}
if (t.classList.contains('rbac-company-role')) {
await postAction('set_company_role', {
user_id: Number(t.getAttribute('data-user-id')),
company_id: Number(t.getAttribute('data-company-id')),
role: t.value,
});
setStatus('Company role updated.');
return;
}
if (t.classList.contains('rbac-privilege-set')) {
await postAction('apply_privilege_set', {
user_id: Number(t.getAttribute('data-user-id')),
company_id: Number(t.getAttribute('data-company-id')),
privilege_set: t.value,
});
setStatus('Privilege set applied.');
}
} catch (e) {
setStatus(String(e.message || e), true);
}
});
}
async function load() {
setStatus('Loading…');
try {
const data = await fetchJson(basePath() + '/api/rbac.php?action=panel');
renderPanel(data);
setStatus('Ready — changes save on selection.');
} catch (e) {
setStatus(String(e.message || e), true);
}
}
document.addEventListener('DOMContentLoaded', function () {
bindChanges();
load();
});
})();

View File

@@ -3,6 +3,14 @@
return document.body.getAttribute('data-base-path') || '';
}
function canOperate() {
return document.body.getAttribute('data-can-ra-operate') === '1';
}
function canAdmin() {
return document.body.getAttribute('data-can-ra-admin') === '1';
}
function apiUrl(action, params) {
const q = new URLSearchParams(params || {});
q.set('action', action);
@@ -35,19 +43,27 @@
const tbody = document.getElementById('ra-devices-tbody');
if (!tbody) return;
tbody.innerHTML = '';
if (!devices || !devices.length) {
tbody.innerHTML = '<tr><td colspan="6" class="muted">No devices have polled yet.</td></tr>';
return;
}
(devices || []).forEach((d) => {
const tr = document.createElement('tr');
const wl = Number(d.whitelisted) === 1;
const canOpen = canOperate() && wl && (d.opt_in_mode === 'wireguard');
const openDisabled = canOpen ? '' : ' disabled title="Whitelist device and wait for WireGuard opt-in poll"';
const wlDisabled = canAdmin() ? '' : ' disabled';
tr.innerHTML =
'<td><code>' + esc(d.device_id) + '</code></td>' +
'<td><code class="ra-device-id">' + esc(d.device_id) + '</code></td>' +
'<td>' + esc(d.opt_in_mode || 'none') + '</td>' +
'<td>' + (wl ? 'yes' : 'no') + '</td>' +
'<td>' + (wl ? '<span class="tag-pill">yes</span>' : 'no') + '</td>' +
'<td>' + esc(d.last_seen_at || '—') + '</td>' +
'<td>' + esc(d.app_version || '—') + '</td>' +
'<td>' +
'<button type="button" class="btn btn-sm" data-open-session="' + esc(d.device_id) + '">Open session</button> ' +
'<button type="button" class="btn btn-sm" data-toggle-wl="' + esc(d.device_id) + '" data-wl="' + (wl ? '0' : '1') + '">' +
(wl ? 'Revoke WL' : 'Whitelist') + '</button>' +
'<button type="button" class="btn btn-sm btn-primary"' + openDisabled + ' data-open-session="' + esc(d.device_id) + '">Open session</button> ' +
'<button type="button" class="btn btn-sm"' + wlDisabled + ' data-toggle-wl="' + esc(d.device_id) + '" data-wl="' + (wl ? '0' : '1') + '">' +
(wl ? 'Revoke WL' : 'Whitelist') + '</button> ' +
'<button type="button" class="btn btn-sm" data-copy-id="' + esc(d.device_id) + '">Copy ID</button>' +
'</td>';
tbody.appendChild(tr);
});
@@ -58,15 +74,21 @@
const iBody = document.getElementById('ra-inactive-tbody');
if (aBody) {
aBody.innerHTML = '';
if (!active || !active.length) {
aBody.innerHTML = '<tr><td colspan="6" class="muted">No pending or active sessions.</td></tr>';
}
(active || []).forEach((s) => {
const tr = document.createElement('tr');
const closeBtn = canOperate()
? '<button type="button" class="btn btn-sm" data-close-session="' + esc(s.session_id) + '" data-device="' + esc(s.device_id) + '">Close</button>'
: '';
tr.innerHTML =
'<td><code>' + esc(s.session_id) + '</code></td>' +
'<td><code>' + esc(s.device_id) + '</code></td>' +
'<td>' + esc(s.status) + '</td>' +
'<td>' + esc(s.tunnel) + '</td>' +
'<td>' + esc(s.endpoint || '—') + '</td>' +
'<td><button type="button" class="btn btn-sm" data-close-session="' + esc(s.session_id) + '" data-device="' + esc(s.device_id) + '">Close</button></td>';
'<td>' + closeBtn + '</td>';
aBody.appendChild(tr);
});
}
@@ -82,6 +104,9 @@
'<td>' + esc(s.close_reason || '—') + '</td>';
iBody.appendChild(tr);
});
if (!inactive || !inactive.length) {
iBody.innerHTML = '<tr><td colspan="5" class="muted">No closed sessions yet.</td></tr>';
}
}
}
@@ -89,6 +114,10 @@
const tbody = document.getElementById('ra-events-tbody');
if (!tbody) return;
tbody.innerHTML = '';
if (!events || !events.length) {
tbody.innerHTML = '<tr><td colspan="4" class="muted">No audit events.</td></tr>';
return;
}
(events || []).forEach((e) => {
const tr = document.createElement('tr');
tr.innerHTML =
@@ -100,14 +129,18 @@
});
}
let lastConfig = {};
async function refresh() {
setStatus('Loading…');
try {
const data = await fetchJson(apiUrl('dashboard'));
lastConfig = data.config || {};
renderDevices(data.devices);
renderSessions(data.active_sessions, data.inactive_sessions);
renderEvents(data.recent_events);
setStatus('Updated ' + new Date().toLocaleTimeString());
const ep = lastConfig.wg_endpoint ? ' · WG ' + lastConfig.wg_endpoint : '';
setStatus('Updated ' + new Date().toLocaleTimeString() + ep);
} catch (e) {
setStatus('Failed: ' + e.message, true);
}
@@ -116,14 +149,27 @@
document.addEventListener('click', async (ev) => {
const t = ev.target;
if (!(t instanceof HTMLElement)) return;
const copyId = t.getAttribute('data-copy-id');
if (copyId) {
try {
await navigator.clipboard.writeText(copyId);
setStatus('Copied device ID');
} catch (e) {
setStatus('Copy failed', true);
}
return;
}
const openId = t.getAttribute('data-open-session');
if (openId) {
if (!canOperate()) return;
try {
setStatus('Opening session for ' + openId + '…');
await fetchJson(apiUrl('open_session'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device_id: openId }),
});
setStatus('Session opened — device will connect on next poll (≤7 min)');
await refresh();
} catch (e) {
setStatus('Open session: ' + e.message, true);
@@ -132,6 +178,7 @@
}
const closeId = t.getAttribute('data-close-session');
if (closeId) {
if (!canOperate()) return;
try {
await fetchJson(apiUrl('close_session'), {
method: 'POST',
@@ -149,6 +196,7 @@
}
const wlDevice = t.getAttribute('data-toggle-wl');
if (wlDevice) {
if (!canAdmin()) return;
try {
await fetchJson(apiUrl('whitelist'), {
method: 'POST',
@@ -167,8 +215,12 @@
const form = document.getElementById('ra-whitelist-form');
if (form) {
if (!canAdmin()) {
form.hidden = true;
}
form.addEventListener('submit', async (ev) => {
ev.preventDefault();
if (!canAdmin()) return;
const fd = new FormData(form);
try {
await fetchJson(apiUrl('whitelist'), {
@@ -189,5 +241,5 @@
}
refresh();
setInterval(refresh, 60000);
setInterval(refresh, 30000);
})();

View File

@@ -100,6 +100,11 @@ if ($route === '/api/remote_access.php' || str_ends_with($route, '/api/remote_ac
exit;
}
if ($route === '/api/rbac.php' || str_ends_with($route, '/api/rbac.php')) {
require __DIR__ . '/api/rbac.php';
exit;
}
if ($route === '/api/ticket_attachment.php' || str_ends_with($route, '/api/ticket_attachment.php')) {
require __DIR__ . '/api/ticket_attachment.php';
exit;
@@ -203,6 +208,7 @@ $pageTitle = match ($view) {
'tickets' => 'Tickets',
'graphs' => 'Graphs',
'remote_access' => 'Remote access',
'rbac' => 'Access control',
'reports', 'report' => 'Crash reports',
default => 'Console',
};

View File

@@ -0,0 +1,22 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
/**
* Purge stale remote-access sessions (cron: hourly).
* Usage: php scripts/purge_remote_access.php [--hours=24]
*/
require_once __DIR__ . '/../src/bootstrap.php';
require_once __DIR__ . '/../src/RemoteAccessRepository.php';
require_once __DIR__ . '/../src/WireGuardPeerProvisioner.php';
$hours = 24;
foreach ($argv as $arg) {
if (str_starts_with($arg, '--hours=')) {
$hours = max(1, (int) substr($arg, 8));
}
}
RemoteAccessRepository::ensureSchema();
$n = RemoteAccessRepository::purgeStale($hours);
fwrite(STDOUT, "purged_sessions={$n}\n");

View File

@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Smoke test RBAC admin API (local orchestration or deployed crashes console).
set -euo pipefail
BASE="${CRASHES_BASE:-http://127.0.0.1:8080/app/androidcast_project/crashes}"
COOKIE_JAR="$(mktemp)"
trap 'rm -f "$COOKIE_JAR"' EXIT
curl -sf -c "$COOKIE_JAR" -b "$COOKIE_JAR" -X POST \
-d "username=${ADMIN_USER:-admin}&password=${ADMIN_PASS:-admin}" \
"$BASE/login" >/dev/null
echo "== rbac panel =="
curl -sf -b "$COOKIE_JAR" "$BASE/api/rbac.php?action=panel" | grep -q '"ok":true' && echo OK
curl -sf -b "$COOKIE_JAR" "$BASE/api/rbac.php?action=panel" | grep -q 'privilege_sets' && echo OK sets
echo "All RBAC API checks passed."

View File

@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# Pre-flight checks for remote-access v1 on the BE (run on Alpine VM as root or deploy user).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
fail=0
warn=0
ok() { echo "OK $*"; }
bad() { echo "FAIL $*"; fail=1; }
note() { echo "WARN $*"; warn=1; }
echo "== Remote access production verify =="
echo "backend: $ROOT"
echo
# config.php
CFG="$ROOT/config/config.php"
if [[ ! -f "$CFG" ]]; then
bad "config/config.php missing (copy from config.example.php)"
else
ok "config.php present"
fi
php -r '
$config = require "'"$CFG"'";
$ra = $config["remote_access"] ?? [];
$pub = trim((string)($ra["wg_server_public_key"] ?? ""));
$ep = trim((string)($ra["wg_endpoint"] ?? ""));
if ($pub === "") { fwrite(STDERR, "FAIL wg_server_public_key is empty — run: wg show wg0 public-key\n"); exit(2); }
if ($ep === "") { fwrite(STDERR, "FAIL wg_endpoint is empty\n"); exit(2); }
if (empty($ra["require_wg_tools"])) { fwrite(STDERR, "WARN require_wg_tools should be true on production\n"); exit(3); }
echo "OK remote_access config: endpoint=$ep, require_wg_tools=" . (($ra["require_wg_tools"] ?? false) ? "true" : "false") . "\n";
' || {
code=$?
if [[ $code -eq 2 ]]; then fail=1; elif [[ $code -eq 3 ]]; then warn=1; fi
}
# wireguard-tools
if command -v wg >/dev/null 2>&1; then
ok "wg in PATH ($(command -v wg))"
else
bad "wireguard-tools missing (apk add wireguard-tools)"
fi
IFACE="${WG_INTERFACE:-wg0}"
if ip link show "$IFACE" >/dev/null 2>&1; then
ok "interface $IFACE up"
if command -v wg >/dev/null 2>&1; then
wg show "$IFACE" 2>/dev/null | head -3 || note "wg show $IFACE returned nothing"
fi
else
note "interface $IFACE not found (data plane not ready; HTTP control plane may still work)"
fi
# PHP can shell_exec wg (same user as FPM)
if php -r 'echo trim((string)@shell_exec("wg genkey 2>/dev/null"));' | grep -qE '.{40,}'; then
ok "php can run wg genkey (FPM user must match for connect)"
else
note "php CLI cannot run wg genkey — ensure www-data can run wg if provision_peers=true"
fi
# DB tables (via bootstrap)
php -r '
require "'"$ROOT"'/src/bootstrap.php";
RemoteAccessRepository::ensureSchema();
$pdo = Database::pdo();
foreach (["remote_access_devices","remote_access_sessions","remote_access_events"] as $t) {
if (!Database::tableExists($pdo, $t)) { fwrite(STDERR, "FAIL table missing: $t\n"); exit(2); }
}
echo "OK MariaDB/SQLite remote_access tables present\n";
' || fail=1
# API smoke (local php -S or deployed BASE)
BASE="${BASE:-http://127.0.0.1:8080/app/androidcast_project/crashes}"
if [[ -x "$ROOT/scripts/test_remote_access_api.sh" ]]; then
if BASE="$BASE" bash "$ROOT/scripts/test_remote_access_api.sh" >/dev/null 2>&1; then
ok "API smoke test ($BASE)"
else
note "API smoke test failed against $BASE (set BASE=…/crashes if needed)"
fi
fi
echo
if [[ $fail -ne 0 ]]; then
echo "Result: NOT READY ($fail hard failure(s), $warn warning(s))"
exit 1
fi
if [[ $warn -ne 0 ]]; then
echo "Result: READY WITH WARNINGS ($warn warning(s))"
exit 0
fi
echo "Result: READY"
exit 0

View File

@@ -21,6 +21,27 @@ final class Rbac {
private const GLOBAL_ADMIN_ROLES = [self::GLOBAL_ROOT, self::GLOBAL_PLATFORM_ADMIN, self::GLOBAL_ADMIN_LEGACY];
/** Company role => granted actions (unless global admin). */
/**
* Named privilege sets stored in company_memberships.permissions_json as {"grants":[...]}.
* Company role defaults still apply; grants extend (not replace) role actions.
*/
public const PRIVILEGE_SET_NONE = '';
public const PRIVILEGE_SET_REMOTE_ACCESS_USER = 'remote_access_user';
public const PRIVILEGE_SET_REMOTE_ACCESS_ADMIN = 'remote_access_admin';
/** @var array<string, list<string>> */
public const PRIVILEGE_SETS = [
self::PRIVILEGE_SET_REMOTE_ACCESS_USER => [
'remote_access_view',
'remote_access_operate',
],
self::PRIVILEGE_SET_REMOTE_ACCESS_ADMIN => [
'remote_access_view',
'remote_access_operate',
'remote_access_admin',
],
];
private const COMPANY_ROLE_ACTIONS = [
self::COMPANY_OWNER => [
'view_reports',
@@ -274,6 +295,76 @@ final class Rbac {
return null;
}
/** @return list<string> */
public static function privilegeSetKeys(): array {
return array_keys(self::PRIVILEGE_SETS);
}
/** @return list<string> */
public static function grantsForPrivilegeSet(string $setKey): array {
$setKey = trim($setKey);
if ($setKey === '' || $setKey === self::PRIVILEGE_SET_NONE) {
return [];
}
return self::PRIVILEGE_SETS[$setKey] ?? [];
}
public static function encodePermissionsJson(?string $privilegeSetKey): ?string {
$grants = self::grantsForPrivilegeSet((string) $privilegeSetKey);
if ($grants === []) {
return null;
}
return json_encode(['grants' => $grants], JSON_UNESCAPED_UNICODE);
}
public static function privilegeSetFromPermissionsJson(?string $raw): string {
if (!is_string($raw) || trim($raw) === '') {
return self::PRIVILEGE_SET_NONE;
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
return self::PRIVILEGE_SET_NONE;
}
$grants = $decoded['grants'] ?? $decoded;
if (!is_array($grants)) {
return self::PRIVILEGE_SET_NONE;
}
sort($grants);
foreach (self::PRIVILEGE_SETS as $key => $want) {
$sorted = $want;
sort($sorted);
if ($grants === $sorted) {
return $key;
}
}
return 'custom';
}
public static function isRoot(?array $user = null): bool {
$user ??= Auth::user();
if (!$user) {
return false;
}
return self::normalizeGlobalRole((string) ($user['role'] ?? '')) === self::GLOBAL_ROOT;
}
public static function canManageRbac(?array $user = null): bool {
$user ??= Auth::user();
if (!$user) {
return false;
}
if (self::isGlobalAdmin($user)) {
return true;
}
foreach ($user['companies'] ?? [] as $c) {
$role = (string) ($c['role'] ?? '');
if ($role === self::COMPANY_OWNER || $role === self::COMPANY_ADMIN) {
return true;
}
}
return false;
}
private static function hasPermissionOverride(array $user, int $companyId, string $action): bool {
$pdo = Database::pdo();
$stmt = $pdo->prepare(

View File

@@ -0,0 +1,251 @@
<?php
declare(strict_types=1);
/** RBAC admin: global roles, company memberships, privilege sets. */
final class RbacAdminRepository {
private function __construct() {}
public static function buildPanel(?array $actor = null): array {
$actor ??= Auth::user();
if (!$actor || !Rbac::canManageRbac($actor)) {
return ['ok' => false, 'error' => 'forbidden'];
}
$actorId = (int) ($actor['id'] ?? 0);
$globalAdmin = Rbac::isGlobalAdmin($actor);
$companies = self::listCompaniesForActor($actor);
$companyIds = array_map(static fn($c) => (int) $c['id'], $companies);
return [
'ok' => true,
'actor' => [
'id' => $actorId,
'username' => (string) ($actor['username'] ?? ''),
'role' => (string) ($actor['role'] ?? ''),
'is_root' => Rbac::isRoot($actor),
'is_global_admin' => $globalAdmin,
],
'companies' => $companies,
'users' => self::listUsersWithMemberships($companyIds, $globalAdmin),
'global_roles' => [
Rbac::GLOBAL_ROOT,
Rbac::GLOBAL_PLATFORM_ADMIN,
Rbac::GLOBAL_VIEWER,
],
'company_roles' => [
Rbac::COMPANY_OWNER,
Rbac::COMPANY_ADMIN,
Rbac::COMPANY_MEMBER,
],
'privilege_sets' => array_map(
static fn($key) => [
'key' => $key,
'label' => str_replace('_', ' ', $key),
'grants' => Rbac::grantsForPrivilegeSet($key),
],
Rbac::privilegeSetKeys()
),
'permissions' => [
'can_edit_global_roles' => Rbac::isRoot($actor),
'can_manage_platform' => Rbac::can('manage_platform', null, $actor),
'can_manage_company_users' => $globalAdmin,
],
];
}
public static function setGlobalRole(int $targetUserId, string $role, ?array $actor = null): array {
$actor ??= Auth::user();
if (!$actor || !Rbac::isRoot($actor)) {
return ['ok' => false, 'error' => 'forbidden'];
}
$role = Rbac::normalizeGlobalRole($role);
$allowed = [Rbac::GLOBAL_ROOT, Rbac::GLOBAL_PLATFORM_ADMIN, Rbac::GLOBAL_VIEWER];
if (!in_array($role, $allowed, true)) {
return ['ok' => false, 'error' => 'invalid_role'];
}
if ($targetUserId <= 0) {
return ['ok' => false, 'error' => 'invalid_user'];
}
$pdo = Database::pdo();
$stmt = $pdo->prepare('UPDATE users SET role = ? WHERE id = ?');
$stmt->execute([$role, $targetUserId]);
if ($stmt->rowCount() < 1) {
return ['ok' => false, 'error' => 'user_not_found'];
}
if (in_array($role, [Rbac::GLOBAL_ROOT, Rbac::GLOBAL_PLATFORM_ADMIN], true)) {
Rbac::seedMembershipsForUser($targetUserId, $role);
}
return ['ok' => true];
}
public static function setCompanyRole(int $targetUserId, int $companyId, string $role, ?array $actor = null): array {
$actor ??= Auth::user();
if (!$actor || !self::canEditMembership($actor, $companyId)) {
return ['ok' => false, 'error' => 'forbidden'];
}
$allowed = [Rbac::COMPANY_OWNER, Rbac::COMPANY_ADMIN, Rbac::COMPANY_MEMBER];
if (!in_array($role, $allowed, true)) {
return ['ok' => false, 'error' => 'invalid_role'];
}
if ($targetUserId <= 0 || $companyId <= 0) {
return ['ok' => false, 'error' => 'invalid_input'];
}
$pdo = Database::pdo();
if (Database::isMysql()) {
$sql = 'INSERT INTO company_memberships (user_id, company_id, role) VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE role = VALUES(role)';
} else {
$sql = 'INSERT INTO company_memberships (user_id, company_id, role) VALUES (?, ?, ?)
ON CONFLICT(user_id, company_id) DO UPDATE SET role = excluded.role';
}
$stmt = $pdo->prepare($sql);
$stmt->execute([$targetUserId, $companyId, $role]);
return ['ok' => true];
}
public static function applyPrivilegeSet(int $targetUserId, int $companyId, string $setKey, ?array $actor = null): array {
$actor ??= Auth::user();
if (!$actor || !self::canEditMembership($actor, $companyId)) {
return ['ok' => false, 'error' => 'forbidden'];
}
if ($targetUserId <= 0 || $companyId <= 0) {
return ['ok' => false, 'error' => 'invalid_input'];
}
$setKey = trim($setKey);
if ($setKey !== '' && $setKey !== Rbac::PRIVILEGE_SET_NONE && !isset(Rbac::PRIVILEGE_SETS[$setKey])) {
return ['ok' => false, 'error' => 'invalid_privilege_set'];
}
$json = Rbac::encodePermissionsJson($setKey === Rbac::PRIVILEGE_SET_NONE ? '' : $setKey);
$pdo = Database::pdo();
$stmt = $pdo->prepare(
'UPDATE company_memberships SET permissions_json = ? WHERE user_id = ? AND company_id = ?'
);
$stmt->execute([$json, $targetUserId, $companyId]);
if ($stmt->rowCount() < 1) {
return ['ok' => false, 'error' => 'membership_not_found'];
}
return ['ok' => true];
}
/** @return list<array{id:int,slug:string,name:string}> */
private static function listCompaniesForActor(array $actor): array {
$pdo = Database::pdo();
$allowed = Rbac::allowedCompanyIds($actor);
if ($allowed === null) {
$stmt = $pdo->query('SELECT id, slug, name FROM companies ORDER BY name ASC');
return self::mapCompanies($stmt->fetchAll(PDO::FETCH_ASSOC));
}
if ($allowed === []) {
return [];
}
$ph = implode(',', array_fill(0, count($allowed), '?'));
$stmt = $pdo->prepare("SELECT id, slug, name FROM companies WHERE id IN ($ph) ORDER BY name ASC");
$stmt->execute($allowed);
return self::mapCompanies($stmt->fetchAll(PDO::FETCH_ASSOC));
}
/** @param list<array<string,mixed>> $rows @return list<array{id:int,slug:string,name:string}> */
private static function mapCompanies(array $rows): array {
$out = [];
foreach ($rows as $row) {
$out[] = [
'id' => (int) $row['id'],
'slug' => (string) $row['slug'],
'name' => (string) $row['name'],
];
}
return $out;
}
/**
* @param list<int> $companyIds
* @return list<array<string, mixed>>
*/
private static function listUsersWithMemberships(array $companyIds, bool $globalAdmin): array {
$pdo = Database::pdo();
if ($globalAdmin) {
$users = $pdo->query('SELECT id, username, role FROM users ORDER BY username ASC')->fetchAll(PDO::FETCH_ASSOC);
} else {
if ($companyIds === []) {
return [];
}
$ph = implode(',', array_fill(0, count($companyIds), '?'));
$stmt = $pdo->prepare(
"SELECT DISTINCT u.id, u.username, u.role
FROM users u
INNER JOIN company_memberships m ON m.user_id = u.id
WHERE m.company_id IN ($ph)
ORDER BY u.username ASC"
);
$stmt->execute($companyIds);
$users = $stmt->fetchAll(PDO::FETCH_ASSOC);
}
$out = [];
foreach ($users as $u) {
$uid = (int) $u['id'];
$memberships = self::membershipsForUser($uid, $companyIds, $globalAdmin);
$out[] = [
'id' => $uid,
'username' => (string) $u['username'],
'global_role' => Rbac::normalizeGlobalRole((string) ($u['role'] ?? '')),
'memberships' => $memberships,
];
}
return $out;
}
/**
* @param list<int> $companyIds
* @return list<array<string, mixed>>
*/
private static function membershipsForUser(int $userId, array $companyIds, bool $globalAdmin): array {
$pdo = Database::pdo();
if ($globalAdmin) {
$stmt = $pdo->prepare(
'SELECT m.company_id, c.slug, c.name, m.role, m.permissions_json
FROM company_memberships m
INNER JOIN companies c ON c.id = m.company_id
WHERE m.user_id = ?
ORDER BY c.name ASC'
);
$stmt->execute([$userId]);
} else {
if ($companyIds === []) {
return [];
}
$ph = implode(',', array_fill(0, count($companyIds), '?'));
$params = array_merge([$userId], $companyIds);
$stmt = $pdo->prepare(
"SELECT m.company_id, c.slug, c.name, m.role, m.permissions_json
FROM company_memberships m
INNER JOIN companies c ON c.id = m.company_id
WHERE m.user_id = ? AND m.company_id IN ($ph)
ORDER BY c.name ASC"
);
$stmt->execute($params);
}
$out = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$raw = isset($row['permissions_json']) ? (string) $row['permissions_json'] : '';
$out[] = [
'company_id' => (int) $row['company_id'],
'slug' => (string) $row['slug'],
'name' => (string) $row['name'],
'role' => (string) $row['role'],
'privilege_set' => Rbac::privilegeSetFromPermissionsJson($raw),
];
}
return $out;
}
private static function canEditMembership(array $actor, int $companyId): bool {
if (Rbac::isGlobalAdmin($actor)) {
return true;
}
if (!Rbac::canAccessCompany($companyId, $actor)) {
return false;
}
$uid = (int) ($actor['id'] ?? 0);
$role = $uid > 0 ? Rbac::companyRole($uid, $companyId) : null;
return $role === Rbac::COMPANY_OWNER || $role === Rbac::COMPANY_ADMIN;
}
}

View File

@@ -18,10 +18,68 @@ final class RemoteAccessRepository {
public static function ensureSchema(): void {
$pdo = Database::pdo();
if (Database::tableExists($pdo, 'remote_access_devices')) {
if (!Database::tableExists($pdo, 'remote_access_devices')) {
self::createTables($pdo);
return;
}
self::createTables($pdo);
self::ensureSessionColumns($pdo);
}
/** @param list<string> $where @param list<mixed> $params */
private static function appendCompanyScopeSql(string $column, array &$where, array &$params): void {
Rbac::appendCompanyScope($column, $where, $params);
}
public static function canAccessDevice(string $deviceId): bool {
$device = self::findDevice($deviceId);
if ($device === null) {
return Rbac::isGlobalAdmin();
}
return Rbac::canAccessCompany((int) ($device['company_id'] ?? 0));
}
public static function canOperatorCloseSession(string $sessionId, int $operatorUserId): bool {
$session = self::findSession($sessionId);
if ($session === null) {
return false;
}
$companyId = (int) ($session['company_id'] ?? 0);
if (!Rbac::canAccessCompany($companyId)) {
return false;
}
if (Rbac::isGlobalAdmin() || Rbac::can('remote_access_admin', $companyId)) {
return true;
}
return $operatorUserId > 0
&& (int) ($session['operator_user_id'] ?? 0) === $operatorUserId
&& Rbac::can('remote_access_operate', $companyId);
}
/** @return array<string, mixed>|null */
public static function findSession(string $sessionId): ?array {
self::ensureSchema();
$st = Database::pdo()->prepare('SELECT * FROM remote_access_sessions WHERE session_id = ? LIMIT 1');
$st->execute([$sessionId]);
$row = $st->fetch(PDO::FETCH_ASSOC);
return $row ?: null;
}
private static function ensureSessionColumns(PDO $pdo): void {
if (!Database::tableExists($pdo, 'remote_access_sessions')) {
return;
}
$col = Database::isMysql() ? 'VARCHAR(64) NULL' : 'TEXT NULL';
try {
$pdo->exec('ALTER TABLE remote_access_sessions ADD COLUMN wg_client_public_key ' . $col);
} catch (PDOException $e) {
$msg = strtolower($e->getMessage());
if (!str_contains($msg, 'duplicate') && !str_contains($msg, 'already exists')) {
throw $e;
}
}
}
private static function createTables(PDO $pdo): void {
@@ -60,6 +118,15 @@ final class RemoteAccessRepository {
return self::handleDisable($deviceId, $random, $clientIp, $appVersion);
}
$existing = self::findDevice($deviceId);
$pendingSession = null;
if ($existing !== null && self::isRateLimited($existing)) {
$pendingSession = self::findConnectableSession($deviceId, $random);
if ($pendingSession === null) {
return self::raEnvelope('wait', ['reason' => 'rate_limited']);
}
}
self::upsertDevicePoll($deviceId, $random, $appVersion, $tunnelMode, $clientIp);
$device = self::findDevice($deviceId);
@@ -78,15 +145,40 @@ final class RemoteAccessRepository {
return self::raEnvelope('deny', ['reason' => 'rssh_not_implemented']);
}
$session = self::findConnectableSession($deviceId, $random);
$session = $pendingSession ?? self::findConnectableSession($deviceId, $random);
if ($session === null) {
self::logEvent($deviceId, null, null, 'poll_wait', 'no_pending_session', null, $clientIp);
return self::raEnvelope('wait', ['reason' => 'no_pending_session']);
}
self::touchSessionActivity((string) ($session['session_id'] ?? ''));
return self::buildConnectResponse($session, $deviceId, $clientIp);
}
/** @param array<string, mixed> $device */
private static function isRateLimited(array $device): bool {
$min = max(15, (int) cfg('remote_access.min_poll_interval_s', 45));
$last = (string) ($device['last_seen_at'] ?? '');
if ($last === '') {
return false;
}
$ts = strtotime($last);
if ($ts === false) {
return false;
}
return (time() - $ts) < $min;
}
private static function touchSessionActivity(string $sessionId): void {
if ($sessionId === '') {
return;
}
Database::pdo()->prepare(
'UPDATE remote_access_sessions SET last_activity_at = ? WHERE session_id = ?'
)->execute([self::nowSql(), $sessionId]);
}
/** @param list<string> $capabilities */
private static function inferTunnelMode(array $capabilities, array $heartbeat): string {
foreach ($capabilities as $cap) {
@@ -193,7 +285,17 @@ final class RemoteAccessRepository {
return self::raEnvelope('deny', ['reason' => 'tunnel_not_supported', 'tunnel' => $tunnel]);
}
$keys = self::ensureSessionWireGuardKeys($sessionId, $session);
$keys = [];
try {
$keys = self::ensureSessionWireGuardKeys($sessionId, $session);
} catch (Throwable $e) {
error_log('RemoteAccess connect keys: ' . $e->getMessage());
return self::raEnvelope('deny', ['reason' => 'key_generation_failed']);
}
if (str_starts_with($keys['client_public'] ?? '', 'dev-placeholder-pub-')
&& (bool) cfg('remote_access.require_wg_tools', false)) {
return self::raEnvelope('deny', ['reason' => 'wg_tools_required']);
}
$endpoint = (string) ($session['endpoint'] ?? cfg('remote_access.wg_endpoint', 'ra.apps.f0xx.org:51820'));
$expiresAt = (int) ($session['expires_at'] ?? (time() + 3600));
@@ -216,34 +318,49 @@ final class RemoteAccessRepository {
]);
}
/** @param array<string, mixed> $session @return array{client_private:string,client_address:string,server_public:string,allowed_ips:string} */
/** @param array<string, mixed> $session @return array{client_private:string,client_address:string,server_public:string,allowed_ips:string,client_public:string} */
private static function ensureSessionWireGuardKeys(string $sessionId, array $session): array {
$priv = trim((string) ($session['wg_client_private_key'] ?? ''));
$pub = trim((string) ($session['wg_client_public_key'] ?? ''));
$addr = trim((string) ($session['wg_client_address'] ?? ''));
$pub = trim((string) ($session['wg_server_public_key'] ?? ''));
$serverPub = trim((string) ($session['wg_server_public_key'] ?? ''));
$allowed = trim((string) ($session['wg_peer_allowed_ips'] ?? ''));
if ($priv !== '' && $addr !== '' && $pub !== '') {
if ($priv !== '' && $addr !== '' && $serverPub !== '') {
if ($pub === '') {
$pub = WireGuardPeerProvisioner::publicKeyFromPrivate($priv);
}
return [
'client_private' => $priv,
'client_public' => $pub,
'client_address' => $addr,
'server_public' => $pub,
'server_public' => $serverPub,
'allowed_ips' => $allowed !== '' ? $allowed : '10.66.66.1/32',
];
}
$pair = self::generateWireGuardKeyPair();
$priv = $pair['private'];
$pub = $pair['public'];
if ($pub === '' || str_starts_with($pub, 'dev-placeholder-pub-')) {
$pub = WireGuardPeerProvisioner::publicKeyFromPrivate($priv);
}
$clientIp = self::allocateClientAddress($sessionId);
$serverPub = cfg('remote_access.wg_server_public_key', '');
$serverPub = (string) cfg('remote_access.wg_server_public_key', '');
if ($serverPub === '') {
$serverPair = self::generateWireGuardKeyPair();
$serverPub = $serverPair['public'];
}
$allowed = '10.66.66.1/32';
Database::pdo()->prepare(
'UPDATE remote_access_sessions SET wg_client_private_key = ?, wg_client_address = ?, wg_server_public_key = ?, wg_peer_allowed_ips = ? WHERE session_id = ?'
)->execute([$pair['private'], $clientIp, $serverPub, $allowed, $sessionId]);
'UPDATE remote_access_sessions SET wg_client_private_key = ?, wg_client_public_key = ?, wg_client_address = ?, wg_server_public_key = ?, wg_peer_allowed_ips = ? WHERE session_id = ?'
)->execute([$priv, $pub, $clientIp, $serverPub, $allowed, $sessionId]);
if (!WireGuardPeerProvisioner::addClientPeer($pub, $clientIp)) {
error_log('RemoteAccess: wg peer add failed for session ' . $sessionId);
}
return [
'client_private' => $pair['private'],
'client_private' => $priv,
'client_public' => $pub,
'client_address' => $clientIp,
'server_public' => $serverPub,
'allowed_ips' => $allowed,
@@ -264,6 +381,9 @@ final class RemoteAccessRepository {
return ['private' => $priv, 'public' => $pub];
}
}
if ((bool) cfg('remote_access.require_wg_tools', false)) {
throw new RuntimeException('wireguard-tools unavailable (wg genkey)');
}
$raw = random_bytes(32);
$priv = rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
return ['private' => $priv, 'public' => 'dev-placeholder-pub-' . substr(hash('sha256', $priv), 0, 43)];
@@ -287,6 +407,14 @@ final class RemoteAccessRepository {
}
public static function closeSession(string $sessionId, string $status, string $reason): void {
$st = Database::pdo()->prepare(
'SELECT wg_client_public_key FROM remote_access_sessions WHERE session_id = ? LIMIT 1'
);
$st->execute([$sessionId]);
$pub = $st->fetchColumn();
if (is_string($pub) && $pub !== '') {
WireGuardPeerProvisioner::removeClientPeer($pub);
}
$now = self::nowSql();
Database::pdo()->prepare(
'UPDATE remote_access_sessions SET status = ?, close_reason = ?, closed_at = ?, last_activity_at = ? WHERE session_id = ?'
@@ -300,6 +428,10 @@ final class RemoteAccessRepository {
if (!$device || !(int) ($device['whitelisted'] ?? 0)) {
return ['ok' => false, 'error' => 'device_not_whitelisted'];
}
$deviceCompanyId = (int) ($device['company_id'] ?? 0);
if (!Rbac::canAccessCompany($deviceCompanyId) || !Rbac::can('remote_access_operate', $deviceCompanyId)) {
return ['ok' => false, 'error' => 'forbidden'];
}
if ((string) ($device['opt_in_mode'] ?? self::OPT_NONE) !== self::OPT_WIREGUARD) {
return ['ok' => false, 'error' => 'device_not_opted_in_wg'];
}
@@ -333,7 +465,17 @@ final class RemoteAccessRepository {
self::ensureSchema();
$sql = 'SELECT * FROM remote_access_devices';
$params = [];
if ($companyId !== null && !Rbac::isGlobalAdmin()) {
if (!Rbac::isGlobalAdmin()) {
$allowed = Rbac::allowedCompanyIds();
if ($allowed === []) {
return [];
}
if ($allowed !== null) {
$placeholders = implode(',', array_fill(0, count($allowed), '?'));
$sql .= ' WHERE company_id IN (' . $placeholders . ')';
$params = $allowed;
}
} elseif ($companyId !== null) {
$sql .= ' WHERE company_id = ?';
$params[] = $companyId;
}
@@ -348,11 +490,16 @@ final class RemoteAccessRepository {
self::ensureSchema();
$sql = 'SELECT s.*, d.opt_in_mode, d.app_version AS device_app_version FROM remote_access_sessions s
LEFT JOIN remote_access_devices d ON d.device_id = s.device_id';
$where = [];
$params = [];
if ($statusFilter !== '') {
$sql .= ' WHERE s.status = ?';
$where[] = 's.status = ?';
$params[] = $statusFilter;
}
self::appendCompanyScopeSql('s.company_id', $where, $params);
if ($where !== []) {
$sql .= ' WHERE ' . implode(' AND ', $where);
}
$sql .= ' ORDER BY s.id DESC LIMIT 200';
$st = Database::pdo()->prepare($sql);
$st->execute($params);
@@ -362,8 +509,28 @@ final class RemoteAccessRepository {
/** @return list<array<string, mixed>> */
public static function listEvents(int $limit = 100): array {
self::ensureSchema();
$st = Database::pdo()->prepare('SELECT * FROM remote_access_events ORDER BY id DESC LIMIT ?');
$st->bindValue(1, max(1, min(500, $limit)), PDO::PARAM_INT);
$sql = 'SELECT e.* FROM remote_access_events e';
$where = [];
$params = [];
if (!Rbac::isGlobalAdmin()) {
$allowed = Rbac::allowedCompanyIds();
if ($allowed === []) {
return [];
}
if ($allowed !== null) {
$sql .= ' INNER JOIN remote_access_devices d ON d.device_id = e.device_id';
self::appendCompanyScopeSql('d.company_id', $where, $params);
}
}
if ($where !== []) {
$sql .= ' WHERE ' . implode(' AND ', $where);
}
$sql .= ' ORDER BY e.id DESC LIMIT ?';
$params[] = max(1, min(500, $limit));
$st = Database::pdo()->prepare($sql);
foreach ($params as $i => $v) {
$st->bindValue($i + 1, $v, is_int($v) ? PDO::PARAM_INT : PDO::PARAM_STR);
}
$st->execute();
return $st->fetchAll(PDO::FETCH_ASSOC) ?: [];
}
@@ -372,11 +539,15 @@ final class RemoteAccessRepository {
self::ensureSchema();
$device = self::findDevice($deviceId);
if (!$device) {
$companyId = Rbac::activeCompanyId() ?? Rbac::defaultCompanyId();
if (!Rbac::can('remote_access_admin', $companyId)) {
return false;
}
Database::pdo()->prepare(
'INSERT INTO remote_access_devices (device_id, company_id, whitelisted, notes, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)'
)->execute([
$deviceId,
Rbac::defaultCompanyId(),
$companyId,
$whitelisted ? 1 : 0,
$notes,
self::nowSql(),
@@ -384,6 +555,10 @@ final class RemoteAccessRepository {
]);
return true;
}
$companyId = (int) ($device['company_id'] ?? 0);
if (!Rbac::canAccessCompany($companyId) || !Rbac::can('remote_access_admin', $companyId)) {
return false;
}
Database::pdo()->prepare(
'UPDATE remote_access_devices SET whitelisted = ?, notes = COALESCE(?, notes), updated_at = ? WHERE device_id = ?'
)->execute([$whitelisted ? 1 : 0, $notes, self::nowSql(), $deviceId]);

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
/** Alpine BE WireGuard peer lifecycle (wg set) for remote-access sessions. */
final class WireGuardPeerProvisioner {
private function __construct() {}
public static function isEnabled(): bool {
return (bool) cfg('remote_access.provision_peers', true);
}
public static function interfaceName(): string {
return (string) cfg('remote_access.wg_interface', 'wg0');
}
public static function publicKeyFromPrivate(string $privateKey): string {
$priv = trim($privateKey);
if ($priv === '') {
return '';
}
$pub = trim((string) @shell_exec('echo ' . escapeshellarg($priv) . ' | wg pubkey 2>/dev/null') ?: '');
return $pub;
}
public static function addClientPeer(string $clientPublicKey, string $clientAddressCidr): bool {
if (!self::isEnabled()) {
return true;
}
$pub = trim($clientPublicKey);
if ($pub === '' || str_starts_with($pub, 'dev-placeholder-pub-')) {
return false;
}
$iface = self::interfaceName();
$allowed = trim($clientAddressCidr);
if ($allowed === '') {
$allowed = '10.66.66.2/32';
}
$cmd = 'wg set ' . escapeshellarg($iface)
. ' peer ' . escapeshellarg($pub)
. ' allowed-ips ' . escapeshellarg($allowed);
exec($cmd, $out, $code);
if ($code !== 0) {
error_log('WireGuardPeerProvisioner add failed: ' . implode(' ', $out));
}
return $code === 0;
}
public static function removeClientPeer(?string $clientPublicKey): void {
if (!self::isEnabled()) {
return;
}
$pub = trim((string) $clientPublicKey);
if ($pub === '' || str_starts_with($pub, 'dev-placeholder-pub-')) {
return;
}
$iface = self::interfaceName();
$cmd = 'wg set ' . escapeshellarg($iface)
. ' peer ' . escapeshellarg($pub)
. ' remove';
exec($cmd);
}
}

View File

@@ -46,6 +46,7 @@ require_once __DIR__ . '/UserRepository.php';
require_once __DIR__ . '/TicketRepository.php';
require_once __DIR__ . '/GraphRepository.php';
require_once __DIR__ . '/RemoteAccessRepository.php';
require_once __DIR__ . '/WireGuardPeerProvisioner.php';
require_once __DIR__ . '/AnalyticsHead.php';
function cfg(string $key, $default = null) {

View File

@@ -37,13 +37,19 @@
<?php if (($view ?? '') === 'remote_access'): ?>
<script src="<?= h(Auth::basePath()) ?>/assets/js/remote_access.js" defer></script>
<?php endif; ?>
<?php if (($view ?? '') === 'rbac'): ?>
<script src="<?= h(Auth::basePath()) ?>/assets/js/rbac_admin.js" defer></script>
<?php endif; ?>
<?php AnalyticsHead::render('crashes'); ?>
</head>
<body data-base-path="<?= h(Auth::basePath()) ?>"
data-view="<?= h($view ?? 'home') ?>"
data-can-tag-edit="<?= Auth::canEditTags() ? '1' : '0' ?>"
<?= (($view ?? '') === 'report' && !empty($report['id'])) ? ' data-report-id="' . (int) $report['id'] . '"' : '' ?>
<?= (($view ?? '') === 'ticket' && !empty($ticket['id'])) ? ' data-ticket-id="' . (int) $ticket['id'] . '"' : '' ?>>
<?= (($view ?? '') === 'ticket' && !empty($ticket['id'])) ? ' data-ticket-id="' . (int) $ticket['id'] . '"' : '' ?>
<?= Rbac::can('remote_access_operate') ? ' data-can-ra-operate="1"' : '' ?>
<?= Rbac::can('remote_access_admin') ? ' data-can-ra-admin="1"' : '' ?>
<?= Rbac::isRoot() ? ' data-can-rbac-root="1"' : '' ?>>
<header class="top-menu" hidden aria-hidden="true"></header>
<div class="shell">
<nav class="nav-pane" id="nav-pane" aria-label="Console navigation">
@@ -101,6 +107,17 @@
</a>
</li>
<?php endif; ?>
<?php if (Rbac::canManageRbac()): ?>
<li>
<a href="<?= h(Auth::basePath()) ?>/?view=rbac"
class="nav-link <?= ($view ?? '') === 'rbac' ? 'active' : '' ?>"
aria-label="Access control"
title="Access control">
<span class="nav-icon nav-icon--tickets" aria-hidden="true"></span>
<span class="nav-label">Access</span>
</a>
</li>
<?php endif; ?>
<li>
<a href="/app/androidcast_project/build/"
class="nav-link"
@@ -144,6 +161,12 @@
<li><span data-i18n="home.upload_api">Upload API:</span> <code><?= h(Auth::basePath()) ?>/api/upload.php</code></li>
<li><span data-i18n="home.schema">Schema:</span> <code>schema_version: 1</code></li>
<li><span data-i18n="home.ticket_upload_api">Ticket upload API:</span> <code><?= h(Auth::basePath()) ?>/api/ticket_upload.php</code></li>
<?php if (Rbac::can('remote_access_view')): ?>
<li>Remote access dashboard: <a href="<?= h(Auth::basePath()) ?>/?view=remote_access">Remote access</a> · API <code><?= h(Auth::basePath()) ?>/api/remote_access.php</code></li>
<?php endif; ?>
<?php if (Rbac::canManageRbac()): ?>
<li>Access control: <a href="<?= h(Auth::basePath()) ?>/?view=rbac">Users &amp; roles</a> · API <code><?= h(Auth::basePath()) ?>/api/rbac.php</code></li>
<?php endif; ?>
</ul>
<?php elseif (($view ?? '') === 'report' && !empty($report)): ?>
<?php require __DIR__ . '/report_detail.php'; ?>
@@ -357,6 +380,10 @@
<a href="/app/androidcast_project/">Hub</a>
</nav>
<p id="ra-status" class="reports-status muted" aria-live="polite">Loading…</p>
<p class="muted graphs-footnote">
Device poll: <code><?= h(Auth::basePath()) ?>/api/heartbeat.php</code> (<code>type: ra</code>).
No nginx changes required — same crashes vhost. Configure <code>remote_access.*</code> in BE <code>config.php</code>.
</p>
<section class="graphs-scope">
<h2 class="graphs-scope-title">Whitelisted devices</h2>
@@ -436,6 +463,35 @@
</div>
</section>
</div>
<?php elseif (($view ?? '') === 'rbac'): ?>
<div id="rbac-app" class="reports-app">
<div class="toolbar reports-toolbar">
<h1>Access control</h1>
</div>
<p id="rbac-status" class="reports-status muted" aria-live="polite">Loading…</p>
<p id="rbac-scope-hint" class="muted graphs-footnote"></p>
<section class="graphs-scope">
<h2 class="graphs-scope-title">Users &amp; company roles</h2>
<div class="reports-table-wrap">
<table class="data-table reports-table--cols" id="rbac-users-table">
<thead>
<tr>
<th scope="col">User</th>
<th scope="col">Global role</th>
<th scope="col">Company</th>
<th scope="col">Company role</th>
<th scope="col">Privilege set</th>
</tr>
</thead>
<tbody id="rbac-users-tbody"></tbody>
</table>
</div>
</section>
<p class="muted graphs-footnote">
API: <code><?= h(Auth::basePath()) ?>/api/rbac.php</code> ·
Root edits global roles; company owner/admin edits memberships and remote-access privilege sets.
</p>
</div>
<?php endif; ?>
</main>
</div>

View File

@@ -16,4 +16,4 @@ i need orchestration for the whole project and project parts. if you need to wri
2) update the existing infra from previous run
anyway, finally it should run, ideally on any devices
the side branch for it is 'feature/20260527_orchestration' forked from the current stable 'next'. do preparations in parallel, ill request SVG landing changes over 'next'
orchestration lives on next in ./orchestration/ (feature/20260527_orchestration merged and removed 2026-06-04). SVG landing changes continue on next / feature branches.

View File

@@ -2,17 +2,46 @@
## Footer (`footer.php`)
Single copyright footer for hub, crashes, tickets, graphs, and builder consoles.
Flexible site-wide footer for hub, crashes, tickets, graphs, and builder consoles.
**Configure:** edit `footer.config.php` (copy from `footer.config.example.php`).
### Layout
```
┌─────────────────────────────────────────────┐
│ top (optional, full width) │
├──────────────────────────┬──────────────────┤
│ left (optional / auto) │ right (optional) │
├──────────────────────────┴──────────────────┤
│ bottom (optional, full width) │
└─────────────────────────────────────────────┘
```
When `left` is null, the left column is built from `copyright_symbol`, `holder`, and year settings.
| Key | Purpose |
|-----|---------|
| `holder` | Copyright name |
| `copyright_symbol` | `(c)` or `©` |
| `show_year` | Append year |
| `year` | Fixed year, or `null` for current |
| `use_i18n` | `data-i18n` for consoles with `i18n.js` |
| `top` | Full-width line above main row (`null` = hidden) |
| `left` | Main left text; `null` = auto copyright line |
| `right` | Main right text (e.g. build id, links) |
| `bottom` | Full-width line below main row |
| `holder` | Copyright name (default left) |
| `copyright_symbol` | `©` (U+00A9) or empty |
| `show_year` | Append year / range on default left |
| `year` | Fixed end year, or `null` for current |
| `year_start` | If set (e.g. `2026`), shows `2026{current}` |
| `use_i18n` | `data-i18n` on auto-built left column |
| `footer_class` | Extra CSS class (always adds `platform-footer`) |
**Per-page override** when rendering:
```php
platform_render_footer([
'right' => 'OTA: staging',
'bottom' => 'Internal only',
]);
```
**In PHP views:** `<?php platform_render_footer(); ?>` (loaded via each app `bootstrap.php`).

View File

@@ -4,19 +4,34 @@ declare(strict_types=1);
/**
* Site-wide footer (hub, crashes, tickets, graphs, builder).
* Copy to footer.config.php and edit; footer.config.php is optional (defaults apply).
*
* Layout (empty/null row = hidden):
* top — full-width line above main row
* main — left | right (flex); invisible gap between columns
* bottom — full-width line below main row
*
* Left column: set `left` to a custom string, or leave null to build from holder + year below.
*/
return [
// Visible copyright holder (also used in i18n {holder} if you customize strings).
// --- Optional rows (static or PHP-dynamic strings) ---
'top' => null,
'left' => null,
'right' => null,
'bottom' => null,
// --- Default left column when `left` is null ---
'holder' => 'Anton Afanaasyeu',
// Prefix before the name: "(c)", "©", or "".
'copyright_symbol' => '(c)',
// Append current calendar year (or fixed year below).
'copyright_symbol' => "\u{00A9}",
'show_year' => true,
// null = (int) date('Y') at render time.
// null = current calendar year at render time (end of range).
'year' => null,
// CSS class on <footer>.
// Set e.g. 2026 for "2026{current}" (en dash). Omit for single year only.
'year_start' => "2026 - current",
# 'year_start' => null,
// CSS class on <footer> (platform-footer layout classes are always added).
'footer_class' => 'bottom-bar',
// When true, consoles with i18n.js replace text via data-i18n / data-year.
// When true, consoles with i18n.js replace default left text via data-i18n / data-year.
'use_i18n' => true,
'i18n_key' => 'footer.copyright',
];

View File

@@ -6,6 +6,11 @@ declare(strict_types=1);
*
* Configure: examples/platform/footer.config.php (see footer.config.example.php).
* Usage: <?php platform_render_footer(); ?> or platform_render_footer(['use_i18n' => false]);
*
* Layout (each row omitted when empty):
* [ top — full width ]
* [ left | right ] — main row (copyright defaults on left when left unset)
* [ bottom — full width ]
*/
function platform_footer_h(mixed $value): string
@@ -28,7 +33,8 @@ function platform_footer_config(): array
return is_array($loaded) ? $loaded : [];
}
function platform_footer_year(array $cfg): int
/** @param array<string, mixed> $cfg */
function platform_footer_year_end(array $cfg): int
{
if (array_key_exists('year', $cfg) && $cfg['year'] !== null) {
return (int) $cfg['year'];
@@ -37,9 +43,34 @@ function platform_footer_year(array $cfg): int
return (int) date('Y');
}
function platform_footer_plain_text(array $cfg, int $year): string
/**
* Year label: single year or range (e.g. 20262026) when year_start is set.
*
* @param array<string, mixed> $cfg
*/
function platform_footer_year_label(array $cfg): string
{
$symbol = trim((string) ($cfg['copyright_symbol'] ?? '(c)'));
$end = platform_footer_year_end($cfg);
$start = isset($cfg['year_start']) ? (int) $cfg['year_start'] : 0;
if ($start > 0) {
if ($start === $end) {
return (string) $start;
}
return $start . "\u{2013}" . $end;
}
return (string) $end;
}
/**
* Default left line: © holder[, year|year-range].
*
* @param array<string, mixed> $cfg
*/
function platform_footer_default_left(array $cfg): string
{
$symbol = trim((string) ($cfg['copyright_symbol'] ?? "\u{00A9}"));
$holder = trim((string) ($cfg['holder'] ?? ''));
$showYear = (bool) ($cfg['show_year'] ?? true);
$parts = [];
@@ -50,36 +81,117 @@ function platform_footer_plain_text(array $cfg, int $year): string
$parts[] = $holder;
}
$line = implode(' ', $parts);
if ($showYear && $line !== '') {
return $line . ', ' . $year;
if (!$showYear) {
return $line;
}
if ($showYear && $line === '') {
return (string) $year;
$yearLabel = platform_footer_year_label($cfg);
if ($line === '') {
return $yearLabel;
}
return $line;
return $line . ', ' . $yearLabel;
}
/**
* @param array{use_i18n?: bool, extra_class?: string} $opts
* Resolve optional row text (null/empty = omit row). Supports per-render overrides in $opts.
*
* @param array<string, mixed> $cfg
* @param array<string, mixed> $opts
*/
function platform_footer_field(array $cfg, array $opts, string $key): ?string
{
if (array_key_exists($key, $opts)) {
$v = $opts[$key];
if ($v === null || $v === false) {
return null;
}
$t = trim((string) $v);
return $t === '' ? null : $t;
}
if (!array_key_exists($key, $cfg)) {
return null;
}
$v = $cfg[$key];
if ($v === null || $v === false) {
return null;
}
$t = trim((string) $v);
return $t === '' ? null : $t;
}
/**
* @param array<string, mixed> $cfg
* @param array<string, mixed> $opts
*/
function platform_footer_resolve_left(array $cfg, array $opts): ?string
{
$custom = platform_footer_field($cfg, $opts, 'left');
if ($custom !== null) {
return $custom;
}
$built = platform_footer_default_left($cfg);
return $built === '' ? null : $built;
}
/** @deprecated Use platform_footer_default_left(); kept for scripts/tests. */
function platform_footer_plain_text(array $cfg, int $year): string
{
$cfg = array_merge($cfg, ['year' => $year]);
return platform_footer_default_left($cfg);
}
/**
* @param array{use_i18n?: bool, extra_class?: string, top?: ?string, left?: ?string, right?: ?string, bottom?: ?string} $opts
*/
function platform_render_footer(array $opts = []): void
{
$cfg = platform_footer_config();
$year = platform_footer_year($cfg);
$text = platform_footer_plain_text($cfg, $year);
$class = (string) ($opts['extra_class'] ?? ($cfg['footer_class'] ?? 'bottom-bar'));
$useI18n = $opts['use_i18n'] ?? (bool) ($cfg['use_i18n'] ?? true);
$i18nKey = (string) ($cfg['i18n_key'] ?? 'footer.copyright');
$top = platform_footer_field($cfg, $opts, 'top');
$left = platform_footer_resolve_left($cfg, $opts);
$right = platform_footer_field($cfg, $opts, 'right');
$bottom = platform_footer_field($cfg, $opts, 'bottom');
if ($useI18n && $i18nKey !== '') {
echo '<footer class="' . platform_footer_h($class) . '"'
. ' data-i18n="' . platform_footer_h($i18nKey) . '"'
. ' data-year="' . $year . '">'
. platform_footer_h($text)
. '</footer>';
return;
$baseClass = (string) ($opts['extra_class'] ?? ($cfg['footer_class'] ?? 'bottom-bar'));
$classes = trim($baseClass . ' platform-footer');
if ($top === null && $bottom === null && $right === null) {
$classes .= ' platform-footer--single-row';
}
echo '<footer class="' . platform_footer_h($class) . '">' . platform_footer_h($text) . '</footer>';
$useI18n = $opts['use_i18n'] ?? (bool) ($cfg['use_i18n'] ?? true);
$i18nKey = (string) ($cfg['i18n_key'] ?? 'footer.copyright');
$leftUsesDefault = platform_footer_field($cfg, $opts, 'left') === null;
$yearEnd = platform_footer_year_end($cfg);
echo '<footer class="' . platform_footer_h($classes) . '">';
if ($top !== null) {
echo '<div class="platform-footer__top">' . platform_footer_h($top) . '</div>';
}
if ($left !== null || $right !== null) {
echo '<div class="platform-footer__main">';
if ($left !== null) {
echo '<div class="platform-footer__left"';
if ($useI18n && $i18nKey !== '' && $leftUsesDefault) {
echo ' data-i18n="' . platform_footer_h($i18nKey) . '"'
. ' data-year="' . platform_footer_h(platform_footer_year_label($cfg)) . '"'
. ' data-year-end="' . $yearEnd . '"';
}
echo '>' . platform_footer_h($left) . '</div>';
}
if ($right !== null) {
echo '<div class="platform-footer__right">' . platform_footer_h($right) . '</div>';
}
echo '</div>';
}
if ($bottom !== null) {
echo '<div class="platform-footer__bottom">' . platform_footer_h($bottom) . '</div>';
}
echo '</footer>';
}