Files
cleanUserUrls/CleanUserUrlsPlugin.php
2026-09-15 04:58:49 +07:00

320 lines
10 KiB
PHP

<?php
/**
* @file plugins/generic/cleanUserUrls/CleanUserUrlsPlugin.php
*
* A small site-wide OJS 3.5 plugin that replaces the site-level account menu
* URLs with clean aliases. Apache/Nginx must map those aliases back to OJS.
*/
namespace APP\plugins\generic\cleanUserUrls;
use APP\core\Application;
use PKP\navigationMenu\NavigationMenuItem;
use PKP\plugins\GenericPlugin;
use PKP\plugins\Hook;
use PKP\security\Validation;
use PKP\template\PKPTemplateManager;
class CleanUserUrlsPlugin extends GenericPlugin
{
/**
* Register hooks when the plugin is enabled.
*
* @param string $category
* @param string $path
* @param mixed $mainContextId
*/
public function register($category, $path, $mainContextId = null)
{
$success = parent::register($category, $path, $mainContextId);
if ($success && $this->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/<locale>/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/<locale>/index -> /
$rootUrl = htmlspecialchars($baseUrl . '/', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$siteNamePattern =
'~(<div\s+class=["\']pkp_site_name["\'][^>]*>\s*<a\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;
}
}