getEnabled($mainContextId)) { Hook::add('NavigationMenus::displaySettings', $this->rewriteAccountMenuUrl(...)); Hook::add('TemplateManager::display', $this->registerOutputFilter(...)); } return $success; } public function getDisplayName() { return __('plugins.generic.cleanUserUrls.displayName'); } public function getDescription() { return __('plugins.generic.cleanUserUrls.description'); } /** * This plugin is intended to be enabled from Site Settings and affect only * the site-wide context. Journal-context navigation is left untouched. */ public function isSitePlugin(): bool { return true; } /** * Register one conservative output filter for site-level pages. * * It only changes: * 1. the public site-name link /index//index -> / * 2. the exact Administration breadcrumb link /index/id/admin[/index] -> /admin * * Child admin routes such as /index/id/admin/settings are deliberately * left untouched. */ public function registerOutputFilter(string $hookName, array $args): bool { $templateMgr = $args[0] ?? null; $request = Application::get()->getRequest(); if (!$templateMgr instanceof PKPTemplateManager || $request->getContext()) { return false; } $templateMgr->registerFilter('output', $this->rewriteSafeSiteLinks(...)); // The backend user menu is rendered by Vue after the server-side // Smarty output filter has finished. Add a tiny, site-level backend // helper that updates only the generated Edit Profile and Log Out links. $baseUrl = rtrim($request->getBaseUrl(), '/'); $legacyProfileUrl = $baseUrl . '/index/id/user/profile'; $cleanProfileUrl = $baseUrl . '/user/profile'; $legacyLogoutUrl = $baseUrl . '/index/id/login/signOut'; $cleanLogoutUrl = $baseUrl . '/logout'; $script = <<<'JS' (function () { const replacements = new Map([ [__LEGACY_PROFILE_URL__, __CLEAN_PROFILE_URL__], [__LEGACY_LOGOUT_URL__, __CLEAN_LOGOUT_URL__], ]); function cleanBackendUserLinks(root) { const scope = root && root.querySelectorAll ? root : document; scope.querySelectorAll('a[href]').forEach(function (link) { const replacement = replacements.get(link.href); if (replacement) { link.href = replacement; } }); } function start() { cleanBackendUserLinks(document); const observer = new MutationObserver(function (mutations) { mutations.forEach(function (mutation) { mutation.addedNodes.forEach(function (node) { if (node.nodeType !== Node.ELEMENT_NODE) { return; } if (node.matches && node.matches('a[href]')) { const replacement = replacements.get(node.href); if (replacement) { node.href = replacement; } } cleanBackendUserLinks(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', start, { once: true }); } else { start(); } })(); JS; $script = str_replace( [ '__LEGACY_PROFILE_URL__', '__CLEAN_PROFILE_URL__', '__LEGACY_LOGOUT_URL__', '__CLEAN_LOGOUT_URL__', ], [ json_encode($legacyProfileUrl), json_encode($cleanProfileUrl), json_encode($legacyLogoutUrl), json_encode($cleanLogoutUrl), ], $script ); $templateMgr->addJavaScript( 'cleanUserUrlsBackendUserMenu', $script, [ 'contexts' => ['backend'], 'inline' => true, ] ); return false; } /** * Rewrite known-safe site-level links. * * Normal site-level Administration page URLs are cleaned: * /index/id/admin -> /admin * /index/id/admin/index -> /admin * /index/id/admin/contexts -> /admin/contexts * /index/id/admin/settings -> /admin/settings * /index/id/admin/systemInfo -> /admin/systemInfo * /index/id/admin/jobs -> /admin/jobs * /index/id/admin/failedJobs -> /admin/failedJobs * * AJAX/component ($$$call$$$), API, journal-context and other sensitive * routes are deliberately left untouched. */ public function rewriteSafeSiteLinks(string $output, $template): string { $request = Application::get()->getRequest(); if ($request->getContext()) { return $output; } $baseUrl = rtrim($request->getBaseUrl(), '/'); $escapedBase = preg_quote($baseUrl, '~'); // Public site-name link: /index//index -> / $rootUrl = htmlspecialchars($baseUrl . '/', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); $siteNamePattern = '~(]*>\s*]*href=["\'])' . $escapedBase . '/index/[^/"\']+/index/?(["\'])~is'; $output = preg_replace_callback( $siteNamePattern, static fn (array $matches): string => $matches[1] . $rootUrl . $matches[2], $output, 1 ) ?? $output; // Clean ordinary site-level user links/actions in the // Indonesian/default locale. // // Examples: // /index/id/user/profile -> /user/profile // /index/id/user/register -> /user/register // // Component/AJAX routes are not matched by this pattern. $userPattern = '~\b(href|action)=(["\'])' . $escapedBase . '/index/id/user(?:/([^"\'?#]*))?([^"\']*)\2~i'; $output = preg_replace_callback( $userPattern, static function (array $matches) use ($baseUrl): string { $attribute = $matches[1]; $quote = $matches[2]; $tail = isset($matches[3]) ? trim($matches[3], '/') : ''; $suffix = $matches[4] ?? ''; $clean = $baseUrl . '/user'; if ($tail !== '' && $tail !== 'index') { $clean .= '/' . $tail; } return $attribute . '=' . $quote . $clean . $suffix . $quote; }, $output ) ?? $output; // Clean ordinary site-level Administration links/actions in the // Indonesian/default locale. Query strings/fragments are preserved. $adminPattern = '~\b(href|action)=(["\'])' . $escapedBase . '/index/id/admin(?:/([^"\'?#]*))?([^"\']*)\2~i'; $output = preg_replace_callback( $adminPattern, static function (array $matches) use ($baseUrl): string { $attribute = $matches[1]; $quote = $matches[2]; $tail = isset($matches[3]) ? trim($matches[3], '/') : ''; $suffix = $matches[4] ?? ''; // /admin and /admin/index both become the clean admin root. if ($tail === '' || $tail === 'index') { $clean = $baseUrl . '/admin'; } else { $clean = $baseUrl . '/admin/' . $tail; } return $attribute . '=' . $quote . $clean . $suffix . $quote; }, $output ) ?? $output; return $output; } /** * Replace site-level account navigation URLs with clean aliases. * * @param string $hookName * @param array $params [$navigationMenuItem, $navigationMenu] */ public function rewriteAccountMenuUrl($hookName, $params): bool { $navigationMenuItem = $params[0] ?? null; if (!$navigationMenuItem instanceof NavigationMenuItem) { return false; } $request = Application::get()->getRequest(); // Do not alter account-menu routes while inside a journal context. if ($request->getContext()) { return false; } $baseUrl = rtrim($request->getBaseUrl(), '/'); switch ($navigationMenuItem->getType()) { case NavigationMenuItem::NMI_TYPE_USER_DASHBOARD: $navigationMenuItem->setUrl($baseUrl . '/dashboard'); break; case NavigationMenuItem::NMI_TYPE_USER_PROFILE: $navigationMenuItem->setUrl($baseUrl . '/profile'); break; case NavigationMenuItem::NMI_TYPE_ADMINISTRATION: $navigationMenuItem->setUrl($baseUrl . '/admin'); break; case NavigationMenuItem::NMI_TYPE_USER_LOGOUT: // When a site administrator is impersonating another user, // preserve OJS' special signOutAsUser URL and behavior. if (!Validation::loggedInAs()) { $navigationMenuItem->setUrl($baseUrl . '/logout'); } break; } return false; } }