mirror of
https://github.com/movie-web/native-app.git
synced 2025-09-13 12:33:26 +00:00
Compare commits
5 Commits
0622e4338c
...
4f833bee46
Author | SHA1 | Date | |
---|---|---|---|
|
4f833bee46 | ||
|
338e633d48 | ||
|
4e01f35458 | ||
|
e8dfb5eaf4 | ||
|
07d313b1fd |
@@ -1,5 +1,60 @@
|
|||||||
|
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() {
|
||||||
return <ScreenLayout></ScreenLayout>;
|
const { backendUrl, setBackendUrl } = useAuthStore();
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
14
apps/expo/src/app/sync/_layout.tsx
Normal file
14
apps/expo/src/app/sync/_layout.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Stack } from "expo-router";
|
||||||
|
|
||||||
|
import { BrandPill } from "~/components/BrandPill";
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
headerTransparent: true,
|
||||||
|
headerRight: BrandPill,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
117
apps/expo/src/app/sync/trust/[url].tsx
Normal file
117
apps/expo/src/app/sync/trust/[url].tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
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,3 +1,4 @@
|
|||||||
|
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";
|
||||||
@@ -5,26 +6,14 @@ 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,
|
||||||
scrollEnabled,
|
...props
|
||||||
keyboardDismissMode,
|
}: ScrollViewProps & Props) {
|
||||||
keyboardShouldPersistTaps,
|
|
||||||
contentContainerStyle,
|
|
||||||
}: Props) {
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -47,15 +36,10 @@ 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,21 +7,33 @@ 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,
|
||||||
|
21
apps/expo/src/components/ui/Card.tsx
Normal file
21
apps/expo/src/components/ui/Card.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
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,3 +257,66 @@ 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,6 +18,7 @@
|
|||||||
"@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"
|
||||||
@@ -27,5 +28,11 @@
|
|||||||
"@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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
35
packages/api/src/auth.ts
Normal file
35
packages/api/src/auth.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
134
packages/api/src/crypto.ts
Normal file
134
packages/api/src/crypto.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
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 +1,3 @@
|
|||||||
export const name = "api";
|
export const name = "api";
|
||||||
|
export * from "./auth";
|
||||||
|
export * from "./crypto";
|
||||||
|
48
packages/api/src/login.ts
Normal file
48
packages/api/src/login.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
15
packages/api/src/meta.ts
Normal file
15
packages/api/src/meta.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
64
packages/api/src/sessions.ts
Normal file
64
packages/api/src/sessions.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
39
packages/api/src/settings.ts
Normal file
39
packages/api/src/settings.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
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,6 +242,19 @@ 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
|
||||||
@@ -252,6 +265,9 @@ 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
|
||||||
@@ -2901,6 +2917,11 @@ 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'}
|
||||||
@@ -3588,6 +3609,17 @@ 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:
|
||||||
@@ -5256,6 +5288,12 @@ 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
|
||||||
@@ -7077,6 +7115,10 @@ 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}
|
||||||
@@ -10503,6 +10545,10 @@ 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}
|
||||||
@@ -10681,6 +10727,14 @@ 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'}
|
||||||
@@ -13328,6 +13382,10 @@ 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