Smail/frontend/src/utils/navigation.mjs

158 lines
4.9 KiB
JavaScript

const BLOCKED_REDIRECT_PATHS = ["/login", "/login-verification", "/setup"];
const ASSET_RECOVERY_KEY = 'smail:route-asset-recovery';
const READ_ONLY_ROUTES = new Set(['home', 'list', 'detail']);
const RECOVERABLE_ROUTES = new Set([...READ_ONLY_ROUTES, 'editer']);
function isRouteAssetLoadError(error) {
const message = typeof error?.message === 'string' ? error.message : '';
return /^Unable to preload CSS for /.test(message)
|| /^Failed to fetch dynamically imported module:/.test(message)
|| /^error loading dynamically imported module/i.test(message)
|| /^Importing a module script failed/.test(message)
|| (error?.name === 'ChunkLoadError' && /^Loading chunk /.test(message));
}
function isReadOnlyRoute(route) {
return READ_ONLY_ROUTES.has(route?.name);
}
function createRouteAssetRecovery({buildID, getStorage, reload}) {
let recovering = false;
return function recoverRouteAsset(error, to, from, {editing = false, online = true} = {}) {
if (!isRouteAssetLoadError(error) || !isReadOnlyRoute(from) || editing || !online) {
return false;
}
let recoveryPath = to?.fullPath;
if (to?.name === 'login' && to.path === '/login') {
// 旧邮件页登录失效时也可能缺失登录资源,只重建安全的登录目标。
const query = new URLSearchParams({redirect: getSafeLoginRedirect(to.query?.redirect)});
recoveryPath = `/login?${query}`;
} else if (!RECOVERABLE_ROUTES.has(to?.name) || !recoveryPath
|| getSafeLoginRedirect(recoveryPath, '') !== recoveryPath) {
return false;
}
if (recovering) {
return true;
}
try {
const storage = getStorage();
if (!buildID || storage.getItem(ASSET_RECOVERY_KEY) === buildID) {
return false;
}
// 先持久化重试标记;同一标签页的同一构建最多自动恢复一次。
storage.setItem(ASSET_RECOVERY_KEY, buildID);
recovering = true;
reload(recoveryPath);
return true;
} catch {
// 浏览器禁用存储或导航失败时保留手动恢复入口,不能无限刷新。
recovering = false;
return false;
}
};
}
function decodeRedirect(value) {
let decoded = value;
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
const next = decodeURIComponent(decoded);
if (next === decoded) {
break;
}
decoded = next;
} catch {
break;
}
}
return decoded;
}
function getSafeLoginRedirect(value, fallback = "/") {
if (typeof value !== "string") {
return fallback;
}
const redirect = value.trim();
if (!redirect.startsWith("/") || redirect.startsWith("//")) {
return fallback;
}
const decodedRedirect = decodeRedirect(redirect);
if (!decodedRedirect.startsWith("/") || decodedRedirect.startsWith("//") || decodedRedirect.includes("\\")) {
return fallback;
}
const decodedPath = decodedRedirect
.split(/[?#]/, 1)[0]
.replace(/\/+$/, "")
.toLowerCase() || "/";
if (BLOCKED_REDIRECT_PATHS.some((path) => decodedPath === path || decodedPath.startsWith(`${path}/`))) {
return fallback;
}
return redirect;
}
function createLoginLocation(redirect) {
return {
name: "login",
query: {
redirect: getSafeLoginRedirect(redirect)
}
};
}
function createSetupLocation(redirect) {
return {
name: "setup",
query: {
redirect: getSafeLoginRedirect(redirect)
}
};
}
function getCurrentLoginRedirect(route) {
if (route?.name === "login" || route?.name === "login-verification") {
return getSafeLoginRedirect(route?.query?.redirect);
}
return getSafeLoginRedirect(route?.fullPath);
}
function createLoginRedirectHandler(router) {
let redirectPromise = null;
return function redirectToLogin() {
const currentRoute = router.currentRoute.value;
if (currentRoute?.name === "login" || currentRoute?.name === "login-verification") {
return Promise.resolve(false);
}
if (redirectPromise) {
return redirectPromise;
}
const location = createLoginLocation(getCurrentLoginRedirect(currentRoute));
redirectPromise = Promise.resolve(router.replace(location))
.then(() => true)
.finally(() => {
redirectPromise = null;
});
return redirectPromise;
};
}
export {
createRouteAssetRecovery,
isReadOnlyRoute,
isRouteAssetLoadError,
createLoginLocation,
createLoginRedirectHandler,
createSetupLocation,
getCurrentLoginRedirect,
getSafeLoginRedirect
};