diff --git a/src/router/index.ts b/src/router/index.ts index bb6165d..9a57d05 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -12,6 +12,7 @@ import { createRouter, createWebHistory } from "vue-router"; import { useLoginStore } from "@/stores/login"; import { getCookie, setCookie } from "@/utils/cookieUtil"; +import { encodeReturnTo } from "@/utils/returnToEncoding"; import AuthContainer from "@/views/AuthContainer.vue"; import MainContainer from "@/views/MainContainer.vue"; import Login from "@/views/Login/LoginPage.vue"; @@ -182,7 +183,7 @@ function buildLoginRedirect(fullPath: string) { return { path: "/login", query: { - "return-to": btoa(fullPath), + "return-to": encodeReturnTo(fullPath), }, }; } diff --git a/src/stores/login.ts b/src/stores/login.ts index 59374de..4172941 100644 --- a/src/stores/login.ts +++ b/src/stores/login.ts @@ -13,6 +13,7 @@ import { defineStore } from "pinia"; import axios from "axios"; import apiClient from "@/api/client.js"; import apiError from "@/module/apiError.js"; +import { decodeReturnTo } from "@/utils/returnToEncoding"; import { deleteCookie, setCookie, @@ -73,7 +74,7 @@ export const useLoginStore = defineStore("loginStore", { if (this.rememberedReturnToUrl !== "") { let decodedUrl = ""; try { - decodedUrl = atob(this.rememberedReturnToUrl); + decodedUrl = decodeReturnTo(this.rememberedReturnToUrl); } catch { this.$router.push("/files"); return; diff --git a/src/utils/returnToEncoding.js b/src/utils/returnToEncoding.js new file mode 100644 index 0000000..a5b15e6 --- /dev/null +++ b/src/utils/returnToEncoding.js @@ -0,0 +1,30 @@ +// The Lucia project. +// Copyright 2026-2026 DSP, inc. All rights reserved. +// Authors: +// imacat.yang@dsp.im (imacat), 2026/03/08 +/** @module returnToEncoding UTF-8 safe base64 helpers for return-to paths. */ + +/** + * Encodes a return-to path into UTF-8 safe base64. + * @param {string} returnToPath - The relative path to preserve. + * @returns {string} Base64-encoded UTF-8 string. + */ +export function encodeReturnTo(returnToPath) { + const bytes = new TextEncoder().encode(returnToPath); + let binary = ""; + bytes.forEach((byte) => { + binary += String.fromCharCode(byte); + }); + return btoa(binary); +} + +/** + * Decodes a UTF-8 safe base64 return-to payload back to a path. + * @param {string} encodedReturnTo - Base64 payload from query parameter. + * @returns {string} Decoded path string. + */ +export function decodeReturnTo(encodedReturnTo) { + const binary = atob(encodedReturnTo); + const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0)); + return new TextDecoder().decode(bytes); +} diff --git a/tests/router/routerGuard.test.js b/tests/router/routerGuard.test.js index 8370802..600c654 100644 --- a/tests/router/routerGuard.test.js +++ b/tests/router/routerGuard.test.js @@ -4,6 +4,7 @@ // imacat.yang@dsp.im (imacat), 2026/03/06 import { describe, it, expect, beforeEach } from "vitest"; +import { decodeReturnTo } from "@/utils/returnToEncoding"; describe("router beforeEach guard logic", () => { beforeEach(() => { @@ -70,7 +71,7 @@ describe("router beforeEach guard logic", () => { matched: [{ meta: { requiresAuth: true } }], }); expect(result.path).toBe("/login"); - expect(atob(result.query["return-to"])).toBe("/files"); + expect(decodeReturnTo(result.query["return-to"])).toBe("/files"); }); it("allows requiresAuth route when refresh token can refresh session", () => { @@ -100,7 +101,7 @@ describe("router beforeEach guard logic", () => { { refreshSucceeds: false }, ); expect(result.path).toBe("/login"); - expect(atob(result.query["return-to"])).toBe( + expect(decodeReturnTo(result.query["return-to"])).toBe( "/discover/log/1/map?view=summary#node-2", ); }); diff --git a/tests/unit/utils/returnToEncoding.test.js b/tests/unit/utils/returnToEncoding.test.js new file mode 100644 index 0000000..0a85887 --- /dev/null +++ b/tests/unit/utils/returnToEncoding.test.js @@ -0,0 +1,21 @@ +// The Lucia project. +// Copyright 2026-2026 DSP, inc. All rights reserved. +// Authors: +// imacat.yang@dsp.im (imacat), 2026/03/08 + +import { describe, expect, it } from "vitest"; +import { encodeReturnTo, decodeReturnTo } from "@/utils/returnToEncoding"; + +describe("returnToEncoding", () => { + it("round-trips ASCII paths", () => { + const original = "/discover/log/1/map?view=summary#node-2"; + const encoded = encodeReturnTo(original); + expect(decodeReturnTo(encoded)).toBe(original); + }); + + it("round-trips Unicode paths without throwing", () => { + const original = "/files?keyword=流程圖#節點"; + const encoded = encodeReturnTo(original); + expect(decodeReturnTo(encoded)).toBe(original); + }); +});