Merge branch 'pr-migration' into production-2

This commit is contained in:
Pas 2025-01-10 22:05:44 -07:00
commit b82e34935f
11 changed files with 514 additions and 13 deletions

View file

@ -112,6 +112,7 @@
"pagetitle": "{{title}} - P-Stream",
"register": "Register",
"settings": "Settings",
"migration": "Migration",
"jip": "Jip"
}
},
@ -197,6 +198,45 @@
"show": "Show"
}
},
"migration": {
"start": {
"title": "Migrate your data",
"explainer": "If you wish to migrate or backup your data, you can do so using the options below. This will allow you to keep your data when you switch backend servers.",
"options": {
"or": "or",
"direct": {
"description": "This will directly migrate your data to the new server. This is the fastest option. <br /><br />This option allows you to keep your passphrase the same!",
"title": "Direct migration",
"quality": "Easiest and fastest",
"action": "Transfer data"
},
"download": {
"description": "This will download your data to your device. You can then upload it to the new server or just keep it for safekeeping.",
"title": "Download data",
"quality": "More technical",
"action": "Download data"
}
}
},
"direct": {
"title": "Direct migration",
"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?",
"loginRequired": "You must be logged in to migrate your data! Please go back and login to continue.",
"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! 🦢"
@ -267,6 +307,7 @@
"start": {
"explainer": "To get the best streams possible, you will need to choose which streaming method you want to use.",
"options": {
"or": "or",
"default": {
"text": "I don't want good quality streams, use the default setup."
},
@ -577,7 +618,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.</0>",
"label": "Custom server",
"urlLabel": "Custom server URL"
"urlLabel": "Custom server URL",
"migration": {
"description": "<0>Migrate my data</0> to a new server. ",
"link": "Migrate my data"
}
},
"setup": {
"doSetup": "Do setup",

View file

@ -21,9 +21,7 @@ export function verifyValidMnemonic(mnemonic: string) {
return validateMnemonic(mnemonic, wordlist);
}
export async function keysFromMnemonic(mnemonic: string): Promise<Keys> {
const seed = await seedFromMnemonic(mnemonic);
export async function keysFromSeed(seed: Uint8Array): Promise<Keys> {
const { privateKey, publicKey } = forge.pki.ed25519.generateKeyPair({
seed,
});
@ -35,6 +33,12 @@ export async function keysFromMnemonic(mnemonic: string): Promise<Keys> {
};
}
export async function keysFromMnemonic(mnemonic: string): Promise<Keys> {
const seed = await seedFromMnemonic(mnemonic);
return keysFromSeed(seed);
}
export function genMnemonic(): string {
return generateMnemonic(wordlist);
}

View file

@ -70,6 +70,8 @@ export enum Icons {
WEB = "web",
SHRINK = "shrink",
STRETCH = "stretch",
CLOUD_ARROW_UP = "cloud_arrow_up",
FILE_ARROW_DOWN = "file_arrow_down",
}
export interface IconProps {
@ -157,6 +159,8 @@ const iconList: Record<Icons, string> = {
</svg>`,
shrink: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 512 512" fill="currentColor"><!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M439 7c9.4-9.4 24.6-9.4 33.9 0l32 32c9.4 9.4 9.4 24.6 0 33.9l-87 87 39 39c6.9 6.9 8.9 17.2 5.2 26.2s-12.5 14.8-22.2 14.8l-144 0c-13.3 0-24-10.7-24-24l0-144c0-9.7 5.8-18.5 14.8-22.2s19.3-1.7 26.2 5.2l39 39L439 7zM72 272l144 0c13.3 0 24 10.7 24 24l0 144c0 9.7-5.8 18.5-14.8 22.2s-19.3 1.7-26.2-5.2l-39-39L73 505c-9.4 9.4-24.6 9.4-33.9 0L7 473c-9.4-9.4-9.4-24.6 0-33.9l87-87L55 313c-6.9-6.9-8.9-17.2-5.2-26.2s12.5-14.8 22.2-14.8z"/></svg>`,
stretch: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 512 512" fill="currentColor"><!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M344 0L488 0c13.3 0 24 10.7 24 24l0 144c0 9.7-5.8 18.5-14.8 22.2s-19.3 1.7-26.2-5.2l-39-39-87 87c-9.4 9.4-24.6 9.4-33.9 0l-32-32c-9.4-9.4-9.4-24.6 0-33.9l87-87L327 41c-6.9-6.9-8.9-17.2-5.2-26.2S334.3 0 344 0zM168 512L24 512c-13.3 0-24-10.7-24-24L0 344c0-9.7 5.8-18.5 14.8-22.2s19.3-1.7 26.2 5.2l39 39 87-87c9.4-9.4 24.6-9.4 33.9 0l32 32c9.4 9.4 9.4 24.6 0 33.9l-87 87 39 39c6.9 6.9 8.9 17.2 5.2 26.2s-12.5 14.8-22.2 14.8z"/></svg>`,
cloud_arrow_up: `<svg xmlns="http://www.w3.org/2000/svg" height="1em" width="1em" viewBox="0 0 640 512"><!--!Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M144 480C64.5 480 0 415.5 0 336c0-62.8 40.2-116.2 96.2-135.9c-.1-2.7-.2-5.4-.2-8.1c0-88.4 71.6-160 160-160c59.3 0 111 32.2 138.7 80.2C409.9 102 428.3 96 448 96c53 0 96 43 96 96c0 12.2-2.3 23.8-6.4 34.6C596 238.4 640 290.1 640 352c0 70.7-57.3 128-128 128H144zm79-217c-9.4 9.4-9.4 24.6 0 33.9s24.6 9.4 33.9 0l39-39V392c0 13.3 10.7 24 24 24s24-10.7 24-24V257.9l39 39c9.4 9.4 24.6 9.4 33.9 0s9.4-24.6 0-33.9l-80-80c-9.4-9.4-24.6-9.4-33.9 0l-80 80z" fill="currentColor"/></svg>`,
file_arrow_down: `<svg xmlns="http://www.w3.org/2000/svg" height="1em" width="1em" viewBox="0 0 384 512"><!--!Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M64 0C28.7 0 0 28.7 0 64V448c0 35.3 28.7 64 64 64H320c35.3 0 64-28.7 64-64V160H256c-17.7 0-32-14.3-32-32V0H64zM256 0V128H384L256 0zM216 232V334.1l31-31c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9l-72 72c-9.4 9.4-24.6 9.4-33.9 0l-72-72c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l31 31V232c0-13.3 10.7-24 24-24s24 10.7 24 24z" fill="currentColor"/></svg>`,
};
function ChromeCastButton() {

View file

@ -0,0 +1,9 @@
import classNames from "classnames";
export function VerticalLine(props: { className?: string }) {
return (
<div className={classNames("w-full grid justify-center", props.className)}>
<div className="w-px h-10 bg-onboarding-divider" />
</div>
);
}

View file

@ -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<string, ProgressMediaItem>,
bookmarkItems: Record<string, BookmarkMediaItem>,
) => {
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,
};
}

View file

@ -0,0 +1,65 @@
import { Trans, useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { Icons } from "@/components/Icon";
import { Stepper } from "@/components/layout/Stepper";
import { CenterContainer } from "@/components/layout/ThinContainer";
import { VerticalLine } from "@/components/layout/VerticalLine";
import { Heading2, Paragraph } from "@/components/utils/Text";
import { MinimalPageLayout } from "@/pages/layouts/MinimalPageLayout";
import { Card, CardContent, Link } from "@/pages/migration/utils";
import { PageTitle } from "@/pages/parts/util/PageTitle";
export function MigrationPage() {
const navigate = useNavigate();
const { t } = useTranslation();
return (
<MinimalPageLayout>
<PageTitle subpage k="global.pages.migration" />
<CenterContainer>
<Stepper steps={2} current={1} className="mb-12" />
<Heading2 className="!mt-0 !text-3xl max-w-[435px]">
{t("migration.start.title")}
</Heading2>
<Paragraph className="max-w-[320px]">
{t("migration.start.explainer")}
</Paragraph>
<div className="w-full flex flex-col md:flex-row gap-3">
<Card onClick={() => navigate("/migration/direct")}>
<CardContent
colorClass="!text-onboarding-best"
title={t("migration.start.options.direct.title")}
subtitle={t("migration.start.options.direct.quality")}
description={
<Trans i18nKey="migration.start.options.direct.description" />
}
icon={Icons.CLOUD_ARROW_UP}
>
<Link>{t("migration.start.options.direct.action")}</Link>
</CardContent>
</Card>
<div className="hidden md:grid grid-rows-[1fr,auto,1fr] justify-center gap-4">
<VerticalLine className="items-end" />
<span className="text-xs uppercase font-bold">
{t("migration.start.options.or")}
</span>
<VerticalLine />
</div>
<Card onClick={() => navigate("/migration/download")}>
<CardContent
colorClass="!text-migration-good"
title={t("migration.start.options.download.title")}
subtitle={t("migration.start.options.download.quality")}
description={t("migration.start.options.download.description")}
icon={Icons.FILE_ARROW_DOWN}
>
<Link>{t("migration.start.options.download.action")}</Link>
</CardContent>
</Card>
</div>
</CenterContainer>
</MinimalPageLayout>
);
}

View file

@ -0,0 +1,139 @@
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { Button } from "@/components/buttons/Button";
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 { useAuth } from "@/hooks/auth/useAuth";
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 { logout } = useAuth();
const navigate = useNavigate();
const { migrate } = useMigration();
const [backendUrl, setBackendUrl] = useState("");
const [status, setStatus] = useState<
"idle" | "success" | "error" | "processing"
>("idle");
const updateBackendUrl = useAuthStore((state) => state.setBackendUrl);
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);
if (account) {
setStatus("success");
await logout();
updateBackendUrl(backendUrl);
} else {
setStatus("error");
}
} catch (error) {
console.error("Error during migration:", error);
setStatus("error");
}
}, [backendUrl, migrate, updateBackendUrl, logout]);
const continueButton = () => {
if (status === "success") {
navigate("/login");
}
};
return (
<MinimalPageLayout>
<PageTitle subpage k="global.pages.migration" />
<CenterContainer>
{user.account ? (
<div>
<Heading2 className="!text-4xl">
{" "}
{t("migration.direct.title")}
</Heading2>
<div className="space-y-6 max-w-3xl mx-auto">
<Paragraph className="text-lg max-w-md">
{t("migration.direct.description")}
</Paragraph>
<SettingsCard>
<div className="flex justify-between items-center">
<p className="font-bold text-white">
{t("migration.direct.backendLabel")}
</p>
</div>
{backendUrl !== null && (
<>
<Divider marginClass="my-6 px-8 box-content -mx-8" />
<AuthInputBox
placeholder="https://"
value={backendUrl ?? ""}
onChange={setBackendUrl}
/>
</>
)}
</SettingsCard>
<div className="text-center">
{status !== "success" && (
<Button theme="purple" onClick={handleMigration}>
{status === "processing"
? t("migration.direct.button.processing")
: t("migration.direct.button.migrate")}
</Button>
)}
{status === "success" && (
<div>
<Button
theme="purple"
className="mt-4"
onClick={continueButton}
>
{t("migration.direct.button.login")}
</Button>
<p className="text-green-600 mt-4">
{t("migration.direct.status.success")}
</p>
</div>
)}
{status === "error" && (
<p className="text-red-600 mt-4">
{t("migration.direct.status.error")}
</p>
)}
</div>
</div>
</div>
) : (
<div className="flex flex-col items-center text-center mb-8">
<Paragraph className="max-w-[320px] text-md">
{t("migration.direct.loginRequired")}
</Paragraph>
<Button
theme="purple"
className="mt-4"
onClick={() => navigate("/")}
>
{t("migration.direct.button.home")}
</Button>
</div>
)}
</CenterContainer>
</MinimalPageLayout>
);
}

View file

@ -0,0 +1,92 @@
import classNames from "classnames";
import { ReactNode } from "react";
import { useNavigate } from "react-router-dom";
import { Icon, Icons } from "@/components/Icon";
import { Heading2, Heading3, Paragraph } from "@/components/utils/Text";
export function Card(props: {
children?: React.ReactNode;
className?: string;
onClick?: () => void;
}) {
return (
<div
className={classNames(
{
"bg-onboarding-card duration-300 border border-onboarding-border rounded-lg p-7":
true,
"hover:bg-onboarding-cardHover transition-colors cursor-pointer":
!!props.onClick,
},
props.className,
)}
onClick={props.onClick}
>
{props.children}
</div>
);
}
export function CardContent(props: {
title: ReactNode;
description: ReactNode;
subtitle: ReactNode;
colorClass: string;
children?: React.ReactNode;
icon: Icons;
}) {
return (
<div className="grid grid-rows-[1fr,auto] h-full">
<div>
<Icon
icon={props.icon}
className={classNames("text-4xl mb-8 block", props.colorClass)}
/>
<Heading3
className={classNames(
"!mt-0 !mb-0 !text-xs uppercase",
props.colorClass,
)}
>
{props.subtitle}
</Heading3>
<Heading2 className="!mb-0 !mt-1 !text-base">{props.title}</Heading2>
<Paragraph className="max-w-[320px] !my-4">
{props.description}
</Paragraph>
</div>
<div>{props.children}</div>
</div>
);
}
export function Link(props: {
children?: React.ReactNode;
to?: string;
href?: string;
className?: string;
target?: "_blank";
}) {
const navigate = useNavigate();
return (
<a
onClick={() => {
if (props.to) navigate(props.to);
}}
href={props.href}
target={props.target}
className={classNames(
"text-onboarding-link cursor-pointer inline-flex gap-2 items-center group hover:opacity-75 transition-opacity",
props.className,
)}
rel="noreferrer"
>
{props.children}
<Icon
icon={Icons.ARROW_RIGHT}
className="group-hover:translate-x-0.5 transition-transform text-xl group-active:translate-x-0"
/>
</a>
);
}

View file

@ -4,6 +4,7 @@ import { Trans, useTranslation } from "react-i18next";
import { Button } from "@/components/buttons/Button";
import { Stepper } from "@/components/layout/Stepper";
import { CenterContainer } from "@/components/layout/ThinContainer";
import { VerticalLine } from "@/components/layout/VerticalLine";
import { Modal, ModalCard, useModal } from "@/components/overlays/Modal";
import { Heading1, Heading2, Paragraph } from "@/components/utils/Text";
import { MinimalPageLayout } from "@/pages/layouts/MinimalPageLayout";
@ -15,14 +16,6 @@ import { Card, CardContent, Link } from "@/pages/onboarding/utils";
import { PageTitle } from "@/pages/parts/util/PageTitle";
import { getProxyUrls } from "@/utils/proxyUrls";
function VerticalLine(props: { className?: string }) {
return (
<div className={classNames("w-full grid justify-center", props.className)}>
<div className="w-px h-10 bg-onboarding-divider" />
</div>
);
}
function HorizontalLine(props: { className?: string }) {
return (
<div className={classNames("w-full grid justify-center", props.className)}>
@ -81,7 +74,9 @@ export function OnboardingPage() {
</Card>
<div className="hidden md:grid grid-rows-[1fr,auto,1fr] justify-center gap-4">
<VerticalLine className="items-end" />
<span className="text-xs uppercase font-bold">or</span>
<span className="text-xs uppercase font-bold">
{t("onboarding.start.options.or")}
</span>
<VerticalLine />
</div>
<Card onClick={() => navigate("/onboarding/proxy")}>

View file

@ -10,6 +10,7 @@ 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 { 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 (
<SettingsCard>
<div className="flex justify-between items-center gap-4">
@ -130,6 +132,18 @@ function BackendEdit({ backendUrl, setBackendUrl }: BackendEditProps) {
</MwLink>
</Trans>
</p>
{user.account && (
<div>
<br />
<p className="max-w-[20rem] font-medium">
<Trans i18nKey="settings.connections.server.migration.description">
<MwLink to="/migration">
{t("settings.connections.server.migration.link")}
</MwLink>
</Trans>
</p>
</div>
)}
</div>
<div>
<Toggle

View file

@ -22,6 +22,8 @@ import { NotFoundPage } from "@/pages/errors/NotFoundPage";
import { HomePage } from "@/pages/HomePage";
import { JipPage } from "@/pages/Jip";
import { LoginPage } from "@/pages/Login";
import { MigrationPage } from "@/pages/migration/Migration";
import { MigrationDirectPage } from "@/pages/migration/MigrationDirect";
import { OnboardingPage } from "@/pages/onboarding/Onboarding";
import { OnboardingExtensionPage } from "@/pages/onboarding/OnboardingExtension";
import { OnboardingProxyPage } from "@/pages/onboarding/OnboardingProxy";
@ -148,6 +150,13 @@ function App() {
element={<OnboardingExtensionPage />}
/>
<Route path="/onboarding/proxy" element={<OnboardingProxyPage />} />
<Route path="/migration" element={<MigrationDirectPage />} />
{/* Migration pages - awaiting import and export fixes
<Route path="/migration" element={<MigrationPage />} />
<Route path="/migration/direct" element={<MigrationDirectPage />} />
*/}
{shouldHaveDmcaPage() ? (
<Route path="/dmca" element={<DmcaPage />} />
) : null}