mirror of
https://github.com/movie-web/native-app.git
synced 2025-09-13 12:33:26 +00:00
Compare commits
6 Commits
925c1a39fc
...
bd8a4394ea
Author | SHA1 | Date | |
---|---|---|---|
|
bd8a4394ea | ||
|
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 { 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() {
|
||||
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 { ScrollView } from "tamagui";
|
||||
import { LinearGradient } from "tamagui/linear-gradient";
|
||||
@@ -5,26 +6,14 @@ import { LinearGradient } from "tamagui/linear-gradient";
|
||||
import { Header } from "./Header";
|
||||
|
||||
interface Props {
|
||||
children?: React.ReactNode;
|
||||
onScrollBeginDrag?: () => void;
|
||||
onMomentumScrollEnd?: () => void;
|
||||
showHeader?: boolean;
|
||||
scrollEnabled?: boolean;
|
||||
keyboardDismissMode?: "none" | "on-drag" | "interactive";
|
||||
keyboardShouldPersistTaps?: "always" | "never" | "handled";
|
||||
contentContainerStyle?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export default function ScreenLayout({
|
||||
children,
|
||||
onScrollBeginDrag,
|
||||
onMomentumScrollEnd,
|
||||
showHeader = true,
|
||||
scrollEnabled,
|
||||
keyboardDismissMode,
|
||||
keyboardShouldPersistTaps,
|
||||
contentContainerStyle,
|
||||
}: Props) {
|
||||
...props
|
||||
}: ScrollViewProps & Props) {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
@@ -47,15 +36,10 @@ export default function ScreenLayout({
|
||||
>
|
||||
{showHeader && <Header />}
|
||||
<ScrollView
|
||||
onScrollBeginDrag={onScrollBeginDrag}
|
||||
onMomentumScrollEnd={onMomentumScrollEnd}
|
||||
scrollEnabled={scrollEnabled}
|
||||
keyboardDismissMode={keyboardDismissMode}
|
||||
keyboardShouldPersistTaps={keyboardShouldPersistTaps}
|
||||
contentContainerStyle={contentContainerStyle}
|
||||
marginTop="$4"
|
||||
flexGrow={1}
|
||||
showsVerticalScrollIndicator={false}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
|
@@ -7,21 +7,33 @@ export const MWButton = styled(Button, {
|
||||
backgroundColor: "$buttonPrimaryBackground",
|
||||
color: "$buttonPrimaryText",
|
||||
fontWeight: "bold",
|
||||
pressStyle: {
|
||||
backgroundColor: "$buttonPrimaryBackgroundHover",
|
||||
},
|
||||
},
|
||||
secondary: {
|
||||
backgroundColor: "$buttonSecondaryBackground",
|
||||
color: "$buttonSecondaryText",
|
||||
fontWeight: "bold",
|
||||
pressStyle: {
|
||||
backgroundColor: "$buttonSecondaryBackgroundHover",
|
||||
},
|
||||
},
|
||||
purple: {
|
||||
backgroundColor: "$buttonPurpleBackground",
|
||||
color: "white",
|
||||
fontWeight: "bold",
|
||||
pressStyle: {
|
||||
backgroundColor: "$buttonPurpleBackgroundHover",
|
||||
},
|
||||
},
|
||||
cancel: {
|
||||
backgroundColor: "$buttonCancelBackground",
|
||||
color: "white",
|
||||
fontWeight: "bold",
|
||||
pressStyle: {
|
||||
backgroundColor: "$buttonCancelBackgroundHover",
|
||||
},
|
||||
},
|
||||
},
|
||||
} 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/prettier-config": "workspace:^0.1.0",
|
||||
"@movie-web/tsconfig": "workspace:^0.1.0",
|
||||
"@types/node-forge": "^1.3.11",
|
||||
"eslint": "^8.56.0",
|
||||
"prettier": "^3.1.1",
|
||||
"typescript": "^5.4.3"
|
||||
@@ -27,5 +28,11 @@
|
||||
"@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 * 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
|
||||
|
||||
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:
|
||||
'@movie-web/eslint-config':
|
||||
specifier: workspace:^0.2.0
|
||||
@@ -252,6 +265,9 @@ importers:
|
||||
'@movie-web/tsconfig':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../../tooling/typescript
|
||||
'@types/node-forge':
|
||||
specifier: ^1.3.11
|
||||
version: 1.3.11
|
||||
eslint:
|
||||
specifier: ^8.56.0
|
||||
version: 8.56.0
|
||||
@@ -2901,6 +2917,11 @@ packages:
|
||||
unpacker: 1.0.1
|
||||
dev: false
|
||||
|
||||
/@noble/hashes@1.4.0:
|
||||
resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==}
|
||||
engines: {node: '>= 16'}
|
||||
dev: false
|
||||
|
||||
/@nodelib/fs.scandir@2.1.5:
|
||||
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -3588,6 +3609,17 @@ packages:
|
||||
react-native-video: 5.2.1
|
||||
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:
|
||||
resolution: {integrity: sha512-ZMCSfztDBqwotkl848ODgVcAmN4OItEWDCkshcKz0/W6gGSQayuuCtWV/MlodFivAZD793d6UgANd6wCXUfrIw==}
|
||||
dependencies:
|
||||
@@ -5256,6 +5288,12 @@ packages:
|
||||
resolution: {integrity: sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==}
|
||||
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:
|
||||
resolution: {integrity: sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==}
|
||||
dev: false
|
||||
@@ -7077,6 +7115,10 @@ packages:
|
||||
minimalistic-assert: 1.0.1
|
||||
dev: false
|
||||
|
||||
/destr@2.0.3:
|
||||
resolution: {integrity: sha512-2N3BOUU4gYMpTP24s5rF5iP7BDr7uNTCs4ozw3kf/eKfvWSIu93GEBi5m427YoyJoeOzQ5smuu4nNAPGb8idSQ==}
|
||||
dev: false
|
||||
|
||||
/destroy@1.2.0:
|
||||
resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==}
|
||||
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
|
||||
@@ -10503,6 +10545,10 @@ packages:
|
||||
engines: {node: '>=10.5.0'}
|
||||
dev: false
|
||||
|
||||
/node-fetch-native@1.6.4:
|
||||
resolution: {integrity: sha512-IhOigYzAKHd244OC0JIMIUrjzctirCmPkaIfhDeGcEETWof5zKYUW7e7MYvChGWh/4CJeXEgsRyGzuF334rOOQ==}
|
||||
dev: false
|
||||
|
||||
/node-fetch@2.7.0:
|
||||
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
|
||||
engines: {node: 4.x || >=6.0.0}
|
||||
@@ -10681,6 +10727,14 @@ packages:
|
||||
es-abstract: 1.22.3
|
||||
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:
|
||||
resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -13328,6 +13382,10 @@ packages:
|
||||
resolution: {integrity: sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==}
|
||||
dev: false
|
||||
|
||||
/ufo@1.5.3:
|
||||
resolution: {integrity: sha512-Y7HYmWaFwPUmkoQCUIAYpKqkOf+SbVj/2fJJZ4RJMCfZp0rTGwRbzQD+HghfnhKOjL9E01okqz+ncJskGYfBNw==}
|
||||
dev: false
|
||||
|
||||
/uglify-js@3.17.4:
|
||||
resolution: {integrity: sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==}
|
||||
engines: {node: '>=0.8.0'}
|
||||
|
Reference in New Issue
Block a user