diff --git a/examples/crash_reporter/backend/README.md b/examples/crash_reporter/backend/README.md
index 466cbdc..a7bd08d 100644
--- a/examples/crash_reporter/backend/README.md
+++ b/examples/crash_reporter/backend/README.md
@@ -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
diff --git a/examples/crash_reporter/backend/config/config.example.php b/examples/crash_reporter/backend/config/config.example.php
index 853638a..9efd064 100644
--- a/examples/crash_reporter/backend/config/config.example.php
+++ b/examples/crash_reporter/backend/config/config.example.php
@@ -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,
+ ],
];
diff --git a/examples/crash_reporter/backend/data/test-rbac.sqlite b/examples/crash_reporter/backend/data/test-rbac.sqlite
new file mode 100644
index 0000000..0a4be1a
Binary files /dev/null and b/examples/crash_reporter/backend/data/test-rbac.sqlite differ
diff --git a/examples/crash_reporter/backend/public/api/report_tags.php b/examples/crash_reporter/backend/public/api/report_tags.php
index f012031..8862f1e 100644
--- a/examples/crash_reporter/backend/public/api/report_tags.php
+++ b/examples/crash_reporter/backend/public/api/report_tags.php
@@ -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);
diff --git a/examples/crash_reporter/backend/public/assets/css/app.css b/examples/crash_reporter/backend/public/assets/css/app.css
index 374deab..0e7e487 100644
--- a/examples/crash_reporter/backend/public/assets/css/app.css
+++ b/examples/crash_reporter/backend/public/assets/css/app.css
@@ -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; }
diff --git a/examples/crash_reporter/backend/public/assets/i18n/en.json b/examples/crash_reporter/backend/public/assets/i18n/en.json
new file mode 100644
index 0000000..a86b333
--- /dev/null
+++ b/examples/crash_reporter/backend/public/assets/i18n/en.json
@@ -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}"
+}
diff --git a/examples/crash_reporter/backend/public/assets/i18n/ru.json b/examples/crash_reporter/backend/public/assets/i18n/ru.json
new file mode 100644
index 0000000..6afc935
--- /dev/null
+++ b/examples/crash_reporter/backend/public/assets/i18n/ru.json
@@ -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}"
+}
diff --git a/examples/crash_reporter/backend/public/assets/js/app.js b/examples/crash_reporter/backend/public/assets/js/app.js
index 0a999d0..34568e7 100644
--- a/examples/crash_reporter/backend/public/assets/js/app.js
+++ b/examples/crash_reporter/backend/public/assets/js/app.js
@@ -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 +=
'
' +
(grouped
? ''
- : '⋮⋮') +
+ : '⋮⋮') +
'' +
- escapeHtml(c.label) +
+ escapeHtml(colLabel(c)) +
' ' +
arrow +
'' +
@@ -820,7 +841,9 @@
tbody.innerHTML =
' | | No reports yet. |
';
+ '" class="muted">' +
+ escapeHtml(t('reports.empty')) +
+ '';
return;
}
let html = '';
@@ -838,7 +861,9 @@
html +=
' | ';
+ '" title="' +
+ escapeHtml(t('row.show_summary')) +
+ '">';
cols.forEach((c) => {
if (c.key === 'cnt') {
html += '' + escapeHtml(row.cnt) + ' | ';
@@ -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 +=
'';
+ '" aria-label="' + escapeHtml(t('tag.remove')) + '">×';
}
return html + '';
}
@@ -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);
})();
diff --git a/examples/crash_reporter/backend/public/assets/js/i18n.js b/examples/crash_reporter/backend/public/assets/js/i18n.js
new file mode 100644
index 0000000..252c1e4
--- /dev/null
+++ b/examples/crash_reporter/backend/public/assets/js/i18n.js
@@ -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|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);
diff --git a/examples/crash_reporter/backend/sql/migrations/001_rbac_companies.sql b/examples/crash_reporter/backend/sql/migrations/001_rbac_companies.sql
new file mode 100644
index 0000000..fcf2acb
--- /dev/null
+++ b/examples/crash_reporter/backend/sql/migrations/001_rbac_companies.sql
@@ -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');
diff --git a/examples/crash_reporter/backend/sql/schema.mariadb.sql b/examples/crash_reporter/backend/sql/schema.mariadb.sql
index b0427e5..bf2fb20 100644
--- a/examples/crash_reporter/backend/sql/schema.mariadb.sql
+++ b/examples/crash_reporter/backend/sql/schema.mariadb.sql
@@ -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');
diff --git a/examples/crash_reporter/backend/sql/schema.sqlite.sql b/examples/crash_reporter/backend/sql/schema.sqlite.sql
index 8d6a6ed..8b5dd3d 100644
--- a/examples/crash_reporter/backend/sql/schema.sqlite.sql
+++ b/examples/crash_reporter/backend/sql/schema.sqlite.sql
@@ -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');
diff --git a/examples/crash_reporter/backend/src/Auth.php b/examples/crash_reporter/backend/src/Auth.php
index 883b428..e3cef37 100644
--- a/examples/crash_reporter/backend/src/Auth.php
+++ b/examples/crash_reporter/backend/src/Auth.php
@@ -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;
}
diff --git a/examples/crash_reporter/backend/src/Database.php b/examples/crash_reporter/backend/src/Database.php
index b373c22..db2fba8 100644
--- a/examples/crash_reporter/backend/src/Database.php
+++ b/examples/crash_reporter/backend/src/Database.php
@@ -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 {
diff --git a/examples/crash_reporter/backend/src/DeviceRepository.php b/examples/crash_reporter/backend/src/DeviceRepository.php
new file mode 100644
index 0000000..d207497
--- /dev/null
+++ b/examples/crash_reporter/backend/src/DeviceRepository.php
@@ -0,0 +1,103 @@
+ $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();
+ }
+}
diff --git a/examples/crash_reporter/backend/src/Rbac.php b/examples/crash_reporter/backend/src/Rbac.php
new file mode 100644
index 0000000..16a079f
--- /dev/null
+++ b/examples/crash_reporter/backend/src/Rbac.php
@@ -0,0 +1,290 @@
+ */
+ 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|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 $where
+ * @param list $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
+ */
+ 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 $memberships
+ */
+ public static function pickActiveCompanyId(array $memberships): int {
+ if ($memberships === []) {
+ return self::defaultCompanyId();
+ }
+ return (int) $memberships[0]['id'];
+ }
+
+ /** @return array 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);
+ }
+}
diff --git a/examples/crash_reporter/backend/src/ReportRepository.php b/examples/crash_reporter/backend/src/ReportRepository.php
index 4a721f3..e10205b 100644
--- a/examples/crash_reporter/backend/src/ReportRepository.php
+++ b/examples/crash_reporter/backend/src/ReportRepository.php
@@ -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 $where
+ * @param list $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})";
+ }
}
diff --git a/examples/crash_reporter/backend/src/bootstrap.php b/examples/crash_reporter/backend/src/bootstrap.php
index c0bf737..50b1b23 100644
--- a/examples/crash_reporter/backend/src/bootstrap.php
+++ b/examples/crash_reporter/backend/src/bootstrap.php
@@ -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';
diff --git a/examples/crash_reporter/backend/views/layout.php b/examples/crash_reporter/backend/views/layout.php
index c5ea72c..e646d59 100644
--- a/examples/crash_reporter/backend/views/layout.php
+++ b/examples/crash_reporter/backend/views/layout.php
@@ -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);
})();
+
- Crash console
- Anonymous Android Cast crash ingest is active. Use Reports to analyze grouped fingerprints.
+ Crash console
+ Anonymous Android Cast crash ingest is active. Use Reports to analyze grouped fingerprints.
- - Upload API:
= h(Auth::basePath()) ?>/api/upload.php
- - Schema:
schema_version: 1
+ - Upload API:
= h(Auth::basePath()) ?>/api/upload.php
+ - Schema:
schema_version: 1