From 0d9cf0cf7c7bf95a45bfd836025fb6362e98fa45 Mon Sep 17 00:00:00 2001 From: Anton Afanasyeu Date: Sat, 23 May 2026 00:47:31 +0200 Subject: [PATCH] sync --- examples/crash_reporter/backend/README.md | 26 ++ .../backend/config/config.example.php | 5 + .../backend/data/test-rbac.sqlite | Bin 0 -> 73728 bytes .../backend/public/api/report_tags.php | 10 +- .../backend/public/assets/css/app.css | 42 ++- .../backend/public/assets/i18n/en.json | 116 +++++++ .../backend/public/assets/i18n/ru.json | 115 +++++++ .../backend/public/assets/js/app.js | 108 +++++-- .../backend/public/assets/js/i18n.js | 168 ++++++++++ .../sql/migrations/001_rbac_companies.sql | 46 +++ .../backend/sql/schema.mariadb.sql | 52 +++- .../backend/sql/schema.sqlite.sql | 46 ++- examples/crash_reporter/backend/src/Auth.php | 39 ++- .../crash_reporter/backend/src/Database.php | 182 +++++++++++ .../backend/src/DeviceRepository.php | 103 +++++++ examples/crash_reporter/backend/src/Rbac.php | 290 ++++++++++++++++++ .../backend/src/ReportRepository.php | 115 +++++-- .../crash_reporter/backend/src/bootstrap.php | 2 + .../crash_reporter/backend/views/layout.php | 75 +++-- .../crash_reporter/backend/views/login.php | 37 ++- .../backend/views/report_detail.php | 38 +-- 21 files changed, 1467 insertions(+), 148 deletions(-) create mode 100644 examples/crash_reporter/backend/data/test-rbac.sqlite create mode 100644 examples/crash_reporter/backend/public/assets/i18n/en.json create mode 100644 examples/crash_reporter/backend/public/assets/i18n/ru.json create mode 100644 examples/crash_reporter/backend/public/assets/js/i18n.js create mode 100644 examples/crash_reporter/backend/sql/migrations/001_rbac_companies.sql create mode 100644 examples/crash_reporter/backend/src/DeviceRepository.php create mode 100644 examples/crash_reporter/backend/src/Rbac.php 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 0000000000000000000000000000000000000000..0a4be1a566a9246e2658a4b5f404c0b3535a7091 GIT binary patch literal 73728 zcmeI*&2Q6Y9Kdm#va%7q_ajurn^PGtU2 z#{Vk*GX89AYur0FH+C_9?b!R-U-J{A-)DasJw5XC$bziGga88nufY0=oc772`gqiC zH^h$J*fAZ^?KqxmuG#H%;dC9l?S(}rXX{3F$k25n8G zwl%03ZER2&ZSDSeR;x^^-yCRcwbSZa?cK0j#nh(4deMf$BGYb^z8gQD(@vjOpO~KY zpecgAnOnBlcB9K<;qI5JpD!3;0D@ZjbU~NXrYjt|KN-9w9P+VaA}Ie%@q ztJL+q_CzD>cJeOn*5_|m>nr*#V@011TRYc364{ZUq3d_*#{7+1P;1a?Nv|8%jk-~r zH5LciBe&_9%oz)YOuE_X;%s%!D3m@P%WAhytBUXKK5EJ(%9jC4DBS z+7Mgzs)$5ne;}5OFJ*+nDCx_!`FqPoP$x31^jNL%ARNoxFumQbNLsr;hm(W3Z&)Q) z3cJwiG(^*HDrn#8cFir}xOS%TG7Om`)@@fF$ZJj^P0RI6SBUm2Pc$vzS$mlh9B?o^^y$5HIHyOt zo^vWo_aSpl?M!GAUmhJY&eq=NU`&e~E@6sz{8_&c+ z#!NTlde3f&=`-!l_L))`wSH^|*Oa}ZO^Rz+Ef&;X+5TZ21~fRfH$JB%WrmTZxK8=U?*AB#Wrbh#R(UWO?1NKq?DEpJTz9+UG|UZo z9f^nP^~vD>qTqzd@a7ROluDyn?Ncoc(eJc5mG9zy>j`nUddN=uM@pnajF(H5e0tg_000IagfB*srAb`Lj z6ZlXaRVF7Z4Y6izHoduER=!lea{kig^X0N$E?@nqab009IL zKmY**5I_I{1P~Y|fkI|7AO2;a_xwLo{7otTUHntdm=Hh!0R#|0009ILKmY**5J2D% z2z-#qRPsq5IXRNi^8RNkj;ooeu!j5~K=A(mXG-zeA?O^3hyVfzAbE2qsx4zzJUZ{S(as6g}rqa3KbUI$@`~P1k#TUZ~NUsn;009ILKmY**5I_I{ z1Q2+K1&+#h1NeVZ2%i5xSBlTyVGX*D00IagfB*srAbbRPLoosf zAb $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)) + ' ' + @@ -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: /api/upload.php
  • -
  • Schema: schema_version: 1
  • +
  • Upload API: /api/upload.php
  • +
  • Schema: schema_version: 1
-

Crash reports

+

Crash reports

- - + + +
- +
-

Loading…

+

Loading…

@@ -134,19 +157,19 @@