Compare commits

...

5 Commits

Author SHA1 Message Date
Adrian Castro
4f833bee46 chore: formatting 2024-04-15 21:19:46 +02:00
Adrian Castro
338e633d48 feat: additional api package stuff 2024-04-15 21:18:25 +02:00
Adrian Castro
4e01f35458 feat: auth store 2024-04-15 20:49:21 +02:00
Adrian Castro
e8dfb5eaf4 feat: auth functions 2024-04-15 20:15:57 +02:00
Jorrin
07d313b1fd start with movie-web page 2024-04-15 19:34:42 +02:00
16 changed files with 690 additions and 22 deletions

View File

@@ -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 &apos;Get started&apos;.
</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>
);
} }

View 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,
}}
/>
);
}

View 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>
);
}

View File

@@ -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>

View File

@@ -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,

View 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,
});

View File

@@ -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),
},
),
);

View File

@@ -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
View 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
View 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();
}

View File

@@ -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
View 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
View 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,
});
}

View 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,
});
}

View 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
View File

@@ -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'}