mirror of
https://github.com/movie-web/native-app.git
synced 2025-09-13 14:53:24 +00:00
Compare commits
1 Commits
bd8a4394ea
...
925c1a39fc
Author | SHA1 | Date | |
---|---|---|---|
|
925c1a39fc |
@@ -1,60 +1,5 @@
|
|||||||
import { Link } from "expo-router";
|
|
||||||
import { H2, H5, Paragraph, View } from "tamagui";
|
|
||||||
|
|
||||||
import ScreenLayout from "~/components/layout/ScreenLayout";
|
import ScreenLayout from "~/components/layout/ScreenLayout";
|
||||||
import { MWButton } from "~/components/ui/Button";
|
|
||||||
import { MWCard } from "~/components/ui/Card";
|
|
||||||
import { MWInput } from "~/components/ui/Input";
|
|
||||||
import { useAuthStore } from "~/stores/settings";
|
|
||||||
|
|
||||||
export default function MovieWebScreen() {
|
export default function MovieWebScreen() {
|
||||||
const { backendUrl, setBackendUrl } = useAuthStore();
|
return <ScreenLayout></ScreenLayout>;
|
||||||
|
|
||||||
return (
|
|
||||||
<ScreenLayout
|
|
||||||
contentContainerStyle={{
|
|
||||||
flexGrow: 1,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MWCard bordered>
|
|
||||||
<MWCard.Header padded>
|
|
||||||
<H2 fontWeight="$bold" paddingBottom="$1">
|
|
||||||
Sync to the cloud
|
|
||||||
</H2>
|
|
||||||
<H5 color="$ash50" fontWeight="$semibold" paddingVertical="$3">
|
|
||||||
Share your watch progress between devices and keep them synced.
|
|
||||||
</H5>
|
|
||||||
<Paragraph color="$ash50">
|
|
||||||
First choose the backend you want to use. If you do not know what
|
|
||||||
this does, use the default and click on 'Get started'.
|
|
||||||
</Paragraph>
|
|
||||||
</MWCard.Header>
|
|
||||||
|
|
||||||
<View padding="$4">
|
|
||||||
<MWInput
|
|
||||||
placeholder={backendUrl}
|
|
||||||
type="search"
|
|
||||||
value={backendUrl}
|
|
||||||
onChangeText={setBackendUrl}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<MWCard.Footer padded justifyContent="center">
|
|
||||||
<MWButton type="purple">
|
|
||||||
<Link
|
|
||||||
href={{
|
|
||||||
pathname: "/sync/trust/[url]",
|
|
||||||
params: { url: backendUrl },
|
|
||||||
}}
|
|
||||||
style={{ color: "white", fontWeight: "bold" }}
|
|
||||||
>
|
|
||||||
Get started
|
|
||||||
</Link>
|
|
||||||
</MWButton>
|
|
||||||
</MWCard.Footer>
|
|
||||||
</MWCard>
|
|
||||||
</ScreenLayout>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
@@ -1,14 +0,0 @@
|
|||||||
import { Stack } from "expo-router";
|
|
||||||
|
|
||||||
import { BrandPill } from "~/components/BrandPill";
|
|
||||||
|
|
||||||
export default function Layout() {
|
|
||||||
return (
|
|
||||||
<Stack
|
|
||||||
screenOptions={{
|
|
||||||
headerTransparent: true,
|
|
||||||
headerRight: BrandPill,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
@@ -1,117 +0,0 @@
|
|||||||
import { Stack, useLocalSearchParams } from "expo-router";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { H4, Paragraph, Text, View } from "tamagui";
|
|
||||||
|
|
||||||
import ScreenLayout from "~/components/layout/ScreenLayout";
|
|
||||||
import { MWButton } from "~/components/ui/Button";
|
|
||||||
import { MWCard } from "~/components/ui/Card";
|
|
||||||
|
|
||||||
// TODO: extract to function with cleanup and types
|
|
||||||
const getBackendMeta = (
|
|
||||||
url: string,
|
|
||||||
): Promise<{
|
|
||||||
description: string;
|
|
||||||
hasCaptcha: boolean;
|
|
||||||
name: string;
|
|
||||||
url: string;
|
|
||||||
}> => {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
|
||||||
return fetch(`${url}/meta`).then((res) => res.json());
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
const { url } = useLocalSearchParams();
|
|
||||||
|
|
||||||
const meta = useQuery({
|
|
||||||
queryKey: ["backendMeta", url],
|
|
||||||
queryFn: () => getBackendMeta(url as string),
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScreenLayout
|
|
||||||
showHeader={false}
|
|
||||||
contentContainerStyle={{
|
|
||||||
flexGrow: 1,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Stack.Screen
|
|
||||||
options={{
|
|
||||||
title: "",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<MWCard bordered>
|
|
||||||
<MWCard.Header padded>
|
|
||||||
<H4 fontWeight="$bold" textAlign="center">
|
|
||||||
Do you trust this server?
|
|
||||||
</H4>
|
|
||||||
|
|
||||||
<Paragraph
|
|
||||||
color="$ash50"
|
|
||||||
textAlign="center"
|
|
||||||
fontWeight="$semibold"
|
|
||||||
paddingVertical="$4"
|
|
||||||
>
|
|
||||||
{meta.isLoading && "Loading..."}
|
|
||||||
{meta.isError && "Error loading metadata"}
|
|
||||||
{meta.isSuccess && (
|
|
||||||
<>
|
|
||||||
You are connecting to{" "}
|
|
||||||
<Text
|
|
||||||
fontWeight="$bold"
|
|
||||||
color="white"
|
|
||||||
textDecorationLine="underline"
|
|
||||||
>
|
|
||||||
{url}
|
|
||||||
</Text>
|
|
||||||
. Please confirm you trust it before making an account.
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Paragraph>
|
|
||||||
</MWCard.Header>
|
|
||||||
|
|
||||||
{meta.isSuccess && (
|
|
||||||
<View
|
|
||||||
borderColor="$shade200"
|
|
||||||
borderWidth="$0.5"
|
|
||||||
borderRadius="$8"
|
|
||||||
paddingHorizontal="$5"
|
|
||||||
paddingVertical="$4"
|
|
||||||
width="90%"
|
|
||||||
alignSelf="center"
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
fontWeight="$bold"
|
|
||||||
paddingBottom="$1"
|
|
||||||
textAlign="center"
|
|
||||||
fontSize="$4"
|
|
||||||
>
|
|
||||||
{meta.data.name}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Paragraph color="$ash50" textAlign="center">
|
|
||||||
{meta.data.description}
|
|
||||||
</Paragraph>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
<MWCard.Footer
|
|
||||||
padded
|
|
||||||
justifyContent="center"
|
|
||||||
flexDirection="column"
|
|
||||||
gap="$4"
|
|
||||||
>
|
|
||||||
<MWButton type="purple">I trust this server</MWButton>
|
|
||||||
<MWButton type="secondary">Go back</MWButton>
|
|
||||||
|
|
||||||
<Paragraph color="$ash50" textAlign="center" fontWeight="$semibold">
|
|
||||||
Already have an account?{" "}
|
|
||||||
<Text color="$purple100" fontWeight="$bold">
|
|
||||||
Login here
|
|
||||||
</Text>
|
|
||||||
</Paragraph>
|
|
||||||
</MWCard.Footer>
|
|
||||||
</MWCard>
|
|
||||||
</ScreenLayout>
|
|
||||||
);
|
|
||||||
}
|
|
@@ -1,4 +1,3 @@
|
|||||||
import type { ScrollViewProps } from "tamagui";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { ScrollView } from "tamagui";
|
import { ScrollView } from "tamagui";
|
||||||
import { LinearGradient } from "tamagui/linear-gradient";
|
import { LinearGradient } from "tamagui/linear-gradient";
|
||||||
@@ -6,14 +5,26 @@ import { LinearGradient } from "tamagui/linear-gradient";
|
|||||||
import { Header } from "./Header";
|
import { Header } from "./Header";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
onScrollBeginDrag?: () => void;
|
||||||
|
onMomentumScrollEnd?: () => void;
|
||||||
showHeader?: boolean;
|
showHeader?: boolean;
|
||||||
|
scrollEnabled?: boolean;
|
||||||
|
keyboardDismissMode?: "none" | "on-drag" | "interactive";
|
||||||
|
keyboardShouldPersistTaps?: "always" | "never" | "handled";
|
||||||
|
contentContainerStyle?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ScreenLayout({
|
export default function ScreenLayout({
|
||||||
children,
|
children,
|
||||||
|
onScrollBeginDrag,
|
||||||
|
onMomentumScrollEnd,
|
||||||
showHeader = true,
|
showHeader = true,
|
||||||
...props
|
scrollEnabled,
|
||||||
}: ScrollViewProps & Props) {
|
keyboardDismissMode,
|
||||||
|
keyboardShouldPersistTaps,
|
||||||
|
contentContainerStyle,
|
||||||
|
}: Props) {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -36,10 +47,15 @@ export default function ScreenLayout({
|
|||||||
>
|
>
|
||||||
{showHeader && <Header />}
|
{showHeader && <Header />}
|
||||||
<ScrollView
|
<ScrollView
|
||||||
|
onScrollBeginDrag={onScrollBeginDrag}
|
||||||
|
onMomentumScrollEnd={onMomentumScrollEnd}
|
||||||
|
scrollEnabled={scrollEnabled}
|
||||||
|
keyboardDismissMode={keyboardDismissMode}
|
||||||
|
keyboardShouldPersistTaps={keyboardShouldPersistTaps}
|
||||||
|
contentContainerStyle={contentContainerStyle}
|
||||||
marginTop="$4"
|
marginTop="$4"
|
||||||
flexGrow={1}
|
flexGrow={1}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
{...props}
|
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
@@ -7,33 +7,21 @@ export const MWButton = styled(Button, {
|
|||||||
backgroundColor: "$buttonPrimaryBackground",
|
backgroundColor: "$buttonPrimaryBackground",
|
||||||
color: "$buttonPrimaryText",
|
color: "$buttonPrimaryText",
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
pressStyle: {
|
|
||||||
backgroundColor: "$buttonPrimaryBackgroundHover",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
secondary: {
|
secondary: {
|
||||||
backgroundColor: "$buttonSecondaryBackground",
|
backgroundColor: "$buttonSecondaryBackground",
|
||||||
color: "$buttonSecondaryText",
|
color: "$buttonSecondaryText",
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
pressStyle: {
|
|
||||||
backgroundColor: "$buttonSecondaryBackgroundHover",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
purple: {
|
purple: {
|
||||||
backgroundColor: "$buttonPurpleBackground",
|
backgroundColor: "$buttonPurpleBackground",
|
||||||
color: "white",
|
color: "white",
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
pressStyle: {
|
|
||||||
backgroundColor: "$buttonPurpleBackgroundHover",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
cancel: {
|
cancel: {
|
||||||
backgroundColor: "$buttonCancelBackground",
|
backgroundColor: "$buttonCancelBackground",
|
||||||
color: "white",
|
color: "white",
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
pressStyle: {
|
|
||||||
backgroundColor: "$buttonCancelBackgroundHover",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as const,
|
} as const,
|
||||||
|
@@ -1,21 +0,0 @@
|
|||||||
import { Card, styled, withStaticProperties } from "tamagui";
|
|
||||||
|
|
||||||
export const MWCardFrame = styled(Card, {
|
|
||||||
backgroundColor: "$shade400",
|
|
||||||
borderColor: "$shade400",
|
|
||||||
|
|
||||||
variants: {
|
|
||||||
bordered: {
|
|
||||||
true: {
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: "$shade500",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const MWCard = withStaticProperties(MWCardFrame, {
|
|
||||||
Header: Card.Header,
|
|
||||||
Footer: Card.Footer,
|
|
||||||
Background: Card.Background,
|
|
||||||
});
|
|
@@ -257,66 +257,3 @@ export const useNetworkSettingsStore = create<
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
export interface Account {
|
|
||||||
profile: {
|
|
||||||
colorA: string;
|
|
||||||
colorB: string;
|
|
||||||
icon: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AccountWithToken = Account & {
|
|
||||||
sessionId: string;
|
|
||||||
userId: string;
|
|
||||||
token: string;
|
|
||||||
seed: string;
|
|
||||||
deviceName: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface AuthStoreState {
|
|
||||||
account: null | AccountWithToken;
|
|
||||||
backendUrl: string;
|
|
||||||
proxySet: null | string[];
|
|
||||||
removeAccount(): void;
|
|
||||||
setAccount(acc: AccountWithToken): void;
|
|
||||||
updateDeviceName(deviceName: string): void;
|
|
||||||
updateAccount(acc: Account): void;
|
|
||||||
setAccountProfile(acc: Account["profile"]): void;
|
|
||||||
setBackendUrl(url: string): void;
|
|
||||||
setProxySet(urls: null | string[]): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useAuthStore = create<
|
|
||||||
AuthStoreState,
|
|
||||||
[["zustand/persist", AuthStoreState]]
|
|
||||||
>(
|
|
||||||
persist(
|
|
||||||
(set) => ({
|
|
||||||
account: null,
|
|
||||||
backendUrl: "https://mw-backend.lonelil.ru",
|
|
||||||
proxySet: null,
|
|
||||||
setAccount: (acc) => set((s) => ({ ...s, account: acc })),
|
|
||||||
removeAccount: () => set((s) => ({ ...s, account: null })),
|
|
||||||
setBackendUrl: (v) => set((s) => ({ ...s, backendUrl: v })),
|
|
||||||
setProxySet: (urls) => set((s) => ({ ...s, proxySet: urls })),
|
|
||||||
setAccountProfile: (profile) =>
|
|
||||||
set((s) => ({
|
|
||||||
...s,
|
|
||||||
account: s.account ? { ...s.account, profile } : s.account,
|
|
||||||
})),
|
|
||||||
updateAccount: (acc) =>
|
|
||||||
set((s) =>
|
|
||||||
s.account ? { ...s, account: { ...s.account, ...acc } } : s,
|
|
||||||
),
|
|
||||||
updateDeviceName: (deviceName) =>
|
|
||||||
set((s) =>
|
|
||||||
s.account ? { ...s, account: { ...s.account, deviceName } } : s,
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
name: "account-settings",
|
|
||||||
storage: createJSONStorage(() => zustandStorage),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
@@ -18,7 +18,6 @@
|
|||||||
"@movie-web/eslint-config": "workspace:^0.2.0",
|
"@movie-web/eslint-config": "workspace:^0.2.0",
|
||||||
"@movie-web/prettier-config": "workspace:^0.1.0",
|
"@movie-web/prettier-config": "workspace:^0.1.0",
|
||||||
"@movie-web/tsconfig": "workspace:^0.1.0",
|
"@movie-web/tsconfig": "workspace:^0.1.0",
|
||||||
"@types/node-forge": "^1.3.11",
|
|
||||||
"eslint": "^8.56.0",
|
"eslint": "^8.56.0",
|
||||||
"prettier": "^3.1.1",
|
"prettier": "^3.1.1",
|
||||||
"typescript": "^5.4.3"
|
"typescript": "^5.4.3"
|
||||||
@@ -28,11 +27,5 @@
|
|||||||
"@movie-web/eslint-config/base"
|
"@movie-web/eslint-config/base"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"prettier": "@movie-web/prettier-config",
|
"prettier": "@movie-web/prettier-config"
|
||||||
"dependencies": {
|
|
||||||
"@noble/hashes": "^1.4.0",
|
|
||||||
"@scure/bip39": "^1.3.0",
|
|
||||||
"node-forge": "^1.3.1",
|
|
||||||
"ofetch": "^1.3.4"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@@ -1,35 +0,0 @@
|
|||||||
import { ofetch } from "ofetch";
|
|
||||||
|
|
||||||
export interface SessionResponse {
|
|
||||||
id: string;
|
|
||||||
userId: string;
|
|
||||||
createdAt: string;
|
|
||||||
accessedAt: string;
|
|
||||||
device: string;
|
|
||||||
userAgent: string;
|
|
||||||
}
|
|
||||||
export interface LoginResponse {
|
|
||||||
session: SessionResponse;
|
|
||||||
token: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAuthHeaders(token: string): Record<string, string> {
|
|
||||||
return {
|
|
||||||
authorization: `Bearer ${token}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function accountLogin(
|
|
||||||
url: string,
|
|
||||||
id: string,
|
|
||||||
deviceName: string,
|
|
||||||
): Promise<LoginResponse> {
|
|
||||||
return ofetch<LoginResponse>("/auth/login", {
|
|
||||||
method: "POST",
|
|
||||||
body: {
|
|
||||||
id,
|
|
||||||
device: deviceName,
|
|
||||||
},
|
|
||||||
baseURL: url,
|
|
||||||
});
|
|
||||||
}
|
|
@@ -1,134 +0,0 @@
|
|||||||
import { pbkdf2Async } from "@noble/hashes/pbkdf2";
|
|
||||||
import { sha256 } from "@noble/hashes/sha256";
|
|
||||||
import { generateMnemonic, validateMnemonic } from "@scure/bip39";
|
|
||||||
import { wordlist } from "@scure/bip39/wordlists/english";
|
|
||||||
import forge from "node-forge";
|
|
||||||
|
|
||||||
interface Keys {
|
|
||||||
privateKey: Uint8Array;
|
|
||||||
publicKey: Uint8Array;
|
|
||||||
seed: Uint8Array;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function seedFromMnemonic(mnemonic: string) {
|
|
||||||
return pbkdf2Async(sha256, mnemonic, "mnemonic", {
|
|
||||||
c: 2048,
|
|
||||||
dkLen: 32,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function verifyValidMnemonic(mnemonic: string) {
|
|
||||||
return validateMnemonic(mnemonic, wordlist);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function keysFromMnemonic(mnemonic: string): Promise<Keys> {
|
|
||||||
const seed = await seedFromMnemonic(mnemonic);
|
|
||||||
|
|
||||||
const { privateKey, publicKey } = forge.pki.ed25519.generateKeyPair({
|
|
||||||
seed,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
privateKey,
|
|
||||||
publicKey,
|
|
||||||
seed,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function genMnemonic(): string {
|
|
||||||
return generateMnemonic(wordlist);
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/require-await
|
|
||||||
export async function signCode(
|
|
||||||
code: string,
|
|
||||||
privateKey: Uint8Array,
|
|
||||||
): Promise<Uint8Array> {
|
|
||||||
return forge.pki.ed25519.sign({
|
|
||||||
encoding: "utf8",
|
|
||||||
message: code,
|
|
||||||
privateKey,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function bytesToBase64(bytes: Uint8Array) {
|
|
||||||
return forge.util.encode64(String.fromCodePoint(...bytes));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function bytesToBase64Url(bytes: Uint8Array): string {
|
|
||||||
return bytesToBase64(bytes)
|
|
||||||
.replace(/\//g, "_")
|
|
||||||
.replace(/\+/g, "-")
|
|
||||||
.replace(/=+$/, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function signChallenge(keys: Keys, challengeCode: string) {
|
|
||||||
const signature = await signCode(challengeCode, keys.privateKey);
|
|
||||||
return bytesToBase64Url(signature);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function base64ToBuffer(data: string) {
|
|
||||||
return forge.util.binary.base64.decode(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function base64ToStringBuffer(data: string) {
|
|
||||||
return forge.util.createBuffer(base64ToBuffer(data));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function stringBufferToBase64(buffer: forge.util.ByteStringBuffer) {
|
|
||||||
return forge.util.encode64(buffer.getBytes());
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function encryptData(data: string, secret: Uint8Array) {
|
|
||||||
if (secret.byteLength !== 32)
|
|
||||||
throw new Error("Secret must be at least 256-bit");
|
|
||||||
|
|
||||||
const iv = await new Promise<string>((resolve, reject) => {
|
|
||||||
forge.random.getBytes(16, (err, bytes) => {
|
|
||||||
if (err) reject(err);
|
|
||||||
resolve(bytes);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const cipher = forge.cipher.createCipher(
|
|
||||||
"AES-GCM",
|
|
||||||
forge.util.createBuffer(secret),
|
|
||||||
);
|
|
||||||
cipher.start({
|
|
||||||
iv,
|
|
||||||
tagLength: 128,
|
|
||||||
});
|
|
||||||
cipher.update(forge.util.createBuffer(data, "utf8"));
|
|
||||||
cipher.finish();
|
|
||||||
|
|
||||||
const encryptedData = cipher.output;
|
|
||||||
const tag = cipher.mode.tag;
|
|
||||||
|
|
||||||
return `${forge.util.encode64(iv)}.${stringBufferToBase64(
|
|
||||||
encryptedData,
|
|
||||||
)}.${stringBufferToBase64(tag)}` as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function decryptData(data: string, secret: Uint8Array) {
|
|
||||||
if (secret.byteLength !== 32) throw new Error("Secret must be 256-bit");
|
|
||||||
|
|
||||||
const [iv, encryptedData, tag] = data.split(".");
|
|
||||||
if (!iv || !encryptedData || !tag)
|
|
||||||
throw new Error("Invalid encrypted data format");
|
|
||||||
|
|
||||||
const decipher = forge.cipher.createDecipher(
|
|
||||||
"AES-GCM",
|
|
||||||
forge.util.createBuffer(secret),
|
|
||||||
);
|
|
||||||
decipher.start({
|
|
||||||
iv: base64ToStringBuffer(iv),
|
|
||||||
tag: base64ToStringBuffer(tag),
|
|
||||||
tagLength: 128,
|
|
||||||
});
|
|
||||||
decipher.update(base64ToStringBuffer(encryptedData));
|
|
||||||
const pass = decipher.finish();
|
|
||||||
|
|
||||||
if (!pass) throw new Error("Error decrypting data");
|
|
||||||
|
|
||||||
return decipher.output.toString();
|
|
||||||
}
|
|
@@ -1,3 +1 @@
|
|||||||
export const name = "api";
|
export const name = "api";
|
||||||
export * from "./auth";
|
|
||||||
export * from "./crypto";
|
|
||||||
|
@@ -1,48 +0,0 @@
|
|||||||
import { ofetch } from "ofetch";
|
|
||||||
|
|
||||||
import type { SessionResponse } from "./auth";
|
|
||||||
|
|
||||||
export interface ChallengeTokenResponse {
|
|
||||||
challenge: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getLoginChallengeToken(
|
|
||||||
url: string,
|
|
||||||
publicKey: string,
|
|
||||||
): Promise<ChallengeTokenResponse> {
|
|
||||||
return ofetch<ChallengeTokenResponse>("/auth/login/start", {
|
|
||||||
method: "POST",
|
|
||||||
body: {
|
|
||||||
publicKey,
|
|
||||||
},
|
|
||||||
baseURL: url,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LoginResponse {
|
|
||||||
session: SessionResponse;
|
|
||||||
token: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LoginInput {
|
|
||||||
publicKey: string;
|
|
||||||
challenge: {
|
|
||||||
code: string;
|
|
||||||
signature: string;
|
|
||||||
};
|
|
||||||
device: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loginAccount(
|
|
||||||
url: string,
|
|
||||||
data: LoginInput,
|
|
||||||
): Promise<LoginResponse> {
|
|
||||||
return ofetch<LoginResponse>("/auth/login/complete", {
|
|
||||||
method: "POST",
|
|
||||||
body: {
|
|
||||||
namespace: "movie-web",
|
|
||||||
...data,
|
|
||||||
},
|
|
||||||
baseURL: url,
|
|
||||||
});
|
|
||||||
}
|
|
@@ -1,15 +0,0 @@
|
|||||||
import { ofetch } from "ofetch";
|
|
||||||
|
|
||||||
export interface MetaResponse {
|
|
||||||
version: string;
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
hasCaptcha: boolean;
|
|
||||||
captchaClientKey?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getBackendMeta(url: string): Promise<MetaResponse> {
|
|
||||||
return ofetch<MetaResponse>("/meta", {
|
|
||||||
baseURL: url,
|
|
||||||
});
|
|
||||||
}
|
|
@@ -1,64 +0,0 @@
|
|||||||
import { ofetch } from "ofetch";
|
|
||||||
|
|
||||||
import { getAuthHeaders } from "./auth";
|
|
||||||
|
|
||||||
export interface SessionResponse {
|
|
||||||
id: string;
|
|
||||||
userId: string;
|
|
||||||
createdAt: string;
|
|
||||||
accessedAt: string;
|
|
||||||
device: string;
|
|
||||||
userAgent: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SessionUpdate {
|
|
||||||
deviceName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Account {
|
|
||||||
profile: {
|
|
||||||
colorA: string;
|
|
||||||
colorB: string;
|
|
||||||
icon: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AccountWithToken = Account & {
|
|
||||||
sessionId: string;
|
|
||||||
userId: string;
|
|
||||||
token: string;
|
|
||||||
seed: string;
|
|
||||||
deviceName: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function getSessions(url: string, account: AccountWithToken) {
|
|
||||||
return ofetch<SessionResponse[]>(`/users/${account.userId}/sessions`, {
|
|
||||||
headers: getAuthHeaders(account.token),
|
|
||||||
baseURL: url,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateSession(
|
|
||||||
url: string,
|
|
||||||
account: AccountWithToken,
|
|
||||||
update: SessionUpdate,
|
|
||||||
) {
|
|
||||||
return ofetch<SessionResponse[]>(`/sessions/${account.sessionId}`, {
|
|
||||||
method: "PATCH",
|
|
||||||
headers: getAuthHeaders(account.token),
|
|
||||||
body: update,
|
|
||||||
baseURL: url,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function removeSession(
|
|
||||||
url: string,
|
|
||||||
token: string,
|
|
||||||
sessionId: string,
|
|
||||||
) {
|
|
||||||
return ofetch<SessionResponse[]>(`/sessions/${sessionId}`, {
|
|
||||||
method: "DELETE",
|
|
||||||
headers: getAuthHeaders(token),
|
|
||||||
baseURL: url,
|
|
||||||
});
|
|
||||||
}
|
|
@@ -1,39 +0,0 @@
|
|||||||
import { ofetch } from "ofetch";
|
|
||||||
|
|
||||||
import type { AccountWithToken } from "./sessions";
|
|
||||||
import { getAuthHeaders } from "./auth";
|
|
||||||
|
|
||||||
export interface SettingsInput {
|
|
||||||
applicationLanguage?: string;
|
|
||||||
applicationTheme?: string | null;
|
|
||||||
defaultSubtitleLanguage?: string;
|
|
||||||
proxyUrls?: string[] | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SettingsResponse {
|
|
||||||
applicationTheme?: string | null;
|
|
||||||
applicationLanguage?: string | null;
|
|
||||||
defaultSubtitleLanguage?: string | null;
|
|
||||||
proxyUrls?: string[] | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateSettings(
|
|
||||||
url: string,
|
|
||||||
account: AccountWithToken,
|
|
||||||
settings: SettingsInput,
|
|
||||||
) {
|
|
||||||
return ofetch<SettingsResponse>(`/users/${account.userId}/settings`, {
|
|
||||||
method: "PUT",
|
|
||||||
body: settings,
|
|
||||||
baseURL: url,
|
|
||||||
headers: getAuthHeaders(account.token),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getSettings(url: string, account: AccountWithToken) {
|
|
||||||
return ofetch<SettingsResponse>(`/users/${account.userId}/settings`, {
|
|
||||||
method: "GET",
|
|
||||||
baseURL: url,
|
|
||||||
headers: getAuthHeaders(account.token),
|
|
||||||
});
|
|
||||||
}
|
|
58
pnpm-lock.yaml
generated
58
pnpm-lock.yaml
generated
@@ -242,19 +242,6 @@ importers:
|
|||||||
version: 5.4.3
|
version: 5.4.3
|
||||||
|
|
||||||
packages/api:
|
packages/api:
|
||||||
dependencies:
|
|
||||||
'@noble/hashes':
|
|
||||||
specifier: ^1.4.0
|
|
||||||
version: 1.4.0
|
|
||||||
'@scure/bip39':
|
|
||||||
specifier: ^1.3.0
|
|
||||||
version: 1.3.0
|
|
||||||
node-forge:
|
|
||||||
specifier: ^1.3.1
|
|
||||||
version: 1.3.1
|
|
||||||
ofetch:
|
|
||||||
specifier: ^1.3.4
|
|
||||||
version: 1.3.4
|
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@movie-web/eslint-config':
|
'@movie-web/eslint-config':
|
||||||
specifier: workspace:^0.2.0
|
specifier: workspace:^0.2.0
|
||||||
@@ -265,9 +252,6 @@ importers:
|
|||||||
'@movie-web/tsconfig':
|
'@movie-web/tsconfig':
|
||||||
specifier: workspace:^0.1.0
|
specifier: workspace:^0.1.0
|
||||||
version: link:../../tooling/typescript
|
version: link:../../tooling/typescript
|
||||||
'@types/node-forge':
|
|
||||||
specifier: ^1.3.11
|
|
||||||
version: 1.3.11
|
|
||||||
eslint:
|
eslint:
|
||||||
specifier: ^8.56.0
|
specifier: ^8.56.0
|
||||||
version: 8.56.0
|
version: 8.56.0
|
||||||
@@ -2917,11 +2901,6 @@ packages:
|
|||||||
unpacker: 1.0.1
|
unpacker: 1.0.1
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/@noble/hashes@1.4.0:
|
|
||||||
resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==}
|
|
||||||
engines: {node: '>= 16'}
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/@nodelib/fs.scandir@2.1.5:
|
/@nodelib/fs.scandir@2.1.5:
|
||||||
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@@ -3609,17 +3588,6 @@ packages:
|
|||||||
react-native-video: 5.2.1
|
react-native-video: 5.2.1
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/@scure/base@1.1.6:
|
|
||||||
resolution: {integrity: sha512-ok9AWwhcgYuGG3Zfhyqg+zwl+Wn5uE+dwC0NV/2qQkx4dABbb/bx96vWu8NSj+BNjjSjno+JRYRjle1jV08k3g==}
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/@scure/bip39@1.3.0:
|
|
||||||
resolution: {integrity: sha512-disdg7gHuTDZtY+ZdkmLpPCk7fxZSu3gBiEGuoC1XYxv9cGx3Z6cpTggCgW6odSOOIXCiDjuGejW+aJKCY/pIQ==}
|
|
||||||
dependencies:
|
|
||||||
'@noble/hashes': 1.4.0
|
|
||||||
'@scure/base': 1.1.6
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/@segment/loosely-validate-event@2.0.0:
|
/@segment/loosely-validate-event@2.0.0:
|
||||||
resolution: {integrity: sha512-ZMCSfztDBqwotkl848ODgVcAmN4OItEWDCkshcKz0/W6gGSQayuuCtWV/MlodFivAZD793d6UgANd6wCXUfrIw==}
|
resolution: {integrity: sha512-ZMCSfztDBqwotkl848ODgVcAmN4OItEWDCkshcKz0/W6gGSQayuuCtWV/MlodFivAZD793d6UgANd6wCXUfrIw==}
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -5288,12 +5256,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==}
|
resolution: {integrity: sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/@types/node-forge@1.3.11:
|
|
||||||
resolution: {integrity: sha512-FQx220y22OKNTqaByeBGqHWYz4cl94tpcxeFdvBo3wjG6XPBuZ0BNgNZRV5J5TFmmcsJ4IzsLkmGRiQbnYsBEQ==}
|
|
||||||
dependencies:
|
|
||||||
'@types/node': 20.12.7
|
|
||||||
dev: true
|
|
||||||
|
|
||||||
/@types/node@17.0.45:
|
/@types/node@17.0.45:
|
||||||
resolution: {integrity: sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==}
|
resolution: {integrity: sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==}
|
||||||
dev: false
|
dev: false
|
||||||
@@ -7115,10 +7077,6 @@ packages:
|
|||||||
minimalistic-assert: 1.0.1
|
minimalistic-assert: 1.0.1
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/destr@2.0.3:
|
|
||||||
resolution: {integrity: sha512-2N3BOUU4gYMpTP24s5rF5iP7BDr7uNTCs4ozw3kf/eKfvWSIu93GEBi5m427YoyJoeOzQ5smuu4nNAPGb8idSQ==}
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/destroy@1.2.0:
|
/destroy@1.2.0:
|
||||||
resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==}
|
resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==}
|
||||||
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
|
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
|
||||||
@@ -10545,10 +10503,6 @@ packages:
|
|||||||
engines: {node: '>=10.5.0'}
|
engines: {node: '>=10.5.0'}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/node-fetch-native@1.6.4:
|
|
||||||
resolution: {integrity: sha512-IhOigYzAKHd244OC0JIMIUrjzctirCmPkaIfhDeGcEETWof5zKYUW7e7MYvChGWh/4CJeXEgsRyGzuF334rOOQ==}
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/node-fetch@2.7.0:
|
/node-fetch@2.7.0:
|
||||||
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
|
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
|
||||||
engines: {node: 4.x || >=6.0.0}
|
engines: {node: 4.x || >=6.0.0}
|
||||||
@@ -10727,14 +10681,6 @@ packages:
|
|||||||
es-abstract: 1.22.3
|
es-abstract: 1.22.3
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/ofetch@1.3.4:
|
|
||||||
resolution: {integrity: sha512-KLIET85ik3vhEfS+3fDlc/BAZiAp+43QEC/yCo5zkNoY2YaKvNkOaFr/6wCFgFH1kuYQM5pMNi0Tg8koiIemtw==}
|
|
||||||
dependencies:
|
|
||||||
destr: 2.0.3
|
|
||||||
node-fetch-native: 1.6.4
|
|
||||||
ufo: 1.5.3
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/on-finished@2.3.0:
|
/on-finished@2.3.0:
|
||||||
resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==}
|
resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -13382,10 +13328,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==}
|
resolution: {integrity: sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/ufo@1.5.3:
|
|
||||||
resolution: {integrity: sha512-Y7HYmWaFwPUmkoQCUIAYpKqkOf+SbVj/2fJJZ4RJMCfZp0rTGwRbzQD+HghfnhKOjL9E01okqz+ncJskGYfBNw==}
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/uglify-js@3.17.4:
|
/uglify-js@3.17.4:
|
||||||
resolution: {integrity: sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==}
|
resolution: {integrity: sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==}
|
||||||
engines: {node: '>=0.8.0'}
|
engines: {node: '>=0.8.0'}
|
||||||
|
Reference in New Issue
Block a user