Clean-room copy of the reusable engines from comiida, with all instance data, secrets, dependencies, and build output excluded: - app/ Astro theme skeleton (no comiida blog posts; hero image -> placeholder) - api/ SeedProject PHP framework (no vendor/.env/config.php) - content-pipeline/ engine only (scripts/admin/prompts; empty runtime state) - astroagent.config.json + app/.astroagent/skills Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SYHWLHihq3v9nxNwoPCKSn
146 lines
5.7 KiB
PHP
146 lines
5.7 KiB
PHP
<?php
|
|
|
|
class Menu {
|
|
|
|
/**
|
|
* Resolve a menu item URL by preferred labels within a menu slug.
|
|
* Useful for linking static template buttons to Menu Builder-managed URLs.
|
|
*/
|
|
public static function getItemUrl(string $slug, array $labels, string $fallback = '#'): string {
|
|
if (empty($labels)) return $fallback;
|
|
|
|
$rows = Db::select("
|
|
SELECT mi.label, mi.url
|
|
FROM sp_menu_items mi
|
|
JOIN sp_menus m ON m.menu_id = mi.menu_id
|
|
WHERE m.slug = ? AND mi.active = 1 AND m.active = 1
|
|
AND mi.type = 'link' AND mi.url IS NOT NULL
|
|
", [$slug]);
|
|
|
|
if (!$rows) return $fallback;
|
|
|
|
$urlByLabel = [];
|
|
foreach ($rows as $row) {
|
|
$key = strtolower(trim((string)$row['label']));
|
|
$urlByLabel[$key] = (string)$row['url'];
|
|
}
|
|
|
|
foreach ($labels as $label) {
|
|
$key = strtolower(trim((string)$label));
|
|
if (isset($urlByLabel[$key]) && trim($urlByLabel[$key]) !== '') {
|
|
return $urlByLabel[$key];
|
|
}
|
|
}
|
|
|
|
return $fallback;
|
|
}
|
|
|
|
/**
|
|
* Returns the full nested menu tree for the given slug,
|
|
* filtered by the current user's permissions (loaded from their role).
|
|
*/
|
|
public static function getTree(string $slug): array {
|
|
$rows = Db::select("
|
|
SELECT mi.*, p.perm_controller, p.perm_action
|
|
FROM sp_menu_items mi
|
|
JOIN sp_menus m ON m.menu_id = mi.menu_id
|
|
LEFT JOIN sp_permissions p ON p.perm_id = mi.perm_id
|
|
WHERE m.slug = ? AND mi.active = 1 AND m.active = 1
|
|
ORDER BY mi.sort_order ASC
|
|
", [$slug]);
|
|
|
|
if (!$rows) return [];
|
|
|
|
$isSysAdmin = !empty($_SESSION['login']['sysadmin']);
|
|
if (!$isSysAdmin) {
|
|
$userPerms = $_SESSION['permissions'] ?? [];
|
|
$rows = array_values(array_filter($rows, function($item) use ($userPerms) {
|
|
if (!$item['perm_id']) return true;
|
|
$key = $item['perm_controller'] . '.' . $item['perm_action'];
|
|
return in_array($key, $userPerms);
|
|
}));
|
|
}
|
|
|
|
$currentUrl = strtok($_SERVER['REQUEST_URI'], '?');
|
|
return self::buildTree($rows, $currentUrl);
|
|
}
|
|
|
|
/**
|
|
* Resolves dynamic URL tokens for the logged-in user.
|
|
*
|
|
* Supported tokens (use in the menu builder URL field):
|
|
* %ORG_TOKEN% → encrypted slug of the user's company → /company/%ORG_TOKEN%
|
|
* %USER_TOKEN% → encrypted slug of the logged-in user → /account/%USER_TOKEN%/edit
|
|
*/
|
|
private static function resolveUrlTokens(string $url): string {
|
|
if (strpos($url, '%') === false) return $url;
|
|
|
|
if (strpos($url, '%ORG_TOKEN%') !== false) {
|
|
$orgId = $_SESSION['login']['orgid'] ?? 0;
|
|
if ($orgId) {
|
|
$token = Functions::encryptData((string)$orgId, HASH_PASSWORD_KEY);
|
|
$url = str_replace('%ORG_TOKEN%', 'org.' . $token, $url);
|
|
}
|
|
}
|
|
|
|
if (strpos($url, '%USER_TOKEN%') !== false) {
|
|
$userId = $_SESSION['login']['userid'] ?? 0;
|
|
if ($userId) {
|
|
$token = Functions::encryptData((string)$userId, HASH_PASSWORD_KEY);
|
|
$url = str_replace('%USER_TOKEN%', 'user.' . $token, $url);
|
|
}
|
|
}
|
|
|
|
return $url;
|
|
}
|
|
|
|
private static function buildTree(array $rows, string $currentUrl, int $parentId = 0): array {
|
|
$branch = [];
|
|
foreach ($rows as $row) {
|
|
if ((int)$row['parent_id'] === $parentId) {
|
|
$row['children'] = self::buildTree($rows, $currentUrl, (int)$row['item_id']);
|
|
|
|
$rawUrl = $row['url'] ?? '';
|
|
$itemUrl = rtrim(self::resolveUrlTokens($rawUrl), '/');
|
|
$row['url'] = $itemUrl; // write resolved URL back for the renderer
|
|
$curUrl = rtrim($currentUrl, '/');
|
|
|
|
// URLs containing dynamic tokens (e.g. %ORG_TOKEN%) are encrypted with a
|
|
// random IV, so the resolved URL differs on every request and can never
|
|
// equal the token already present in the browser's address bar.
|
|
// Instead, match on the static path segment that precedes the token.
|
|
if (strpos($rawUrl, '%') !== false) {
|
|
$staticPrefix = rtrim(preg_replace('/%[^%]+%.*$/', '', $rawUrl), '/');
|
|
$row['is_active'] = $staticPrefix !== '' && (
|
|
$curUrl === $staticPrefix ||
|
|
str_starts_with($curUrl, $staticPrefix . '/')
|
|
);
|
|
} else {
|
|
$row['is_active'] = $itemUrl !== '' && $curUrl === $itemUrl;
|
|
}
|
|
|
|
if (!empty($row['children'])) {
|
|
$row['is_open'] = $row['is_active'] || self::hasActiveChild($row['children']);
|
|
} elseif ($row['match_prefix'] && $itemUrl !== '') {
|
|
$row['is_active'] = str_starts_with($currentUrl, $itemUrl);
|
|
$row['is_open'] = $row['is_active'];
|
|
} else {
|
|
$row['is_open'] = false;
|
|
}
|
|
|
|
// Skip section headers with no visible children
|
|
if ($row['type'] === 'header' && empty($row['children'])) continue;
|
|
|
|
$branch[] = $row;
|
|
}
|
|
}
|
|
return $branch;
|
|
}
|
|
|
|
private static function hasActiveChild(array $children): bool {
|
|
foreach ($children as $child) {
|
|
if ($child['is_active'] || $child['is_open']) return true;
|
|
}
|
|
return false;
|
|
}
|
|
}
|