From 4fd954f6435f165cf8d7c9e4ab35291e27ae1d40 Mon Sep 17 00:00:00 2001 From: Ivan Evans <74743263+Pasithea0@users.noreply.github.com> Date: Sun, 5 Jan 2025 13:32:34 -0700 Subject: [PATCH] init --- src/assets/locales/en.json | 23 ++- src/backend/accounts/crypto.ts | 10 +- src/hooks/auth/useMigration.ts | 125 ++++++++++++++ src/pages/migration/MigrationDirect.tsx | 162 +++++++++++++++++++ src/pages/parts/settings/ConnectionsPart.tsx | 16 +- src/setup/App.tsx | 4 + 6 files changed, 335 insertions(+), 5 deletions(-) create mode 100644 src/hooks/auth/useMigration.ts create mode 100644 src/pages/migration/MigrationDirect.tsx diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index abe4e754..f0d6be45 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -197,6 +197,23 @@ "show": "Show" } }, + "migration": { + "title": "Migrate Account Data", + "description": "Enter the destination backend URL to migrate your current account data to a new backend. This keeps your passphrase the same!", + "backendLabel": "Destination Backend URL", + "recaptchaLabel": "ReCaptcha Key (Optional)", + "toggleLable": "Needs ReCaptcha?", + "status": { + "error": "Failed to migrate your data. 😿", + "success": "Your data has been migrated successfully! 🎉" + }, + "button": { + "migrate": "Migrate", + "processing": "Processing...", + "home": "Go home", + "login": "Continue to login" + } + }, "navigation": { "banner": { "offline": "Check your internet connection, silly goose! 🦢" @@ -573,7 +590,11 @@ "server": { "description": "If you would like to connect to a custom backend to store your data, enable this and provide the URL. <0>Instructions.", "label": "Custom server", - "urlLabel": "Custom server URL" + "urlLabel": "Custom server URL", + "migration": { + "description": "<0>Migrate my data to a new server. ", + "link": "Migrate my data" + } }, "setup": { "doSetup": "Do setup", diff --git a/src/backend/accounts/crypto.ts b/src/backend/accounts/crypto.ts index 9fe51380..9e099bf2 100644 --- a/src/backend/accounts/crypto.ts +++ b/src/backend/accounts/crypto.ts @@ -21,9 +21,7 @@ export function verifyValidMnemonic(mnemonic: string) { return validateMnemonic(mnemonic, wordlist); } -export async function keysFromMnemonic(mnemonic: string): Promise { - const seed = await seedFromMnemonic(mnemonic); - +export async function keysFromSeed(seed: Uint8Array): Promise { const { privateKey, publicKey } = forge.pki.ed25519.generateKeyPair({ seed, }); @@ -35,6 +33,12 @@ export async function keysFromMnemonic(mnemonic: string): Promise { }; } +export async function keysFromMnemonic(mnemonic: string): Promise { + const seed = await seedFromMnemonic(mnemonic); + + return keysFromSeed(seed); +} + export function genMnemonic(): string { return generateMnemonic(wordlist); } diff --git a/src/hooks/auth/useMigration.ts b/src/hooks/auth/useMigration.ts new file mode 100644 index 00000000..446ac92e --- /dev/null +++ b/src/hooks/auth/useMigration.ts @@ -0,0 +1,125 @@ +import { useCallback } from "react"; + +import { SessionResponse } from "@/backend/accounts/auth"; +import { bookmarkMediaToInput } from "@/backend/accounts/bookmarks"; +import { + base64ToBuffer, + bytesToBase64, + bytesToBase64Url, + encryptData, + keysFromMnemonic, + keysFromSeed, + signChallenge, +} from "@/backend/accounts/crypto"; +import { importBookmarks, importProgress } from "@/backend/accounts/import"; +import { getLoginChallengeToken, loginAccount } from "@/backend/accounts/login"; +import { progressMediaItemToInputs } from "@/backend/accounts/progress"; +import { + getRegisterChallengeToken, + registerAccount, +} from "@/backend/accounts/register"; +import { removeSession } from "@/backend/accounts/sessions"; +import { getSettings } from "@/backend/accounts/settings"; +import { + UserResponse, + getBookmarks, + getProgress, + getUser, +} from "@/backend/accounts/user"; +import { useAuthData } from "@/hooks/auth/useAuthData"; +import { useBackendUrl } from "@/hooks/auth/useBackendUrl"; +import { AccountWithToken, useAuthStore } from "@/stores/auth"; +import { BookmarkMediaItem, useBookmarkStore } from "@/stores/bookmarks"; +import { ProgressMediaItem, useProgressStore } from "@/stores/progress"; + +export interface RegistrationData { + recaptchaToken?: string; + mnemonic: string; + userData: { + device: string; + profile: { + colorA: string; + colorB: string; + icon: string; + }; + }; +} + +export interface LoginData { + mnemonic: string; + userData: { + device: string; + }; +} + +export function useMigration() { + const currentAccount = useAuthStore((s) => s.account); + const progress = useProgressStore((s) => s.items); + const bookmarks = useBookmarkStore((s) => s.bookmarks); + const { login: userDataLogin } = useAuthData(); + + const importData = async ( + backendUrl: string, + account: AccountWithToken, + progressItems: Record, + bookmarkItems: Record, + ) => { + if ( + Object.keys(progressItems).length === 0 && + Object.keys(bookmarkItems).length === 0 + ) { + return; + } + + const progressInputs = Object.entries(progressItems).flatMap( + ([tmdbId, item]) => progressMediaItemToInputs(tmdbId, item), + ); + + const bookmarkInputs = Object.entries(bookmarkItems).map(([tmdbId, item]) => + bookmarkMediaToInput(tmdbId, item), + ); + + await Promise.all([ + importProgress(backendUrl, account, progressInputs), + importBookmarks(backendUrl, account, bookmarkInputs), + ]); + }; + + const migrate = useCallback( + async (backendUrl: string, recaptchaToken?: string) => { + if (!currentAccount) return; + + const { challenge } = await getRegisterChallengeToken( + backendUrl, + recaptchaToken || undefined, // Pass undefined if token is not provided + ); + const keys = await keysFromSeed(base64ToBuffer(currentAccount.seed)); + const signature = await signChallenge(keys, challenge); + const registerResult = await registerAccount(backendUrl, { + challenge: { + code: challenge, + signature, + }, + publicKey: bytesToBase64Url(keys.publicKey), + device: await encryptData(currentAccount.deviceName, keys.seed), + profile: currentAccount.profile, + }); + + const account = await userDataLogin( + registerResult, + registerResult.user, + registerResult.session, + bytesToBase64(keys.seed), + ); + + await importData(backendUrl, account, progress, bookmarks); + + return account; + }, + [currentAccount, userDataLogin, bookmarks, progress], + ); + + return { + migrate, + }; +} diff --git a/src/pages/migration/MigrationDirect.tsx b/src/pages/migration/MigrationDirect.tsx new file mode 100644 index 00000000..c41630e7 --- /dev/null +++ b/src/pages/migration/MigrationDirect.tsx @@ -0,0 +1,162 @@ +import { useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; + +import { Button } from "@/components/buttons/Button"; +import { Toggle } from "@/components/buttons/Toggle"; +import { SettingsCard } from "@/components/layout/SettingsCard"; +import { CenterContainer } from "@/components/layout/ThinContainer"; +import { AuthInputBox } from "@/components/text-inputs/AuthInputBox"; +import { Divider } from "@/components/utils/Divider"; +import { Heading2, Paragraph } from "@/components/utils/Text"; +import { useMigration } from "@/hooks/auth/useMigration"; +import { MinimalPageLayout } from "@/pages/layouts/MinimalPageLayout"; +import { PageTitle } from "@/pages/parts/util/PageTitle"; +import { useAuthStore } from "@/stores/auth"; + +export function MigrationDirectPage() { + const { t } = useTranslation(); + const user = useAuthStore(); + const navigate = useNavigate(); + const { migrate } = useMigration(); + const [backendUrl, setBackendUrl] = useState(""); + const [recaptchaToken, setRecaptchaToken] = useState(); + const [status, setStatus] = useState< + "idle" | "success" | "error" | "processing" + >("idle"); + const [needscaptcha, setNeedscaptcha] = useState(false); + + const handleMigration = useCallback(async () => { + if (!backendUrl) { + // eslint-disable-next-line no-alert + alert("Please provide a Backend URL."); + return; + } + + try { + setStatus("processing"); + const account = await migrate(backendUrl, recaptchaToken); + if (account) { + setStatus("success"); + } else { + setStatus("error"); + } + } catch (error) { + console.error("Error during migration:", error); + setStatus("error"); + } + }, [backendUrl, recaptchaToken, migrate]); + + const handleToggleChange = () => { + setNeedscaptcha(!needscaptcha); + }; + + return ( + + + + {user.account ? ( +
+ {t("migration.title")} +
+ + {t("migration.description")} + + +
+

+ {t("migration.backendLabel")} +

+
+ {backendUrl !== null && ( + <> + + + + )} +
+ +
+ +

+ {t("migration.toggleLable")} +

+
+ {needscaptcha && ( + +
+

+ {t("migration.recaptchaLabel")} +

+
+ {recaptchaToken !== null && ( + <> + + setRecaptchaToken(val)} + /> + + )} +
+ )} + +
+ {status !== "success" && ( + + )} + + {status === "success" && ( +
+ +

+ {t("migration.status.success")} +

+
+ )} + + {status === "error" && ( +

+ {t("migration.status.error")} +

+ )} +
+
+
+ ) : ( +
+ + You must be logged in to migrate your data! Please go back and + login to continue. + + +
+ )} +
+
+ ); +} diff --git a/src/pages/parts/settings/ConnectionsPart.tsx b/src/pages/parts/settings/ConnectionsPart.tsx index a0e0e4fd..347136c4 100644 --- a/src/pages/parts/settings/ConnectionsPart.tsx +++ b/src/pages/parts/settings/ConnectionsPart.tsx @@ -9,7 +9,8 @@ import { MwLink } from "@/components/text/Link"; import { AuthInputBox } from "@/components/text-inputs/AuthInputBox"; import { Divider } from "@/components/utils/Divider"; import { Heading1 } from "@/components/utils/Text"; -// import { SetupPart } from "@/pages/parts/settings/SetupPart"; +import { SetupPart } from "@/pages/parts/settings/SetupPart"; +import { useAuthStore } from "@/stores/auth"; interface ProxyEditProps { proxyUrls: string[] | null; @@ -116,6 +117,7 @@ function ProxyEdit({ proxyUrls, setProxyUrls }: ProxyEditProps) { function BackendEdit({ backendUrl, setBackendUrl }: BackendEditProps) { const { t } = useTranslation(); + const user = useAuthStore(); return (
@@ -130,6 +132,18 @@ function BackendEdit({ backendUrl, setBackendUrl }: BackendEditProps) {

+ {user.account && ( +
+
+

+ + + {t("settings.connections.server.migration.link")} + + +

+
+ )}
} /> } /> + + } /> + {shouldHaveDmcaPage() ? ( } /> ) : null}