1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 09:17:51 +03:00
This commit is contained in:
Anton Afanasyeu
2026-05-23 00:47:31 +02:00
parent 144ddec367
commit 0d9cf0cf7c
21 changed files with 1467 additions and 148 deletions

View File

@@ -41,6 +41,32 @@ php -S 127.0.0.1:8080 -t public
Open http://127.0.0.1:8080/ — login **admin** / **admin**.
## RBAC (phase 1)
Multi-tenant foundation (no admin UI yet):
- **Global roles** on `users.role`: `root`, `platform_admin` (legacy `admin` works), `viewer`
- **Companies** + **company_memberships** (`owner` / `admin` / `member`)
- **Devices** auto-registered on upload; reports get `company_id` + `device_id`
- Default company slug `default` — existing rows migrate on first request (`Database::ensureRbacSchema()`)
Config (`config.php`):
```php
'rbac' => [
'default_company_slug' => 'default',
'default_company_id' => 1,
],
```
Permission checks: `Rbac::can('edit_tags', $companyId)` (used by `Auth::canEditTags()`).
Manual SQL: `sql/migrations/001_rbac_companies.sql` (MariaDB). Re-login after deploy so session includes company memberships.
## UI languages
JSON catalogs under `public/assets/i18n/` (`en.json`, `ru.json`) — same key style as Android `strings.xml`.
Default: English. User choice is stored in `localStorage` (`crash_console_lang`). Language selector is in the reports toolbar next to **Theme**, and in the nav on other pages (login included).
Upload test payload:
```bash

View File

@@ -32,4 +32,9 @@ return [
'session_name' => 'ac_crash_sess',
// When true, upload API may include a short error hint (do not enable on public prod long-term)
'debug' => false,
// Phase 1 RBAC: anonymous uploads and legacy rows attach to this company
'rbac' => [
'default_company_slug' => 'default',
'default_company_id' => 1,
],
];

Binary file not shown.

View File

@@ -22,14 +22,10 @@ if ($method === 'GET') {
'id' => $id,
'tags' => $report['tags'] ?? [],
'presets' => ReportRepository::listTagPresets(),
'can_edit' => Auth::canEditTags(),
'can_edit' => Auth::canEditTags((int) ($report['company_id'] ?? 0)),
]);
}
if (!Auth::canEditTags()) {
json_out(['ok' => false, 'error' => 'forbidden'], 403);
}
if ($method !== 'PUT' && $method !== 'POST') {
json_out(['ok' => false, 'error' => 'method not allowed'], 405);
}
@@ -54,6 +50,10 @@ if (!$report) {
json_out(['ok' => false, 'error' => 'not found'], 404);
}
if (!Auth::canEditTags((int) ($report['company_id'] ?? 0))) {
json_out(['ok' => false, 'error' => 'forbidden'], 403);
}
try {
ReportRepository::setCustomTags($id, $tagsIn);
$updated = ReportRepository::getById($id);

View File

@@ -435,6 +435,20 @@ a:hover { text-decoration: underline; }
.stack { background: var(--surface2); padding: 16px; border-radius: 8px; overflow-x: auto; font-size: 13px; line-height: 1.5; }
.reports-toolbar .toolbar-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.toolbar-select { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--muted); }
.nav-locale {
padding: 10px 12px 6px;
border-top: 1px solid var(--border);
}
.nav-locale--toolbar-dup { display: none; }
.toolbar-select--nav { width: 100%; }
.toolbar-select--nav select { flex: 1; min-width: 0; }
.login-locale {
position: fixed;
top: 12px;
right: 12px;
z-index: 5;
}
.login-page .login-card { margin-top: 2.5rem; }
.toolbar-select select {
padding: 6px 8px;
border-radius: 6px;
@@ -683,16 +697,32 @@ a:hover { text-decoration: underline; }
.detail-filter-link--tag {
display: inline-block;
padding: 2px 8px;
margin: 0;
border-radius: 999px;
background: var(--surface2);
border: 1px solid var(--border);
font-size: 12px;
border: none;
font-size: 11px;
font-weight: 600;
line-height: 1.4;
color: #fff;
background: var(--tag-bg, #5c6b82);
}
.detail-filter-link--tag:hover {
border-color: var(--accent);
.detail-filter-link--tag:hover,
.detail-filter-link--tag:focus-visible {
color: #fff;
background: var(--tag-bg, #5c6b82);
filter: brightness(1.12);
outline: none;
}
.detail-abi-row,
.detail-meta-row {
line-height: 1.8;
}
.detail-meta-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.detail-abi-row { line-height: 1.8; }
.exc { color: var(--danger); font-weight: 600; }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; margin: 20px 0; }

View File

@@ -0,0 +1,116 @@
{
"app_name": "Android Cast Crashes",
"nav.home": "Home",
"nav.reports": "Reports",
"nav.logout": "Logout",
"nav.toggle": "Navigation",
"theme.label": "Theme",
"theme.dark": "Dark",
"theme.light": "Light",
"lang.label": "Language",
"lang.en": "English",
"lang.ru": "Russian",
"home.title": "Crash console",
"home.intro": "Anonymous Android Cast crash ingest is active. Use Reports to analyze grouped fingerprints.",
"home.upload_api": "Upload API:",
"home.schema": "Schema:",
"reports.title": "Crash reports",
"reports.by_time": "By time",
"reports.grouped": "Grouped",
"reports.per_page": "Per page",
"reports.search": "Search",
"reports.search_placeholder": "Keywords (exceptions, devices, stack traces…)",
"reports.clear": "Clear",
"reports.loading": "Loading…",
"reports.empty": "No reports yet.",
"reports.status_showing": "Showing {from}{to} of {total}",
"reports.invalid_response": "Invalid response",
"reports.load_failed": "Load failed",
"reports.network_error": "Network error",
"reports.mode_disabled_filter": "Disabled while a filter is active",
"filter.banner_similar": "Similar reports for #{id}",
"filter.banner_device": "Device: “{value}”",
"filter.banner_abi": "ABI: {value}",
"filter.banner_sdk": "SDK {value}",
"filter.banner_os": "Android {value}",
"filter.banner_app": "App {value}",
"filter.banner_build": "Build {value}",
"filter.banner_search": "Search: “{value}”",
"filter.clear": "Clear filters",
"pagination.previous": "← Previous",
"pagination.next": "Next →",
"pagination.previous_range": "← Previous ({before}/{through})",
"pagination.next_range": "Next ({start}/{end}) →",
"col.device": "Device",
"col.app": "App",
"col.os": "OS",
"col.os_ver": "OS ver",
"col.received": "Received",
"col.rating": "Rating",
"col.count": "Count",
"col.fingerprint": "Fingerprint",
"col.type": "Type",
"col.last_generated": "Last generated",
"col.last_received": "Last received",
"col.tags": "Tags",
"col.expand": "Expand",
"col.drag": "Drag column",
"col.drag_reorder": "Drag column to reorder",
"row.show_summary": "Show summary",
"row.open_report": "Click to open full report",
"row.edit_tags": "Edit tags",
"tag.new": "new",
"tag.java": "Java",
"tag.ndk": "NDK",
"tag.android": "Android",
"tag.edit_title": "Edit tags",
"tag.close": "Close",
"tag.label": "Label",
"tag.color": "Color",
"tag.add": "Add",
"tag.add_tag": "Add tag",
"tag.save": "Save",
"tag.save_tags": "Save tags",
"tag.suggestions": "Suggestions:",
"tag.placeholder": "e.g. regression",
"tag.hint_auto": "Auto tags (NDK / Java / Android / new) appear in the list. Add custom labels below.",
"tag.hint_admin": "Sign in as admin to edit custom tags.",
"tag.none_custom": "No custom tags yet.",
"tag.none_preview": "No custom tags",
"tag.remove": "Remove tag",
"tag.saving": "Saving…",
"tag.saved": "Saved",
"tag.save_failed": "Save failed",
"detail.back": "Back to list",
"detail.title": "Crash report",
"detail.report_meta": "Report {id} · {type}",
"detail.tags": "Tags",
"detail.device": "Device",
"detail.app": "App",
"detail.timing": "Timing",
"detail.abis": "ABIs:",
"detail.build": "Build:",
"detail.generated": "Generated:",
"detail.received": "Received:",
"detail.fingerprint": "Fingerprint:",
"detail.filter_device": "Show reports for similar devices",
"detail.filter_os": "Show reports on this OS",
"detail.filter_version": "Show reports for this app version",
"detail.filter_build": "Show reports from this build",
"detail.java_exception": "Java exception",
"detail.native_crash": "Native crash",
"detail.find_similar": "Find similar reports",
"detail.thread": "Thread:",
"detail.signal": "Signal:",
"detail.session_context": "Session context (crash)",
"detail.raw_json": "Raw JSON",
"login.sign_in_hint": "Sign in to view anonymous crash reports",
"login.username": "Username",
"login.password": "Password",
"login.submit": "Sign in",
"login.hint_default": "Default: admin / admin",
"login.register": "Register",
"login.register_soon": "(coming soon)",
"login.error": "Invalid credentials",
"footer.copyright": "(c) Anton Afanaasyeu, {year}"
}

View File

@@ -0,0 +1,115 @@
{
"app_name": "Сбои Android Cast",
"nav.home": "Главная",
"nav.reports": "Отчёты",
"nav.logout": "Выход",
"nav.toggle": "Навигация",
"theme.label": "Тема",
"theme.dark": "Тёмная",
"theme.light": "Светлая",
"lang.label": "Язык",
"lang.en": "English",
"lang.ru": "Русский",
"home.title": "Консоль сбоев",
"home.intro": "Приём анонимных отчётов Android Cast активен. Откройте «Отчёты» для анализа сгруппированных отпечатков.",
"home.upload_api": "API загрузки:",
"home.schema": "Схема:",
"reports.title": "Отчёты о сбоях",
"reports.by_time": "По времени",
"reports.grouped": "Группы",
"reports.per_page": "На странице",
"reports.search": "Поиск",
"reports.search_placeholder": "Ключевые слова (исключения, устройства, стеки…)",
"reports.clear": "Очистить",
"reports.loading": "Загрузка…",
"reports.empty": "Отчётов пока нет.",
"reports.status_showing": "Показано {from}{to} из {total}",
"reports.invalid_response": "Некорректный ответ",
"reports.load_failed": "Ошибка загрузки",
"reports.network_error": "Сетевая ошибка",
"reports.mode_disabled_filter": "Недоступно при активном фильтре",
"filter.banner_similar": "Похожие отчёты для №{id}",
"filter.banner_device": "Устройство: «{value}»",
"filter.banner_abi": "ABI: {value}",
"filter.banner_sdk": "SDK {value}",
"filter.banner_os": "Android {value}",
"filter.banner_app": "Приложение {value}",
"filter.banner_build": "Сборка {value}",
"filter.banner_search": "Поиск: «{value}»",
"filter.clear": "Сбросить фильтры",
"pagination.previous": "← Назад",
"pagination.next": "Вперёд →",
"pagination.previous_range": "← Назад ({before}/{through})",
"pagination.next_range": "Вперёд ({start}/{end}) →",
"col.device": "Устройство",
"col.app": "Приложение",
"col.os": "ОС",
"col.os_ver": "Версия ОС",
"col.received": "Получен",
"col.rating": "Рейтинг",
"col.count": "Кол-во",
"col.fingerprint": "Отпечаток",
"col.type": "Тип",
"col.last_generated": "Создан",
"col.last_received": "Получен",
"col.tags": "Метки",
"col.expand": "Развернуть",
"col.drag": "Перетащить столбец",
"col.drag_reorder": "Перетащите для смены порядка столбцов",
"row.show_summary": "Показать сводку",
"row.open_report": "Нажмите, чтобы открыть полный отчёт",
"row.edit_tags": "Редактировать метки",
"tag.new": "новый",
"tag.java": "Java",
"tag.ndk": "NDK",
"tag.android": "Android",
"tag.edit_title": "Редактирование меток",
"tag.close": "Закрыть",
"tag.label": "Название",
"tag.color": "Цвет",
"tag.add": "Добавить",
"tag.add_tag": "Добавить метку",
"tag.save": "Сохранить",
"tag.save_tags": "Сохранить метки",
"tag.suggestions": "Подсказки:",
"tag.hint_auto": "Автометки (NDK / Java / Android / новый) в списке. Ниже можно добавить свои.",
"tag.hint_admin": "Войдите как администратор, чтобы редактировать метки.",
"tag.none_custom": "Пользовательских меток пока нет.",
"tag.none_preview": "Нет пользовательских меток",
"tag.remove": "Удалить метку",
"tag.saving": "Сохранение…",
"tag.saved": "Сохранено",
"tag.save_failed": "Не удалось сохранить",
"detail.back": "К списку",
"detail.title": "Отчёт о сбое",
"detail.report_meta": "Отчёт {id} · {type}",
"detail.tags": "Метки",
"detail.device": "Устройство",
"detail.app": "Приложение",
"detail.timing": "Время",
"detail.abis": "ABI:",
"detail.build": "Сборка:",
"detail.generated": "Создан:",
"detail.received": "Получен:",
"detail.fingerprint": "Отпечаток:",
"detail.filter_device": "Показать отчёты для похожих устройств",
"detail.filter_os": "Показать отчёты с этой ОС",
"detail.filter_version": "Показать отчёты для этой версии приложения",
"detail.filter_build": "Показать отчёты этой сборки",
"detail.java_exception": "Исключение Java",
"detail.native_crash": "Нативный сбой",
"detail.find_similar": "Найти похожие отчёты",
"detail.thread": "Поток:",
"detail.signal": "Сигнал:",
"detail.session_context": "Контекст сессии (сбой)",
"detail.raw_json": "Исходный JSON",
"login.sign_in_hint": "Войдите для просмотра анонимных отчётов о сбоях",
"login.username": "Имя пользователя",
"login.password": "Пароль",
"login.submit": "Войти",
"login.hint_default": "По умолчанию: admin / admin",
"login.register": "Регистрация",
"login.register_soon": "(скоро)",
"login.error": "Неверные учётные данные",
"footer.copyright": "(c) Anton Afanaasyeu, {year}"
}

View File

@@ -1,4 +1,8 @@
(function () {
function t(key, params) {
return window.CrashI18n ? window.CrashI18n.t(key, params) : key;
}
const STORAGE = {
theme: 'crash_console_theme',
perPage: 'crash_console_per_page',
@@ -21,20 +25,17 @@
tags: 128,
};
const TAG_NEW = { label: 'new', bg: '#3d8bfd' };
const CRASH_KIND_TAGS = {
native: { label: 'NDK', bg: '#b45309' },
java: { label: 'Java', bg: '#6d28d9' },
android: { label: 'Android', bg: '#047857' },
};
function tagNew() {
return { label: t('tag.new'), bg: '#3d8bfd' };
}
const LIST_COLUMNS = [
{ key: 'device_model', label: 'Device' },
{ key: 'app_version', label: 'App' },
{ key: 'os_name', label: 'OS' },
{ key: 'os_version', label: 'OS ver' },
{ key: 'received_at_ms', label: 'Received', fmt: 'time' },
{ key: 'rating', label: 'Rating', fmt: 'stars' },
{ key: 'device_model', labelKey: 'col.device' },
{ key: 'app_version', labelKey: 'col.app' },
{ key: 'os_name', labelKey: 'col.os' },
{ key: 'os_version', labelKey: 'col.os_ver' },
{ key: 'received_at_ms', labelKey: 'col.received', fmt: 'time' },
{ key: 'rating', labelKey: 'col.rating', fmt: 'stars' },
];
const SORT_API_MAP = {
@@ -45,14 +46,19 @@
};
const GROUP_COLUMNS = [
{ key: 'cnt', label: 'Count' },
{ key: 'rating', label: 'Rating', fmt: 'stars' },
{ key: 'fingerprint', label: 'Fingerprint', fmt: 'fp' },
{ key: 'crash_type', label: 'Type' },
{ key: 'last_generated_ms', label: 'Last generated', fmt: 'time' },
{ key: 'last_received_ms', label: 'Last received', fmt: 'time' },
{ key: 'cnt', labelKey: 'col.count' },
{ key: 'rating', labelKey: 'col.rating', fmt: 'stars' },
{ key: 'fingerprint', labelKey: 'col.fingerprint', fmt: 'fp' },
{ key: 'crash_type', labelKey: 'col.type' },
{ key: 'last_generated_ms', labelKey: 'col.last_generated', fmt: 'time' },
{ key: 'last_received_ms', labelKey: 'col.last_received', fmt: 'time' },
];
function colLabel(col) {
if (!col) return '';
return t(col.labelKey || 'col.' + col.key);
}
function basePath() {
return document.body.getAttribute('data-base-path') || '';
}
@@ -207,8 +213,15 @@
}
function crashKindTag(crashType) {
const t = String(crashType || '').toLowerCase();
return CRASH_KIND_TAGS[t] || { label: t || '?', bg: '#5c6b82' };
const kind = String(crashType || '').toLowerCase();
const map = {
native: { labelKey: 'tag.ndk', bg: '#b45309' },
java: { labelKey: 'tag.java', bg: '#6d28d9' },
android: { labelKey: 'tag.android', bg: '#047857' },
};
const hit = map[kind];
if (hit) return { label: t(hit.labelKey), bg: hit.bg };
return { label: kind || '?', bg: '#5c6b82' };
}
function renderTagPills(tags) {
@@ -233,7 +246,7 @@
const viewed = row.viewed || getViewedIds().has(Number(row.id));
const sinceVisit = Number(row.received_at_ms) > lastVisitMs();
if (!viewed && (lastVisitMs() === 0 || sinceVisit)) {
tags.push(TAG_NEW);
tags.push(tagNew());
}
}
if (Array.isArray(row.tags)) {
@@ -333,7 +346,11 @@
function columnLabelByKey(key) {
const c = LIST_COLUMNS.find((col) => col.key === key);
return c ? c.label : key;
if (c) return colLabel(c);
const g = GROUP_COLUMNS.find((col) => col.key === key);
if (g) return colLabel(g);
if (key === 'tags') return t('col.tags');
return key;
}
function clearColumnDropMarkers(thead) {
@@ -708,7 +725,9 @@
thDragAttrs =
' draggable="true" data-col-key="' +
escapeHtml(c.key) +
'" title="Drag column to reorder"';
'" title="' +
escapeHtml(t('col.drag_reorder')) +
'"';
}
html +=
'<th class="' +
@@ -722,9 +741,11 @@
' scope="col"><span class="th-inner">' +
(grouped
? ''
: '<span class="th-drag" aria-hidden="true" title="Drag column">⋮⋮</span>') +
: '<span class="th-drag" aria-hidden="true" title="' +
escapeHtml(t('col.drag')) +
'">⋮⋮</span>') +
'<span class="th-label">' +
escapeHtml(c.label) +
escapeHtml(colLabel(c)) +
'</span> <span class="sort-ind" aria-hidden="true">' +
arrow +
'</span></span>' +
@@ -820,7 +841,9 @@
tbody.innerHTML =
'<tr><td colspan="' +
colspan() +
'" class="muted">No reports yet.</td></tr>';
'" class="muted">' +
escapeHtml(t('reports.empty')) +
'</td></tr>';
return;
}
let html = '';
@@ -838,7 +861,9 @@
html +=
'<td class="report-tree-cell"><button type="button" class="report-tree-toggle" aria-expanded="false" aria-controls="' +
briefId +
'" title="Show summary"><span class="report-tree-arrow" aria-hidden="true"></span></button></td>';
'" title="' +
escapeHtml(t('row.show_summary')) +
'"><span class="report-tree-arrow" aria-hidden="true"></span></button></td>';
cols.forEach((c) => {
if (c.key === 'cnt') {
html += '<td><span class="badge">' + escapeHtml(row.cnt) + '</span></td>';
@@ -890,7 +915,7 @@
function load() {
if (loading) return;
loading = true;
statusEl.textContent = 'Loading';
statusEl.textContent = t('reports.loading');
let qs =
'page=' +
page +
@@ -1129,7 +1154,7 @@
html +=
'<button type="button" class="tag-remove" data-remove-id="' +
escapeHtml(tag.id) +
'" aria-label="Remove tag">×</button>';
'" aria-label="' + escapeHtml(t('tag.remove')) + '">×</button>';
}
return html + '</span>';
}
@@ -1249,7 +1274,7 @@
if (els.saveBtn) {
els.saveBtn.addEventListener('click', () => {
setStatus('Saving', true);
setStatus(t('tag.saving'), true);
const xhr = new XMLHttpRequest();
xhr.open('POST', basePath() + '/api/report_tags.php', true);
xhr.setRequestHeader('Content-Type', 'application/json');
@@ -1271,7 +1296,7 @@
if (cfg.onSaved) cfg.onSaved(state.tags, state.reportId);
};
xhr.onerror = function () {
setStatus('Network error', false);
setStatus(t('reports.network_error'), false);
};
xhr.send(JSON.stringify({ id: state.reportId, tags: state.tags }));
});
@@ -1418,12 +1443,29 @@
});
}
document.addEventListener('DOMContentLoaded', () => {
function boot() {
initTheme();
initNav();
initTagModal();
initReportDetail();
initTagEditorDetail();
initReportsApp();
});
}
function onReady(fn) {
const run = () => {
if (window.CrashI18n && window.CrashI18n.ready) {
window.CrashI18n.ready.then(fn);
} else {
fn();
}
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', run);
} else {
run();
}
}
onReady(boot);
})();

View File

@@ -0,0 +1,168 @@
/**
* Crash console i18n — JSON catalogs (en default), same key style as Android strings.xml.
*/
(function (global) {
const STORAGE_KEY = 'crash_console_lang';
const SUPPORTED = ['en', 'ru'];
const DEFAULT_LANG = 'en';
let lang = DEFAULT_LANG;
let catalog = {};
let fallback = {};
let readyResolve;
const ready = new Promise((resolve) => {
readyResolve = resolve;
});
function basePath() {
const body = document.body;
return body ? body.getAttribute('data-base-path') || '' : '';
}
function storedLang() {
try {
const v = localStorage.getItem(STORAGE_KEY);
return SUPPORTED.includes(v) ? v : DEFAULT_LANG;
} catch {
return DEFAULT_LANG;
}
}
function persistLang(code) {
try {
localStorage.setItem(STORAGE_KEY, code);
} catch { /* ignore */ }
}
function fetchCatalog(code) {
const url = basePath() + '/assets/i18n/' + encodeURIComponent(code) + '.json';
return fetch(url, { credentials: 'same-origin' }).then((res) => {
if (!res.ok) throw new Error('i18n load failed: ' + code);
return res.json();
});
}
/**
* @param {string} key
* @param {Record<string, string|number>|undefined} params
*/
function t(key, params) {
let s = catalog[key] ?? fallback[key] ?? key;
if (params) {
Object.keys(params).forEach((k) => {
s = s.split('{' + k + '}').join(String(params[k]));
});
}
return s;
}
function apply(root) {
const scope = root || document;
scope.querySelectorAll('[data-i18n]').forEach((el) => {
const key = el.getAttribute('data-i18n');
if (!key) return;
if (el.hasAttribute('data-i18n-html')) {
el.innerHTML = t(key);
} else {
el.textContent = t(key);
}
});
scope.querySelectorAll('[data-i18n-placeholder]').forEach((el) => {
const key = el.getAttribute('data-i18n-placeholder');
if (key) el.setAttribute('placeholder', t(key));
});
scope.querySelectorAll('[data-i18n-title]').forEach((el) => {
const key = el.getAttribute('data-i18n-title');
if (key) el.setAttribute('title', t(key));
});
scope.querySelectorAll('[data-i18n-aria]').forEach((el) => {
const key = el.getAttribute('data-i18n-aria');
if (key) el.setAttribute('aria-label', t(key));
});
scope.querySelectorAll('select[data-i18n-options]').forEach((sel) => {
const prefix = sel.getAttribute('data-i18n-options');
if (!prefix) return;
sel.querySelectorAll('option[data-i18n]').forEach((opt) => {
const k = opt.getAttribute('data-i18n');
if (k) opt.textContent = t(k);
});
});
const footer = document.querySelector('[data-i18n="footer.copyright"]');
if (footer) {
const year = footer.getAttribute('data-year') || String(new Date().getFullYear());
footer.textContent = t('footer.copyright', { year });
}
scope.querySelectorAll('[data-i18n="detail.report_meta"]').forEach((el) => {
el.textContent = t('detail.report_meta', {
id: el.getAttribute('data-i18n-id') || '',
type: el.getAttribute('data-i18n-type') || '',
});
});
}
function syncLangSelect() {
document.querySelectorAll('.lang-select').forEach((sel) => {
if (sel.value !== lang) sel.value = lang;
});
}
async function setLang(code, options) {
const opts = options || {};
if (!SUPPORTED.includes(code)) code = DEFAULT_LANG;
fallback = await fetchCatalog(DEFAULT_LANG);
catalog = code === DEFAULT_LANG ? { ...fallback } : await fetchCatalog(code);
lang = code;
document.documentElement.setAttribute('lang', code);
persistLang(code);
syncLangSelect();
if (!opts.silent) {
apply(document);
document.dispatchEvent(new CustomEvent('crash-i18n-change', { detail: { lang: code } }));
}
}
function getLang() {
return lang;
}
function initLangSelect() {
document.querySelectorAll('.lang-select').forEach((sel) => {
sel.value = lang;
if (sel.dataset.i18nBound === '1') return;
sel.dataset.i18nBound = '1';
sel.addEventListener('change', () => {
setLang(sel.value).catch(() => setLang(DEFAULT_LANG));
});
});
}
async function init() {
lang = storedLang();
document.documentElement.setAttribute('lang', lang);
await setLang(lang, { silent: true });
apply(document);
initLangSelect();
readyResolve();
}
global.CrashI18n = {
t,
apply,
setLang,
getLang,
init,
ready,
SUPPORTED,
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
init().catch(() => {
lang = DEFAULT_LANG;
readyResolve();
});
});
} else {
init().catch(() => readyResolve());
}
})(typeof window !== 'undefined' ? window : globalThis);

View File

@@ -0,0 +1,46 @@
-- Phase 1: companies, memberships, devices, report scoping.
-- Idempotent on fresh DB; existing installs: prefer app auto-migration (Database::ensureRbacSchema).
-- Manual run: mysql -u root -p androidcast_crashes < sql/migrations/001_rbac_companies.sql
USE androidcast_crashes;
CREATE TABLE IF NOT EXISTS companies (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
slug VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_companies_slug (slug)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS company_memberships (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id INT UNSIGNED NOT NULL,
company_id INT UNSIGNED NOT NULL,
role ENUM('owner', 'admin', 'member') NOT NULL DEFAULT 'member',
permissions_json LONGTEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_membership_user_company (user_id, company_id),
KEY idx_membership_company (company_id),
CONSTRAINT fk_membership_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
CONSTRAINT fk_membership_company FOREIGN KEY (company_id) REFERENCES companies (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS devices (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
company_id INT UNSIGNED NOT NULL,
external_id VARCHAR(128) NOT NULL,
display_name VARCHAR(256) NULL,
manufacturer VARCHAR(128) NULL,
model VARCHAR(128) NULL,
source ENUM('auto', 'manual', 'barcode') NOT NULL DEFAULT 'auto',
registered_at_ms BIGINT NOT NULL,
last_seen_at_ms BIGINT NOT NULL,
meta_json LONGTEXT NULL,
UNIQUE KEY uq_devices_company_external (company_id, external_id),
KEY idx_devices_company (company_id),
CONSTRAINT fk_devices_company FOREIGN KEY (company_id) REFERENCES companies (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- reports.company_id / reports.device_id added by application migration when missing
INSERT IGNORE INTO companies (id, slug, name) VALUES (1, 'default', 'Default');

View File

@@ -8,17 +8,56 @@ CREATE DATABASE IF NOT EXISTS androidcast_crashes
USE androidcast_crashes;
CREATE TABLE IF NOT EXISTS companies (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
slug VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_companies_slug (slug)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS users (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(64) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
role ENUM('root', 'admin', 'viewer') NOT NULL DEFAULT 'viewer',
role ENUM('root', 'platform_admin', 'admin', 'viewer') NOT NULL DEFAULT 'viewer',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_users_username (username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS company_memberships (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id INT UNSIGNED NOT NULL,
company_id INT UNSIGNED NOT NULL,
role ENUM('owner', 'admin', 'member') NOT NULL DEFAULT 'member',
permissions_json LONGTEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_membership_user_company (user_id, company_id),
KEY idx_membership_company (company_id),
CONSTRAINT fk_membership_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
CONSTRAINT fk_membership_company FOREIGN KEY (company_id) REFERENCES companies (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS devices (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
company_id INT UNSIGNED NOT NULL,
external_id VARCHAR(128) NOT NULL,
display_name VARCHAR(256) NULL,
manufacturer VARCHAR(128) NULL,
model VARCHAR(128) NULL,
source ENUM('auto', 'manual', 'barcode') NOT NULL DEFAULT 'auto',
registered_at_ms BIGINT NOT NULL,
last_seen_at_ms BIGINT NOT NULL,
meta_json LONGTEXT NULL,
UNIQUE KEY uq_devices_company_external (company_id, external_id),
KEY idx_devices_company (company_id),
CONSTRAINT fk_devices_company FOREIGN KEY (company_id) REFERENCES companies (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS reports (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
company_id INT UNSIGNED NOT NULL DEFAULT 1,
device_id BIGINT UNSIGNED NULL,
report_id VARCHAR(128) NOT NULL,
fingerprint VARCHAR(64) NOT NULL,
crash_type VARCHAR(32) NOT NULL,
@@ -29,10 +68,14 @@ CREATE TABLE IF NOT EXISTS reports (
payload_json LONGTEXT NOT NULL,
tags_json LONGTEXT NOT NULL,
UNIQUE KEY uq_reports_report_id (report_id),
KEY idx_reports_company (company_id),
KEY idx_reports_device (device_id),
KEY idx_reports_generated (generated_at_ms),
KEY idx_reports_received (received_at_ms),
KEY idx_reports_fingerprint (fingerprint),
KEY idx_reports_fp_received (fingerprint, received_at_ms)
KEY idx_reports_fp_received (fingerprint, received_at_ms),
CONSTRAINT fk_reports_company FOREIGN KEY (company_id) REFERENCES companies (id),
CONSTRAINT fk_reports_device FOREIGN KEY (device_id) REFERENCES devices (id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS report_views (
@@ -44,6 +87,11 @@ CREATE TABLE IF NOT EXISTS report_views (
FOREIGN KEY (report_id) REFERENCES reports (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT IGNORE INTO companies (id, slug, name) VALUES (1, 'default', 'Default');
INSERT IGNORE INTO users (id, username, password_hash, role)
VALUES (1, 'admin', '$2y$10$t2JyYCyjBwRxAHfmLwsnG.APoj/in0i6nxpKcQggdl.AkhFJR13o.', 'root');
-- default password: admin — change in production
INSERT IGNORE INTO company_memberships (user_id, company_id, role)
VALUES (1, 1, 'owner');

View File

@@ -1,5 +1,12 @@
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS companies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
@@ -8,8 +15,37 @@ CREATE TABLE IF NOT EXISTS users (
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS company_memberships (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
company_id INTEGER NOT NULL,
role TEXT NOT NULL DEFAULT 'member',
permissions_json TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (user_id, company_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS devices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_id INTEGER NOT NULL,
external_id TEXT NOT NULL,
display_name TEXT,
manufacturer TEXT,
model TEXT,
source TEXT NOT NULL DEFAULT 'auto',
registered_at_ms INTEGER NOT NULL,
last_seen_at_ms INTEGER NOT NULL,
meta_json TEXT,
UNIQUE (company_id, external_id),
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS reports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_id INTEGER NOT NULL DEFAULT 1,
device_id INTEGER,
report_id TEXT NOT NULL UNIQUE,
fingerprint TEXT NOT NULL,
crash_type TEXT NOT NULL,
@@ -18,7 +54,9 @@ CREATE TABLE IF NOT EXISTS reports (
device_model TEXT,
app_version TEXT,
payload_json TEXT NOT NULL,
tags_json TEXT NOT NULL DEFAULT '[]'
tags_json TEXT NOT NULL DEFAULT '[]',
FOREIGN KEY (company_id) REFERENCES companies(id),
FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS report_views (
@@ -29,10 +67,16 @@ CREATE TABLE IF NOT EXISTS report_views (
FOREIGN KEY (report_id) REFERENCES reports(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_reports_company ON reports(company_id);
CREATE INDEX IF NOT EXISTS idx_reports_generated ON reports(generated_at_ms DESC);
CREATE INDEX IF NOT EXISTS idx_reports_received ON reports(received_at_ms DESC);
CREATE INDEX IF NOT EXISTS idx_reports_fingerprint ON reports(fingerprint);
INSERT OR IGNORE INTO companies (id, slug, name) VALUES (1, 'default', 'Default');
INSERT OR IGNORE INTO users (id, username, password_hash, role)
VALUES (1, 'admin', '$2y$10$t2JyYCyjBwRxAHfmLwsnG.APoj/in0i6nxpKcQggdl.AkhFJR13o.', 'root');
-- password: admin
INSERT OR IGNORE INTO company_memberships (user_id, company_id, role)
VALUES (1, 1, 'owner');

View File

@@ -14,7 +14,24 @@ declare(strict_types=1);
final class Auth {
public static function user(): ?array {
return $_SESSION['user'] ?? null;
$u = $_SESSION['user'] ?? null;
if (!$u || !empty($u['companies'])) {
return $u;
}
$uid = (int) ($u['id'] ?? 0);
if ($uid <= 0) {
return $u;
}
$stmt = Database::pdo()->prepare('SELECT * FROM users WHERE id = ? LIMIT 1');
$stmt->execute([$uid]);
$row = $stmt->fetch();
if (!$row) {
return $u;
}
Database::ensureRbacSchema();
Rbac::seedMembershipsForUser($uid, (string) ($row['role'] ?? 'viewer'));
$_SESSION['user'] = Rbac::buildSessionUser($row);
return $_SESSION['user'];
}
public static function check(): void {
@@ -25,19 +42,11 @@ final class Auth {
}
public static function can(string $action): bool {
$u = self::user();
if (!$u) {
return false;
}
$role = $u['role'] ?? 'viewer';
if ($role === 'root' || $role === 'admin') {
return true;
}
return $action === 'view';
return Rbac::can($action);
}
public static function canEditTags(): bool {
return self::can('tag_edit');
public static function canEditTags(?int $companyId = null): bool {
return Rbac::can('edit_tags', $companyId);
}
public static function login(string $username, string $password): bool {
@@ -48,11 +57,7 @@ final class Auth {
if (!$row || !password_verify($password, $row['password_hash'])) {
return false;
}
$_SESSION['user'] = [
'id' => (int) $row['id'],
'username' => $row['username'],
'role' => $row['role'],
];
$_SESSION['user'] = Rbac::buildSessionUser($row);
return true;
}

View File

@@ -98,6 +98,7 @@ final class Database {
if (!$need('users') && !$need('reports') && self::reportsSchemaOk($pdo)) {
self::ensureReportColumns($pdo);
self::ensureReportViewsTable($pdo);
self::ensureRbacSchema($pdo);
return;
}
$schemaFile = __DIR__ . '/../sql/schema.sqlite.sql';
@@ -110,6 +111,7 @@ final class Database {
$pdo->exec((string) file_get_contents($schemaFile));
self::ensureReportColumns($pdo);
self::ensureReportViewsTable($pdo);
self::ensureRbacSchema($pdo);
}
private static function ensureMysqlSchema(PDO $pdo): void {
@@ -153,6 +155,186 @@ final class Database {
self::ensureMysqlReportColumns($pdo);
}
self::ensureMysqlReportViewsTable($pdo);
self::ensureRbacSchema($pdo);
}
/** Phase 1 RBAC tables + report company/device columns (idempotent). */
public static function ensureRbacSchema(?PDO $pdo = null): void {
$pdo ??= self::$pdo;
if ($pdo === null) {
return;
}
self::ensureCompaniesTable($pdo);
self::seedDefaultCompany($pdo);
self::ensureCompanyMembershipsTable($pdo);
self::ensureDevicesTable($pdo);
self::ensureReportCompanyColumns($pdo);
self::backfillCompanyMemberships($pdo);
}
private static function ensureCompaniesTable(PDO $pdo): void {
if (self::tableExists($pdo, 'companies')) {
return;
}
if (self::isMysql()) {
$pdo->exec(
"CREATE TABLE companies (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
slug VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_companies_slug (slug)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
return;
}
$pdo->exec(
"CREATE TABLE companies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)"
);
}
private static function seedDefaultCompany(PDO $pdo): void {
if (self::isMysql()) {
$pdo->exec("INSERT IGNORE INTO companies (id, slug, name) VALUES (1, 'default', 'Default')");
return;
}
$pdo->exec("INSERT OR IGNORE INTO companies (id, slug, name) VALUES (1, 'default', 'Default')");
}
private static function ensureCompanyMembershipsTable(PDO $pdo): void {
if (self::tableExists($pdo, 'company_memberships')) {
return;
}
if (self::isMysql()) {
$pdo->exec(
"CREATE TABLE company_memberships (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id INT UNSIGNED NOT NULL,
company_id INT UNSIGNED NOT NULL,
role ENUM('owner', 'admin', 'member') NOT NULL DEFAULT 'member',
permissions_json LONGTEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_membership_user_company (user_id, company_id),
KEY idx_membership_company (company_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
return;
}
$pdo->exec(
"CREATE TABLE company_memberships (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
company_id INTEGER NOT NULL,
role TEXT NOT NULL DEFAULT 'member',
permissions_json TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (user_id, company_id)
)"
);
}
private static function ensureDevicesTable(PDO $pdo): void {
if (self::tableExists($pdo, 'devices')) {
return;
}
if (self::isMysql()) {
$pdo->exec(
"CREATE TABLE devices (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
company_id INT UNSIGNED NOT NULL,
external_id VARCHAR(128) NOT NULL,
display_name VARCHAR(256) NULL,
manufacturer VARCHAR(128) NULL,
model VARCHAR(128) NULL,
source ENUM('auto', 'manual', 'barcode') NOT NULL DEFAULT 'auto',
registered_at_ms BIGINT NOT NULL,
last_seen_at_ms BIGINT NOT NULL,
meta_json LONGTEXT NULL,
UNIQUE KEY uq_devices_company_external (company_id, external_id),
KEY idx_devices_company (company_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
return;
}
$pdo->exec(
"CREATE TABLE devices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_id INTEGER NOT NULL,
external_id TEXT NOT NULL,
display_name TEXT,
manufacturer TEXT,
model TEXT,
source TEXT NOT NULL DEFAULT 'auto',
registered_at_ms INTEGER NOT NULL,
last_seen_at_ms INTEGER NOT NULL,
meta_json TEXT,
UNIQUE (company_id, external_id)
)"
);
}
private static function ensureReportCompanyColumns(PDO $pdo): void {
if (!self::tableExists($pdo, 'reports')) {
return;
}
$names = self::columnNames($pdo, 'reports');
if (!in_array('company_id', $names, true)) {
if (self::isMysql()) {
$pdo->exec(
'ALTER TABLE reports ADD COLUMN company_id INT UNSIGNED NOT NULL DEFAULT 1'
);
$pdo->exec('ALTER TABLE reports ADD KEY idx_reports_company (company_id)');
} else {
$pdo->exec('ALTER TABLE reports ADD COLUMN company_id INTEGER NOT NULL DEFAULT 1');
}
$pdo->exec('UPDATE reports SET company_id = 1 WHERE company_id IS NULL OR company_id = 0');
}
$names = self::columnNames($pdo, 'reports');
if (!in_array('device_id', $names, true)) {
if (self::isMysql()) {
$pdo->exec('ALTER TABLE reports ADD COLUMN device_id BIGINT UNSIGNED NULL');
$pdo->exec('ALTER TABLE reports ADD KEY idx_reports_device (device_id)');
} else {
$pdo->exec('ALTER TABLE reports ADD COLUMN device_id INTEGER NULL');
}
}
}
private static function backfillCompanyMemberships(PDO $pdo): void {
if (!self::tableExists($pdo, 'users') || !self::tableExists($pdo, 'company_memberships')) {
return;
}
$companyId = 1;
$rows = $pdo->query('SELECT id, role FROM users')->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as $row) {
$uid = (int) $row['id'];
$globalRole = strtolower((string) ($row['role'] ?? 'viewer'));
$companyRole = in_array($globalRole, ['root', 'admin', 'platform_admin'], true) ? 'owner' : 'member';
if (self::isMysql()) {
$stmt = $pdo->prepare(
'INSERT IGNORE INTO company_memberships (user_id, company_id, role) VALUES (?, ?, ?)'
);
} else {
$stmt = $pdo->prepare(
'INSERT OR IGNORE INTO company_memberships (user_id, company_id, role) VALUES (?, ?, ?)'
);
}
$stmt->execute([$uid, $companyId, $companyRole]);
}
if (self::isMysql()) {
$pdo->exec(
"INSERT IGNORE INTO company_memberships (user_id, company_id, role) VALUES (1, 1, 'owner')"
);
} else {
$pdo->exec(
"INSERT OR IGNORE INTO company_memberships (user_id, company_id, role) VALUES (1, 1, 'owner')"
);
}
}
private static function ensureMysqlReportColumns(PDO $pdo): void {

View File

@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
/** Phase 1 device registry — auto-register on crash upload. */
final class DeviceRepository {
public static function externalKey(array $device): string {
$parts = array_filter([
(string) ($device['manufacturer'] ?? ''),
(string) ($device['brand'] ?? ''),
(string) ($device['model'] ?? ''),
(string) ($device['product'] ?? ''),
(string) ($device['device'] ?? ''),
(string) ($device['serial'] ?? ''),
(string) ($device['android_id'] ?? ''),
], static fn ($v) => $v !== '');
if ($parts === []) {
return 'unknown';
}
return substr(hash('sha256', implode('|', $parts)), 0, 32);
}
public static function upsertFromPayload(int $companyId, array $device, string $source = 'auto'): int {
$companyId = max(1, $companyId);
$externalId = self::externalKey($device);
$now = (int) round(microtime(true) * 1000);
$manufacturer = trim((string) ($device['manufacturer'] ?? ''));
$model = trim((string) ($device['model'] ?? ''));
$display = trim($manufacturer . ' ' . $model);
$meta = json_encode($device, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($meta === false) {
$meta = '{}';
}
$pdo = Database::pdo();
if (Database::isMysql()) {
$stmt = $pdo->prepare(
'INSERT INTO devices (company_id, external_id, display_name, manufacturer, model, source,
registered_at_ms, last_seen_at_ms, meta_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
display_name = VALUES(display_name),
manufacturer = VALUES(manufacturer),
model = VALUES(model),
last_seen_at_ms = VALUES(last_seen_at_ms),
meta_json = VALUES(meta_json)'
);
$stmt->execute([
$companyId,
$externalId,
$display !== '' ? $display : null,
$manufacturer !== '' ? $manufacturer : null,
$model !== '' ? $model : null,
$source,
$now,
$now,
$meta,
]);
$sel = $pdo->prepare(
'SELECT id FROM devices WHERE company_id = ? AND external_id = ? LIMIT 1'
);
$sel->execute([$companyId, $externalId]);
return (int) $sel->fetchColumn();
}
$stmt = $pdo->prepare(
'SELECT id FROM devices WHERE company_id = ? AND external_id = ? LIMIT 1'
);
$stmt->execute([$companyId, $externalId]);
$existing = $stmt->fetchColumn();
if ($existing !== false) {
$upd = $pdo->prepare(
'UPDATE devices SET display_name = ?, manufacturer = ?, model = ?,
last_seen_at_ms = ?, meta_json = ? WHERE id = ?'
);
$upd->execute([
$display !== '' ? $display : null,
$manufacturer !== '' ? $manufacturer : null,
$model !== '' ? $model : null,
$now,
$meta,
(int) $existing,
]);
return (int) $existing;
}
$ins = $pdo->prepare(
'INSERT INTO devices (company_id, external_id, display_name, manufacturer, model, source,
registered_at_ms, last_seen_at_ms, meta_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
);
$ins->execute([
$companyId,
$externalId,
$display !== '' ? $display : null,
$manufacturer !== '' ? $manufacturer : null,
$model !== '' ? $model : null,
$source,
$now,
$now,
$meta,
]);
return (int) $pdo->lastInsertId();
}
}

View File

@@ -0,0 +1,290 @@
<?php
declare(strict_types=1);
/**
* Phase 1 RBAC: global platform roles + company memberships.
* MariaDB-style: global scope (root/platform) vs company scope (owner/admin/member).
*/
final class Rbac {
/** Global roles stored on users.role */
public const GLOBAL_ROOT = 'root';
public const GLOBAL_PLATFORM_ADMIN = 'platform_admin';
/** Legacy alias — treated as platform_admin */
public const GLOBAL_ADMIN_LEGACY = 'admin';
public const GLOBAL_VIEWER = 'viewer';
public const COMPANY_OWNER = 'owner';
public const COMPANY_ADMIN = 'admin';
public const COMPANY_MEMBER = 'member';
/** @var list<string> */
private const GLOBAL_ADMIN_ROLES = [self::GLOBAL_ROOT, self::GLOBAL_PLATFORM_ADMIN, self::GLOBAL_ADMIN_LEGACY];
/** Company role => granted actions (unless global admin). */
private const COMPANY_ROLE_ACTIONS = [
self::COMPANY_OWNER => [
'view_reports',
'edit_tags',
'upload_reports',
'manage_devices',
'manage_company_users',
'manage_company_settings',
'manage_self',
],
self::COMPANY_ADMIN => [
'view_reports',
'edit_tags',
'upload_reports',
'manage_devices',
'manage_company_users',
'manage_self',
],
self::COMPANY_MEMBER => [
'view_reports',
'manage_self',
],
];
public static function normalizeGlobalRole(string $role): string {
$role = strtolower(trim($role));
if ($role === self::GLOBAL_ADMIN_LEGACY) {
return self::GLOBAL_PLATFORM_ADMIN;
}
return $role;
}
public static function isGlobalAdmin(?array $user = null): bool {
$user ??= Auth::user();
if (!$user) {
return false;
}
$role = self::normalizeGlobalRole((string) ($user['role'] ?? ''));
return in_array($role, self::GLOBAL_ADMIN_ROLES, true);
}
/** @return list<int>|null null = all companies (global admin) */
public static function allowedCompanyIds(?array $user = null): ?array {
if (self::isGlobalAdmin($user)) {
return null;
}
$user ??= Auth::user();
if (!$user) {
return [];
}
$ids = [];
foreach ($user['companies'] ?? [] as $c) {
if (isset($c['id'])) {
$ids[] = (int) $c['id'];
}
}
return array_values(array_unique($ids));
}
public static function activeCompanyId(?array $user = null): ?int {
$user ??= Auth::user();
if (!$user) {
return null;
}
$id = (int) ($user['active_company_id'] ?? 0);
return $id > 0 ? $id : null;
}
public static function companyRole(int $userId, int $companyId): ?string {
$stmt = Database::pdo()->prepare(
'SELECT role FROM company_memberships WHERE user_id = ? AND company_id = ? LIMIT 1'
);
$stmt->execute([$userId, $companyId]);
$role = $stmt->fetchColumn();
return is_string($role) && $role !== '' ? $role : null;
}
public static function can(string $action, ?int $companyId = null, ?array $user = null): bool {
$user ??= Auth::user();
if (!$user) {
return false;
}
if (self::isGlobalAdmin($user)) {
return true;
}
if ($action === 'manage_platform') {
return false;
}
if ($action === 'manage_self') {
return true;
}
$companyId ??= self::activeCompanyId($user);
if ($companyId === null || $companyId <= 0) {
return false;
}
$role = self::membershipRoleForUser($user, $companyId);
if ($role === null) {
return false;
}
$grants = self::COMPANY_ROLE_ACTIONS[$role] ?? [];
if (in_array($action, $grants, true)) {
return true;
}
return self::hasPermissionOverride($user, $companyId, $action);
}
public static function canAccessCompany(int $companyId, ?array $user = null): bool {
$allowed = self::allowedCompanyIds($user);
if ($allowed === null) {
return true;
}
return in_array($companyId, $allowed, true);
}
public static function canAccessReport(array $report, ?array $user = null): bool {
$companyId = (int) ($report['company_id'] ?? 0);
if ($companyId <= 0) {
return self::isGlobalAdmin($user);
}
return self::canAccessCompany($companyId, $user);
}
public static function defaultCompanyId(): int {
static $cached = 0;
if ($cached > 0) {
return $cached;
}
$fromCfg = (int) cfg('rbac.default_company_id', 0);
if ($fromCfg > 0) {
$cached = $fromCfg;
return $cached;
}
$slug = (string) cfg('rbac.default_company_slug', 'default');
$pdo = Database::pdo();
$stmt = $pdo->prepare('SELECT id FROM companies WHERE slug = ? LIMIT 1');
$stmt->execute([$slug]);
$id = (int) $stmt->fetchColumn();
if ($id > 0) {
$cached = $id;
return $cached;
}
$cached = 1;
return $cached;
}
/**
* Append company scope to WHERE fragments.
*
* @param list<string> $where
* @param list<mixed> $params
*/
public static function appendCompanyScope(string $column, array &$where, array &$params, ?array $user = null): void {
$allowed = self::allowedCompanyIds($user);
if ($allowed === null) {
return;
}
if ($allowed === []) {
$where[] = '0=1';
return;
}
$placeholders = implode(',', array_fill(0, count($allowed), '?'));
$where[] = "$column IN ($placeholders)";
foreach ($allowed as $id) {
$params[] = $id;
}
}
/** Match fingerprint counts within the same company. */
public static function fingerprintCountSql(string $rAlias = 'r', string $r2Alias = 'r2'): string {
return " AND $r2Alias.company_id = $rAlias.company_id";
}
/**
* @return list<array{id:int,slug:string,name:string,role:string}>
*/
public static function fetchMemberships(int $userId): array {
$stmt = Database::pdo()->prepare(
'SELECT c.id, c.slug, c.name, m.role
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]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
$out = [];
foreach ($rows as $row) {
$out[] = [
'id' => (int) $row['id'],
'slug' => (string) $row['slug'],
'name' => (string) $row['name'],
'role' => (string) $row['role'],
];
}
return $out;
}
/**
* @param list<array{id:int,slug:string,name:string,role:string}> $memberships
*/
public static function pickActiveCompanyId(array $memberships): int {
if ($memberships === []) {
return self::defaultCompanyId();
}
return (int) $memberships[0]['id'];
}
/** @return array<string, mixed> session user payload */
public static function buildSessionUser(array $dbUser): array {
$uid = (int) $dbUser['id'];
$memberships = self::fetchMemberships($uid);
return [
'id' => $uid,
'username' => (string) $dbUser['username'],
'role' => self::normalizeGlobalRole((string) ($dbUser['role'] ?? self::GLOBAL_VIEWER)),
'companies' => $memberships,
'active_company_id' => self::pickActiveCompanyId($memberships),
];
}
public static function seedMembershipsForUser(int $userId, string $globalRole): void {
$pdo = Database::pdo();
$companyId = self::defaultCompanyId();
$globalRole = self::normalizeGlobalRole($globalRole);
$companyRole = self::COMPANY_MEMBER;
if (in_array($globalRole, self::GLOBAL_ADMIN_ROLES, true)) {
$companyRole = self::COMPANY_OWNER;
}
if (Database::isMysql()) {
$sql = 'INSERT IGNORE INTO company_memberships (user_id, company_id, role) VALUES (?, ?, ?)';
} else {
$sql = 'INSERT OR IGNORE INTO company_memberships (user_id, company_id, role) VALUES (?, ?, ?)';
}
$stmt = $pdo->prepare($sql);
$stmt->execute([$userId, $companyId, $companyRole]);
}
private static function membershipRoleForUser(array $user, int $companyId): ?string {
foreach ($user['companies'] ?? [] as $c) {
if ((int) ($c['id'] ?? 0) === $companyId) {
return (string) ($c['role'] ?? '');
}
}
return null;
}
private static function hasPermissionOverride(array $user, int $companyId, string $action): bool {
$pdo = Database::pdo();
$stmt = $pdo->prepare(
'SELECT permissions_json FROM company_memberships WHERE user_id = ? AND company_id = ? LIMIT 1'
);
$stmt->execute([(int) $user['id'], $companyId]);
$raw = $stmt->fetchColumn();
if (!is_string($raw) || $raw === '') {
return false;
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
return false;
}
$grants = $decoded['grants'] ?? $decoded;
if (!is_array($grants)) {
return false;
}
return in_array($action, $grants, true);
}
}

View File

@@ -23,19 +23,23 @@ final class ReportRepository {
public static function insert(array $payload): void {
$pdo = Database::pdo();
$device = $payload['device'] ?? [];
$app = $payload['app'] ?? [];
$device = is_array($payload['device'] ?? null) ? $payload['device'] : [];
$app = is_array($payload['app'] ?? null) ? $payload['app'] : [];
$json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if ($json === false) {
throw new RuntimeException('payload json_encode failed: ' . json_last_error_msg());
}
$tags = json_encode($payload['tags'] ?? [], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$companyId = Rbac::defaultCompanyId();
$deviceId = DeviceRepository::upsertFromPayload($companyId, $device, 'auto');
$stmt = $pdo->prepare(
'INSERT INTO reports (report_id, fingerprint, crash_type, generated_at_ms, received_at_ms,
device_model, app_version, payload_json, tags_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
'INSERT INTO reports (company_id, device_id, report_id, fingerprint, crash_type, generated_at_ms,
received_at_ms, device_model, app_version, payload_json, tags_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
);
$stmt->execute([
$companyId,
$deviceId > 0 ? $deviceId : null,
$payload['report_id'] ?? uniqid('rpt_', true),
$payload['fingerprint'] ?? 'unknown',
$payload['crash_type'] ?? 'java',
@@ -69,9 +73,17 @@ final class ReportRepository {
if ($grouped) {
$sortCol = in_array($sort, self::GROUP_SORT, true) ? $sort : 'cnt';
$total = (int) $pdo->query(
'SELECT COUNT(*) FROM (SELECT 1 FROM reports GROUP BY fingerprint, crash_type)'
)->fetchColumn();
$gWhere = ['1=1'];
$gParams = [];
self::appendCompanyScope('company_id', $gWhere, $gParams);
$gWhereSql = implode(' AND ', $gWhere);
$countStmt = $pdo->prepare(
"SELECT COUNT(*) FROM (
SELECT 1 FROM reports WHERE $gWhereSql GROUP BY fingerprint, crash_type
) t"
);
$countStmt->execute($gParams);
$total = (int) $countStmt->fetchColumn();
$sql = "SELECT fingerprint, crash_type,
MIN(device_model) AS device_model,
MAX(app_version) AS app_version,
@@ -79,10 +91,13 @@ final class ReportRepository {
MAX(generated_at_ms) AS last_generated_ms,
MAX(received_at_ms) AS last_received_ms
FROM reports
WHERE $gWhereSql
GROUP BY fingerprint, crash_type
ORDER BY $sortCol $dir
LIMIT $perPage OFFSET $offset";
$items = $pdo->query($sql)->fetchAll();
$stmt = $pdo->prepare($sql);
$stmt->execute($gParams);
$items = $stmt->fetchAll();
foreach ($items as &$row) {
$row['fingerprint_cnt'] = (int) ($row['cnt'] ?? 0);
$row['fingerprint_recent_cnt'] = $sinceMs > 0 && (int) ($row['last_received_ms'] ?? 0) > $sinceMs
@@ -109,19 +124,27 @@ final class ReportRepository {
$sortCol = 'fingerprint_cnt';
}
$orderBy = self::buildListOrder($sortCol === 'priority' ? 'priority' : $sortCol, $dir);
$total = (int) $pdo->query('SELECT COUNT(*) FROM reports')->fetchColumn();
$where = ['1=1'];
$scopeParams = [];
self::appendCompanyScope('r.company_id', $where, $scopeParams);
$whereSql = implode(' AND ', $where);
$fpCnt = self::fingerprintCountSql();
$countStmt = $pdo->prepare("SELECT COUNT(*) FROM reports r WHERE $whereSql");
$countStmt->execute($scopeParams);
$total = (int) $countStmt->fetchColumn();
$sql = "SELECT r.id, r.report_id, r.fingerprint, r.crash_type, r.generated_at_ms, r.received_at_ms,
r.device_model, r.app_version, r.payload_json, r.tags_json,
CASE WHEN v.viewed_at_ms IS NOT NULL THEN 1 ELSE 0 END AS viewed,
(SELECT COUNT(*) FROM reports r2 WHERE r2.fingerprint = r.fingerprint) AS fingerprint_cnt,
{$fpCnt} AS fingerprint_cnt,
(SELECT COUNT(*) FROM reports r2 WHERE r2.fingerprint = r.fingerprint
AND r2.received_at_ms > ?) AS fingerprint_recent_cnt
AND r2.company_id = r.company_id AND r2.received_at_ms > ?) AS fingerprint_recent_cnt
FROM reports r
LEFT JOIN report_views v ON v.report_id = r.id AND v.user_id = ?
WHERE $whereSql
ORDER BY $orderBy
LIMIT $perPage OFFSET $offset";
$stmt = $pdo->prepare($sql);
$stmt->execute([$sinceMs, $userId]);
$stmt->execute(array_merge($scopeParams, [$sinceMs, $userId]));
$items = $stmt->fetchAll();
self::finishListRows($items, $sinceMs, false);
@@ -154,6 +177,7 @@ final class ReportRepository {
$userId = (int) (Auth::user()['id'] ?? 0);
$where = ['1=1'];
$params = [];
self::appendCompanyScope('r.company_id', $where, $params);
$device = trim((string) ($filters['device'] ?? ''));
if ($device !== '') {
$like = '%' . $device . '%';
@@ -200,19 +224,20 @@ final class ReportRepository {
}
$orderBy = self::buildListOrder($sortCol === 'priority' ? 'priority' : $sortCol, $dir);
$offset = ($page - 1) * $perPage;
$fpCnt = self::fingerprintCountSql();
$sql = "SELECT r.id, r.report_id, r.fingerprint, r.crash_type, r.generated_at_ms, r.received_at_ms,
r.device_model, r.app_version, r.payload_json, r.tags_json,
CASE WHEN v.viewed_at_ms IS NOT NULL THEN 1 ELSE 0 END AS viewed,
(SELECT COUNT(*) FROM reports r2 WHERE r2.fingerprint = r.fingerprint) AS fingerprint_cnt,
{$fpCnt} AS fingerprint_cnt,
(SELECT COUNT(*) FROM reports r2 WHERE r2.fingerprint = r.fingerprint
AND r2.received_at_ms > ?) AS fingerprint_recent_cnt
AND r2.company_id = r.company_id AND r2.received_at_ms > ?) AS fingerprint_recent_cnt
FROM reports r
LEFT JOIN report_views v ON v.report_id = r.id AND v.user_id = ?
WHERE $whereSql
ORDER BY $orderBy
LIMIT $perPage OFFSET $offset";
$stmt = $pdo->prepare($sql);
$stmt->execute(array_merge([$sinceMs, $userId], $params));
$stmt->execute(array_merge($params, [$sinceMs, $userId]));
$items = $stmt->fetchAll();
self::finishListRows($items, $sinceMs, false);
return [
@@ -260,6 +285,7 @@ final class ReportRepository {
$where = ['r.id != ?'];
$params = [$anchorId];
self::appendCompanyScope('r.company_id', $where, $params);
$or = [];
if ($fp !== '') {
$or[] = 'r.fingerprint = ?';
@@ -288,19 +314,20 @@ final class ReportRepository {
$where[] = '(' . implode(' OR ', $or) . ')';
$whereSql = implode(' AND ', $where);
$fpCnt = self::fingerprintCountSql();
$sql = "SELECT r.id, r.report_id, r.fingerprint, r.crash_type, r.generated_at_ms, r.received_at_ms,
r.device_model, r.app_version, r.payload_json, r.tags_json,
CASE WHEN v.viewed_at_ms IS NOT NULL THEN 1 ELSE 0 END AS viewed,
(SELECT COUNT(*) FROM reports r2 WHERE r2.fingerprint = r.fingerprint) AS fingerprint_cnt,
{$fpCnt} AS fingerprint_cnt,
(SELECT COUNT(*) FROM reports r2 WHERE r2.fingerprint = r.fingerprint
AND r2.received_at_ms > ?) AS fingerprint_recent_cnt
AND r2.company_id = r.company_id AND r2.received_at_ms > ?) AS fingerprint_recent_cnt
FROM reports r
LEFT JOIN report_views v ON v.report_id = r.id AND v.user_id = ?
WHERE $whereSql
ORDER BY r.received_at_ms DESC
LIMIT 1200";
$stmt = $pdo->prepare($sql);
$stmt->execute(array_merge([$sinceMs, $userId], $params));
$stmt->execute(array_merge($params, [$sinceMs, $userId]));
$rows = $stmt->fetchAll();
$scored = [];
foreach ($rows as $row) {
@@ -386,10 +413,15 @@ final class ReportRepository {
return [];
}
$pdo = Database::pdo();
$stmt = $pdo->query(
'SELECT payload_json, device_model, app_version, crash_type FROM reports
ORDER BY received_at_ms DESC LIMIT 500'
$where = ['1=1'];
$params = [];
self::appendCompanyScope('company_id', $where, $params);
$whereSql = implode(' AND ', $where);
$stmt = $pdo->prepare(
"SELECT payload_json, device_model, app_version, crash_type FROM reports
WHERE $whereSql ORDER BY received_at_ms DESC LIMIT 500"
);
$stmt->execute($params);
$freq = [];
while ($row = $stmt->fetch()) {
$payload = json_decode($row['payload_json'] ?? '', true);
@@ -450,28 +482,32 @@ final class ReportRepository {
$page = max(1, $page);
$perPage = max(1, min(200, $perPage));
$userId = (int) (Auth::user()['id'] ?? 0);
$orParts = [];
$where = ['1=1'];
$params = [];
self::appendCompanyScope('r.company_id', $where, $params);
$orParts = [];
foreach ($tokens as $token) {
$like = '%' . $token . '%';
$orParts[] = '(r.payload_json LIKE ? OR r.device_model LIKE ? OR r.app_version LIKE ?
OR r.fingerprint LIKE ? OR r.crash_type LIKE ?)';
array_push($params, $like, $like, $like, $like, $like);
}
$whereSql = '(' . implode(' OR ', $orParts) . ')';
$where[] = '(' . implode(' OR ', $orParts) . ')';
$whereSql = implode(' AND ', $where);
$fpCnt = self::fingerprintCountSql();
$sql = "SELECT r.id, r.report_id, r.fingerprint, r.crash_type, r.generated_at_ms, r.received_at_ms,
r.device_model, r.app_version, r.payload_json, r.tags_json,
CASE WHEN v.viewed_at_ms IS NOT NULL THEN 1 ELSE 0 END AS viewed,
(SELECT COUNT(*) FROM reports r2 WHERE r2.fingerprint = r.fingerprint) AS fingerprint_cnt,
{$fpCnt} AS fingerprint_cnt,
(SELECT COUNT(*) FROM reports r2 WHERE r2.fingerprint = r.fingerprint
AND r2.received_at_ms > ?) AS fingerprint_recent_cnt
AND r2.company_id = r.company_id AND r2.received_at_ms > ?) AS fingerprint_recent_cnt
FROM reports r
LEFT JOIN report_views v ON v.report_id = r.id AND v.user_id = ?
WHERE $whereSql
ORDER BY r.received_at_ms DESC
LIMIT 1500";
$stmt = $pdo->prepare($sql);
$stmt->execute(array_merge([$sinceMs, $userId], $params));
$stmt->execute(array_merge($params, [$sinceMs, $userId]));
$scored = [];
while ($row = $stmt->fetch()) {
$payload = json_decode($row['payload_json'] ?? '', true);
@@ -569,8 +605,13 @@ final class ReportRepository {
/** Distinct custom tags used across reports (for suggestions). */
public static function listTagPresets(int $limit = 40): array {
$pdo = Database::pdo();
$rows = $pdo->query('SELECT tags_json FROM reports WHERE tags_json IS NOT NULL AND tags_json != \'[]\' LIMIT 500')
->fetchAll();
$where = ['tags_json IS NOT NULL', "tags_json != '[]'"];
$params = [];
self::appendCompanyScope('company_id', $where, $params);
$whereSql = implode(' AND ', $where);
$stmt = $pdo->prepare("SELECT tags_json FROM reports WHERE $whereSql LIMIT 500");
$stmt->execute($params);
$rows = $stmt->fetchAll();
$map = [];
foreach ($rows as $row) {
foreach (parse_report_tags($row['tags_json'] ?? '[]') as $tag) {
@@ -602,10 +643,26 @@ final class ReportRepository {
if (!$row) {
return null;
}
if (!Rbac::canAccessReport($row)) {
return null;
}
$decoded = json_decode($row['payload_json'], true);
$row['payload'] = is_array($decoded) ? $decoded : [];
$row['tags'] = parse_report_tags($row['tags_json'] ?? '[]');
$row['viewed'] = (bool) ($row['viewed'] ?? false);
return $row;
}
/**
* @param list<string> $where
* @param list<mixed> $params
*/
private static function appendCompanyScope(string $column, array &$where, array &$params): void {
Rbac::appendCompanyScope($column, $where, $params);
}
private static function fingerprintCountSql(): string {
$scope = Rbac::fingerprintCountSql('r', 'r2');
return "(SELECT COUNT(*) FROM reports r2 WHERE r2.fingerprint = r.fingerprint{$scope})";
}
}

View File

@@ -33,6 +33,8 @@ if (session_status() !== PHP_SESSION_ACTIVE) {
}
require_once __DIR__ . '/Database.php';
require_once __DIR__ . '/Rbac.php';
require_once __DIR__ . '/DeviceRepository.php';
require_once __DIR__ . '/Auth.php';
require_once __DIR__ . '/ReportRepository.php';

View File

@@ -21,9 +21,12 @@
(function () {
var t = localStorage.getItem('crash_console_theme');
if (t === 'light' || t === 'dark') document.documentElement.setAttribute('data-theme', t);
var l = localStorage.getItem('crash_console_lang');
if (l === 'en' || l === 'ru') document.documentElement.setAttribute('lang', l);
})();
</script>
<link rel="stylesheet" href="<?= h(Auth::basePath()) ?>/assets/css/app.css">
<script src="<?= h(Auth::basePath()) ?>/assets/js/i18n.js" defer></script>
<script src="<?= h(Auth::basePath()) ?>/assets/js/app.js" defer></script>
</head>
<body data-base-path="<?= h(Auth::basePath()) ?>"
@@ -33,29 +36,40 @@
<header class="top-menu" hidden aria-hidden="true"></header>
<div class="shell">
<nav class="nav-pane" id="nav-pane" aria-label="Console navigation">
<button type="button" class="nav-handle" id="nav-handle" aria-label="Toggle navigation" title="Navigation">
<button type="button" class="nav-handle" id="nav-handle" data-i18n-aria="nav.toggle" data-i18n-title="nav.toggle" aria-label="Toggle navigation" title="Navigation">
<span class="nav-icon nav-icon--menu" aria-hidden="true"></span>
</button>
<ul class="nav-list">
<li>
<a href="<?= h(Auth::basePath()) ?>/?view=home"
class="nav-link <?= ($view ?? '') === 'home' ? 'active' : '' ?>"
data-i18n-aria="nav.home" data-i18n-title="nav.home"
aria-label="Home"
title="Home">
<span class="nav-icon nav-icon--home" aria-hidden="true"></span>
<span class="nav-label">Home</span>
<span class="nav-label" data-i18n="nav.home">Home</span>
</a>
</li>
<li>
<a href="<?= h(Auth::basePath()) ?>/?view=reports"
class="nav-link <?= ($view ?? '') === 'reports' || ($view ?? '') === 'report' ? 'active' : '' ?>"
data-i18n-aria="nav.reports" data-i18n-title="nav.reports"
aria-label="Reports"
title="Reports">
<span class="nav-icon nav-icon--reports" aria-hidden="true"></span>
<span class="nav-label">Reports</span>
<span class="nav-label" data-i18n="nav.reports">Reports</span>
</a>
</li>
</ul>
<div class="nav-locale<?= ($view ?? '') === 'reports' ? ' nav-locale--toolbar-dup' : '' ?>">
<label class="toolbar-select toolbar-select--nav">
<span data-i18n="lang.label">Language</span>
<select class="lang-select" aria-label="Language">
<option value="en" data-i18n="lang.en">English</option>
<option value="ru" data-i18n="lang.ru">Russian</option>
</select>
</label>
</div>
<div class="nav-user">
<span class="nav-user-name" title="<?= h(Auth::user()['username'] ?? '') ?>">
<span class="nav-icon nav-icon--user" aria-hidden="true"></span>
@@ -63,39 +77,47 @@
</span>
<a href="<?= h(Auth::basePath()) ?>/logout"
class="nav-link nav-link--logout"
data-i18n-aria="nav.logout" data-i18n-title="nav.logout"
aria-label="Logout"
title="Logout">
<span class="nav-icon nav-icon--logout" aria-hidden="true"></span>
<span class="nav-label">Logout</span>
<span class="nav-label" data-i18n="nav.logout">Logout</span>
</a>
</div>
</nav>
<main class="main-pane">
<?php if (($view ?? 'home') === 'home'): ?>
<h1>Crash console</h1>
<p>Anonymous Android Cast crash ingest is active. Use <strong>Reports</strong> to analyze grouped fingerprints.</p>
<h1 data-i18n="home.title">Crash console</h1>
<p data-i18n="home.intro">Anonymous Android Cast crash ingest is active. Use <strong>Reports</strong> to analyze grouped fingerprints.</p>
<ul>
<li>Upload API: <code><?= h(Auth::basePath()) ?>/api/upload.php</code></li>
<li>Schema: <code>schema_version: 1</code></li>
<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>
</ul>
<?php elseif (($view ?? '') === 'report' && !empty($report)): ?>
<?php require __DIR__ . '/report_detail.php'; ?>
<?php elseif (($view ?? '') === 'reports'): ?>
<div id="reports-app" class="reports-app" data-grouped="0">
<div class="toolbar reports-toolbar">
<h1>Crash reports</h1>
<h1 data-i18n="reports.title">Crash reports</h1>
<div class="toolbar-actions">
<button type="button" class="btn reports-mode-btn active" data-grouped="0">By time</button>
<button type="button" class="btn reports-mode-btn" data-grouped="1">Grouped</button>
<button type="button" class="btn reports-mode-btn active" data-grouped="0" data-i18n="reports.by_time">By time</button>
<button type="button" class="btn reports-mode-btn" data-grouped="1" data-i18n="reports.grouped">Grouped</button>
<label class="toolbar-select">
Theme
<select id="theme-select" aria-label="UI theme">
<option value="dark">Dark</option>
<option value="light">Light</option>
<span data-i18n="lang.label">Language</span>
<select class="lang-select" id="lang-select" aria-label="Language" data-i18n-options="lang">
<option value="en" data-i18n="lang.en">English</option>
<option value="ru" data-i18n="lang.ru">Russian</option>
</select>
</label>
<label class="toolbar-select">
Per page
<span data-i18n="theme.label">Theme</span>
<select id="theme-select" aria-label="UI theme" data-i18n-options="theme">
<option value="dark" data-i18n="theme.dark">Dark</option>
<option value="light" data-i18n="theme.light">Light</option>
</select>
</label>
<label class="toolbar-select">
<span data-i18n="reports.per_page">Per page</span>
<select id="per-page-select" aria-label="Reports per page">
<option value="25">25</option>
<option value="50" selected>50</option>
@@ -108,15 +130,16 @@
</div>
<p id="reports-filter-banner" class="reports-filter-banner muted" hidden></p>
<div class="reports-search-row">
<label class="reports-search-label" for="reports-search-input">Search</label>
<label class="reports-search-label" for="reports-search-input" data-i18n="reports.search">Search</label>
<div class="reports-search-field">
<input type="search" id="reports-search-input" class="reports-search-input"
data-i18n-placeholder="reports.search_placeholder"
placeholder="Keywords (exceptions, devices, stack traces…)" autocomplete="off" spellcheck="false">
<ul id="reports-search-suggest" class="reports-search-suggest" hidden role="listbox"></ul>
</div>
<button type="button" class="btn" id="reports-search-clear" hidden>Clear</button>
<button type="button" class="btn" id="reports-search-clear" hidden data-i18n="reports.clear">Clear</button>
</div>
<p id="reports-status" class="reports-status muted" aria-live="polite">Loading…</p>
<p id="reports-status" class="reports-status muted" aria-live="polite" data-i18n="reports.loading">Loading…</p>
<div class="reports-table-wrap">
<table class="data-table reports-tree reports-table--cols" id="reports-tree">
<colgroup id="reports-colgroup"></colgroup>
@@ -134,19 +157,19 @@
<div class="tag-modal-backdrop" data-tag-modal-close></div>
<div class="tag-modal-panel" role="dialog" aria-labelledby="tag-modal-title">
<header class="tag-modal-header">
<h2 id="tag-modal-title">Edit tags</h2>
<button type="button" class="tag-modal-close" data-tag-modal-close aria-label="Close">×</button>
<h2 id="tag-modal-title" data-i18n="tag.edit_title">Edit tags</h2>
<button type="button" class="tag-modal-close" data-tag-modal-close data-i18n-aria="tag.close" aria-label="Close">×</button>
</header>
<div class="tag-editor tag-editor--modal" id="tag-editor-modal" data-report-id="0">
<div class="tag-editor-preview" id="tag-modal-preview"></div>
<ul class="tag-editor-list" id="tag-modal-list"></ul>
<form class="tag-editor-add" id="tag-modal-add">
<label class="tag-field"><span>Label</span><input type="text" name="label" maxlength="40" required></label>
<label class="tag-field"><span>Color</span><input type="color" name="bg" value="#5c6b82"></label>
<button type="submit" class="btn">Add</button>
<label class="tag-field"><span data-i18n="tag.label">Label</span><input type="text" name="label" maxlength="40" required></label>
<label class="tag-field"><span data-i18n="tag.color">Color</span><input type="color" name="bg" value="#5c6b82"></label>
<button type="submit" class="btn" data-i18n="tag.add">Add</button>
</form>
<div class="tag-preset-bar" id="tag-modal-presets" hidden>
<span class="muted">Suggestions:</span>
<span class="muted" data-i18n="tag.suggestions">Suggestions:</span>
<div class="tag-preset-chips" id="tag-modal-preset-chips"></div>
</div>
<div class="tag-editor-actions">
@@ -157,6 +180,6 @@
</div>
</div>
<?php endif; ?>
<footer class="bottom-bar">Android Cast crash reporter — placeholder footer</footer>
<footer class="bottom-bar" data-i18n="footer.copyright" data-year="<?= (int) date('Y') ?>">(c) Anton Afanaasyeu, <?= (int) date('Y') ?></footer>
</body>
</html>

View File

@@ -10,6 +10,7 @@
* - Cursor Agent (project assistant)
* Digest: SHA256 9bef983f4d34cb790922cda355ccf5d14b565b29273d2ebfe48b3173cd2e8f9f
*/
$bp = Auth::basePath();
?>
<!DOCTYPE html>
<html lang="en">
@@ -17,20 +18,36 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Login — <?= h(cfg('app_name')) ?></title>
<link rel="stylesheet" href="<?= h(Auth::basePath()) ?>/assets/css/app.css">
<script>
(function () {
var l = localStorage.getItem('crash_console_lang');
if (l === 'en' || l === 'ru') document.documentElement.setAttribute('lang', l);
})();
</script>
<link rel="stylesheet" href="<?= h($bp) ?>/assets/css/app.css">
<script src="<?= h($bp) ?>/assets/js/i18n.js" defer></script>
</head>
<body class="login-page">
<form class="login-card" method="post" action="<?= h(Auth::basePath()) ?>/login">
<body class="login-page" data-base-path="<?= h($bp) ?>">
<div class="login-locale">
<label class="toolbar-select">
<span data-i18n="lang.label">Language</span>
<select class="lang-select" aria-label="Language">
<option value="en" data-i18n="lang.en">English</option>
<option value="ru" data-i18n="lang.ru">Russian</option>
</select>
</label>
</div>
<form class="login-card" method="post" action="<?= h($bp) ?>/login">
<h1><?= h(cfg('app_name')) ?></h1>
<p class="muted">Sign in to view anonymous crash reports</p>
<p class="muted" data-i18n="login.sign_in_hint">Sign in to view anonymous crash reports</p>
<?php if (!empty($loginError)): ?>
<div class="alert"><?= h($loginError) ?></div>
<div class="alert" data-i18n="login.error"><?= h($loginError) ?></div>
<?php endif; ?>
<label>Username<input name="username" autocomplete="username" required></label>
<label>Password<input name="password" type="password" autocomplete="current-password" required></label>
<button type="submit">Sign in</button>
<p class="hint">Default: admin / admin</p>
<p class="hint"><a href="#">Register</a> (coming soon)</p>
<label><span data-i18n="login.username">Username</span><input name="username" autocomplete="username" required></label>
<label><span data-i18n="login.password">Password</span><input name="password" type="password" autocomplete="current-password" required></label>
<button type="submit" data-i18n="login.submit">Sign in</button>
<p class="hint" data-i18n="login.hint_default">Default: admin / admin</p>
<p class="hint"><span data-i18n="login.register">Register</span> <span data-i18n="login.register_soon">(coming soon)</span></p>
</form>
</body>
</html>

View File

@@ -34,36 +34,36 @@ if (!empty($device['abis']) && is_array($device['abis'])) {
}
?>
<div class="detail-header">
<a href="<?= h($bp) ?>/?view=reports" class="back-link" aria-label="Back to reports list" title="Back to list">
<a href="<?= h($bp) ?>/?view=reports" class="back-link" data-i18n-aria="detail.back" data-i18n-title="detail.back" aria-label="Back to reports list" title="Back to list">
<span class="back-icon" aria-hidden="true"></span>
</a>
<div>
<h1>Crash report</h1>
<p class="muted">Report <?= h($report['report_id'] ?? '') ?> · <?= h($p['crash_type'] ?? '') ?></p>
<h1 data-i18n="detail.title">Crash report</h1>
<p class="muted" data-i18n="detail.report_meta" data-i18n-id="<?= h($report['report_id'] ?? '') ?>" data-i18n-type="<?= h($p['crash_type'] ?? '') ?>">Report <?= h($report['report_id'] ?? '') ?> · <?= h($p['crash_type'] ?? '') ?></p>
</div>
</div>
<section class="tag-editor" id="tag-editor" data-report-id="<?= $reportDbId ?>">
<div class="tag-editor-head">
<h2>Tags</h2>
<p class="muted tag-editor-hint">Auto tags (NDK / Java / Android / new) appear in the list. Add custom labels below.</p>
<h2 data-i18n="detail.tags">Tags</h2>
<p class="muted tag-editor-hint" data-i18n="tag.hint_auto">Auto tags (NDK / Java / Android / new) appear in the list. Add custom labels below.</p>
</div>
<div class="tag-editor-preview" id="tag-editor-preview" aria-live="polite"></div>
<?php if (Auth::canEditTags()): ?>
<ul class="tag-editor-list" id="tag-editor-list"></ul>
<form class="tag-editor-add" id="tag-editor-add">
<label class="tag-field">
<span>Label</span>
<input type="text" name="label" maxlength="40" required placeholder="e.g. regression">
<span data-i18n="tag.label">Label</span>
<input type="text" name="label" maxlength="40" required data-i18n-placeholder="tag.placeholder" placeholder="e.g. regression">
</label>
<label class="tag-field">
<span>Color</span>
<span data-i18n="tag.color">Color</span>
<input type="color" name="bg" value="#5c6b82">
</label>
<button type="submit" class="btn">Add tag</button>
<button type="submit" class="btn" data-i18n="tag.add_tag">Add tag</button>
</form>
<div class="tag-preset-bar" id="tag-preset-bar" hidden>
<span class="muted">Suggestions:</span>
<span class="muted" data-i18n="tag.suggestions">Suggestions:</span>
<div class="tag-preset-chips" id="tag-preset-chips"></div>
</div>
<div class="tag-editor-actions">
@@ -71,7 +71,7 @@ if (!empty($device['abis']) && is_array($device['abis'])) {
<span class="tag-editor-status muted" id="tag-editor-status"></span>
</div>
<?php else: ?>
<p class="muted">Sign in as admin to edit custom tags.</p>
<p class="muted" data-i18n="tag.hint_admin">Sign in as admin to edit custom tags.</p>
<?php endif; ?>
</section>
<script type="application/json" id="report-custom-tags-data"><?=
@@ -80,12 +80,12 @@ if (!empty($device['abis']) && is_array($device['abis'])) {
<div class="cards cards--lift">
<div class="card card--lift"><h3>Device</h3>
<?php if ($deviceName !== ''): ?>
<p><a class="detail-filter-link" href="<?= h($filterDeviceUrl) ?>" title="Show reports for similar devices"><?= h($deviceName) ?></a></p>
<p><a class="detail-filter-link detail-filter-link--tag" href="<?= h($filterDeviceUrl) ?>" title="Show reports for similar devices"><?= h($deviceName) ?></a></p>
<?php else: ?>
<p class="muted">—</p>
<?php endif; ?>
<?php if ($filterOsUrl !== ''): ?>
<p><a class="detail-filter-link" href="<?= h($filterOsUrl) ?>" title="Show reports on this OS">Android <?= h($release) ?> (SDK <?= $sdk ?>)</a></p>
<p><a class="detail-filter-link detail-filter-link--tag" href="<?= h($filterOsUrl) ?>" title="Show reports on this OS">Android <?= h($release) ?> (SDK <?= $sdk ?>)</a></p>
<?php else: ?>
<p>Android <?= h($release) ?> (SDK <?= $sdk ?>)</p>
<?php endif; ?>
@@ -98,7 +98,7 @@ if (!empty($device['abis']) && is_array($device['abis'])) {
</p>
<?php endif; ?>
</div>
<div class="card card--lift"><h3>App</h3>
<div class="card card--lift"><h3 data-i18n="detail.app">App</h3>
<p><?= h($app['package'] ?? '') ?></p>
<?php
$versionName = (string) ($app['version_name'] ?? '');
@@ -107,7 +107,7 @@ if (!empty($device['abis']) && is_array($device['abis'])) {
: '';
?>
<?php if ($filterAppUrl !== ''): ?>
<p><a class="detail-filter-link" href="<?= h($filterAppUrl) ?>" title="Show reports for this app version">v<?= h($versionName) ?></a> (<?= h((string)($app['version_code'] ?? '')) ?>)</p>
<p><a class="detail-filter-link detail-filter-link--tag" href="<?= h($filterAppUrl) ?>" data-i18n-title="detail.filter_version" title="Show reports for this app version">v<?= h($versionName) ?></a> <span class="muted">(<?= h((string)($app['version_code'] ?? '')) ?>)</span></p>
<?php else: ?>
<p>v<?= h($versionName) ?> (<?= h((string)($app['version_code'] ?? '')) ?>)</p>
<?php endif; ?>
@@ -119,9 +119,9 @@ if (!empty($device['abis']) && is_array($device['abis'])) {
: '';
?>
<?php if ($filterBuildUrl !== ''): ?>
<p class="muted">Build: <a class="detail-filter-link" href="<?= h($filterBuildUrl) ?>" title="Show reports from this build"><?= h($buildId) ?></a></p>
<p><span class="muted" data-i18n="detail.build">Build:</span> <a class="detail-filter-link detail-filter-link--tag" href="<?= h($filterBuildUrl) ?>" data-i18n-title="detail.filter_build" title="Show reports from this build"><?= h($buildId) ?></a></p>
<?php else: ?>
<p class="muted">Build: <?= h($buildId) ?></p>
<p class="muted"><span data-i18n="detail.build">Build:</span> <?= h($buildId) ?></p>
<?php endif; ?>
<?php endif; ?>
</div>
@@ -138,7 +138,7 @@ if (!empty($device['abis']) && is_array($device['abis'])) {
<a class="btn btn-similar" href="<?= h($similarUrl) ?>">Find similar reports</a>
</div>
<p class="exc"><?= h($j['exception'] ?? '') ?><?= !empty($j['message']) ? ': ' . h($j['message']) : '' ?></p>
<p class="muted">Thread: <?= h($j['thread'] ?? '') ?></p>
<p class="muted"><span data-i18n="detail.thread">Thread:</span> <?= h($j['thread'] ?? '') ?></p>
<pre class="stack"><?php foreach ($j['stack_frames'] ?? [] as $frame) { echo h((string)$frame) . "\n"; } ?></pre>
</section>
<?php endif; ?>
@@ -176,7 +176,7 @@ if (!empty($device['abis']) && is_array($device['abis'])) {
<span class="report-tree-arrow" aria-hidden="true"></span>
</button>
</span>
<span class="detail-tree-caption">Raw JSON</span>
<span class="detail-tree-caption" data-i18n="detail.raw_json">Raw JSON</span>
</div>
<div class="detail-tree-body" id="detail-raw-json" hidden>
<pre class="stack raw-json"><?= h($rawJson) ?></pre>