/** * JS-порт `backend/app/core/auth.py::is_path_allowed`. * * Backend — source of truth: глобы из `auth/roles.yaml` парсятся в regex * по тем же правилам что и здесь. Фронт использует это только для UI-gating * (фильтр top-nav, route-guard перед навигацией) — реальная проверка * по-прежнему происходит в backend middleware на каждый /api/v1/* request. * * Семантика glob (mirror of `_glob_to_regex`): * `/**` → matches everything (admin scope) * `/foo/**` → matches `/foo`, `/foo/`, `/foo/bar`, `/foo/bar/baz`, … * `/foo/*` → matches one segment after `/foo/` (no slashes) * `/foo` → exact match only * * Path allowed iff (a) matches >=1 allow-pattern AND (b) doesn't match any * deny-pattern. Deny final (admin uses empty deny → всегда побеждает). */ const REGEX_META = /[.+^${}()|[\]\\]/g; function escapeRegex(s: string): string { return s.replace(REGEX_META, "\\$&"); } function globToRegex(pattern: string): RegExp { // Sentinels — никогда не встречаются в реальном path или в glob-метасимволах. const dstar = "\x00DSTAR\x00"; const sstar = "\x00SSTAR\x00"; const work = pattern.replace(/\*\*/g, dstar).replace(/\*/g, sstar); let regex = escapeRegex(work); regex = regex.replace(new RegExp(escapeRegex(dstar), "g"), ".*"); regex = regex.replace(new RegExp(escapeRegex(sstar), "g"), "[^/]*"); // Special-case `/foo/**` → также матчит `/foo` (без trailing-сегмента), // чтобы `/admin/**` покрывал bare `/admin` request — что и ждут callers. if (regex.endsWith("/.*")) { regex = regex.slice(0, -"/.*".length) + "(?:/.*)?"; } return new RegExp(`^${regex}$`); } // Кэш скомпилированных regex — UI часто гоняет isPathAllowed на каждый рендер // nav. patterns массив маленький и стабильный per-session, поэтому Map по // pattern-строке достаточно. const REGEX_CACHE = new Map(); function compileGlob(pattern: string): RegExp { const cached = REGEX_CACHE.get(pattern); if (cached) return cached; const compiled = globToRegex(pattern); REGEX_CACHE.set(pattern, compiled); return compiled; } /** * Mirror of backend `is_path_allowed(role, path)`. * * @param allowedPaths — глобы из `/api/v1/me` `allowed_paths` * @param denyPaths — глобы из `/api/v1/me` `deny_paths` * @param path — путь который пытаемся открыть (например `/admin/scrape`) * * Note: role-аргумент не нужен — allow/deny уже резолвлены backend'ом для * текущего юзера и приехали в /me. Этот хелпер чистый list-matcher. */ export function isPathAllowed( allowedPaths: readonly string[], denyPaths: readonly string[], path: string, ): boolean { // Deny overrides allow — даже если path в allowed, deny-match блокирует. for (const p of denyPaths) { if (compileGlob(p).test(path)) return false; } for (const p of allowedPaths) { if (compileGlob(p).test(path)) return true; } return false; }