chunk upload, modals, env update

This commit is contained in:
Verticool 2025-02-28 21:49:36 +06:00
parent 7cb7a7d73a
commit 1c1ee5df16
9 changed files with 419 additions and 99 deletions

28
package-lock.json generated
View File

@ -16,6 +16,7 @@
"axios": "^1.6.7", "axios": "^1.6.7",
"buffer": "^6.0.3", "buffer": "^6.0.3",
"clsx": "^2.1.0", "clsx": "^2.1.0",
"jssha": "^3.3.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hook-form": "^7.51.0", "react-hook-form": "^7.51.0",
@ -1437,6 +1438,26 @@
"jssha": "3.2.0" "jssha": "3.2.0"
} }
}, },
"node_modules/@ton/crypto-primitives/node_modules/jssha": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/jssha/-/jssha-3.2.0.tgz",
"integrity": "sha512-QuruyBENDWdN4tZwJbQq7/eAK85FqrI4oDbXjy5IBhYD+2pTJyBUWZe8ctWaCkrV0gy6AaelgOZZBMeswEa/6Q==",
"license": "BSD-3-Clause",
"peer": true,
"engines": {
"node": "*"
}
},
"node_modules/@ton/crypto/node_modules/jssha": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/jssha/-/jssha-3.2.0.tgz",
"integrity": "sha512-QuruyBENDWdN4tZwJbQq7/eAK85FqrI4oDbXjy5IBhYD+2pTJyBUWZe8ctWaCkrV0gy6AaelgOZZBMeswEa/6Q==",
"license": "BSD-3-Clause",
"peer": true,
"engines": {
"node": "*"
}
},
"node_modules/@tonconnect/isomorphic-eventsource": { "node_modules/@tonconnect/isomorphic-eventsource": {
"version": "0.0.2", "version": "0.0.2",
"resolved": "https://registry.npmjs.org/@tonconnect/isomorphic-eventsource/-/isomorphic-eventsource-0.0.2.tgz", "resolved": "https://registry.npmjs.org/@tonconnect/isomorphic-eventsource/-/isomorphic-eventsource-0.0.2.tgz",
@ -4202,11 +4223,10 @@
} }
}, },
"node_modules/jssha": { "node_modules/jssha": {
"version": "3.2.0", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/jssha/-/jssha-3.2.0.tgz", "resolved": "https://registry.npmjs.org/jssha/-/jssha-3.3.1.tgz",
"integrity": "sha512-QuruyBENDWdN4tZwJbQq7/eAK85FqrI4oDbXjy5IBhYD+2pTJyBUWZe8ctWaCkrV0gy6AaelgOZZBMeswEa/6Q==", "integrity": "sha512-VCMZj12FCFMQYcFLPRm/0lOBbLi8uM2BhXPTqw3U4YAfs4AZfiApOoBLoN8cQE60Z50m1MYMTQVCfgF/KaCVhQ==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"peer": true,
"engines": { "engines": {
"node": "*" "node": "*"
} }

1
src/env.d.ts vendored
View File

@ -3,6 +3,7 @@
interface ImportMetaEnv { interface ImportMetaEnv {
readonly VITE_SENTRY_DSN: string readonly VITE_SENTRY_DSN: string
readonly VITE_API_BASE_URL: string readonly VITE_API_BASE_URL: string
readonly VITE_API_BASE_STORAGE_URL: string
readonly MODE: 'development' | 'production' readonly MODE: 'development' | 'production'
readonly PROD: boolean readonly PROD: boolean
readonly DEV: boolean readonly DEV: boolean

View File

@ -1,4 +1,5 @@
import { useHapticFeedback } from "@vkruglikov/react-telegram-web-app"; import { useHapticFeedback, useWebApp } from "@vkruglikov/react-telegram-web-app";
import { useEffect } from "react";
import { Button } from "~/shared/ui/button"; import { Button } from "~/shared/ui/button";
type DisclaimerModalProps = { type DisclaimerModalProps = {
@ -9,6 +10,20 @@ export const DisclaimerModal = ({
onConfirm, onConfirm,
}: DisclaimerModalProps) => { }: DisclaimerModalProps) => {
const [impactOccurred] = useHapticFeedback(); const [impactOccurred] = useHapticFeedback();
const WebApp = useWebApp();
useEffect(() => {
// Отключаем вертикальные свайпы при монтировании компонента
if (WebApp && WebApp.disableVerticalSwipes) {
WebApp.disableVerticalSwipes();
}
// Включаем вертикальные свайпы обратно при размонтировании
return () => {
if (WebApp && WebApp.enableVerticalSwipes) {
WebApp.enableVerticalSwipes();
}
};
}, []);
const handleClick = (fn: () => void) => { const handleClick = (fn: () => void) => {
impactOccurred("light"); impactOccurred("light");
@ -21,40 +36,40 @@ export const DisclaimerModal = ({
"fixed left-0 top-0 z-30 flex h-full w-full items-center justify-center bg-black/80 px-[15px]" "fixed left-0 top-0 z-30 flex h-full w-full items-center justify-center bg-black/80 px-[15px]"
} }
> >
<div className={"flex flex-col gap-[30px]"}> <div className={"flex flex-col max-h-[80vh] w-[85vw] max-w-md"}>
<div <div
className={ className={
"border border-white bg-[#1D1D1B] px-[10px] py-[16px] text-start flex flex-col gap-12" "border border-white bg-[#1D1D1B] px-[15px] py-[16px] text-start flex flex-col gap-12 h-full overflow-y-auto"
} }
> >
<p className="mt-4"> <div className="flex flex-col gap-6">
«Внимание! <p className="mt-2">
</p> Внимание!
<p className=""> </p>
MY снимает с себя ответственность за правомерность загрузки контента пользователем. <p>
</p> MY снимает с себя ответственность за правомерность загрузки контента пользователем.
<p className="flex flex-col"> </p>
<span className="tracking-[.25em] w-full"> <p className="flex flex-col">
Сервис исходит из личной <span className="tracking-[.25em] w-full">
</span> Сервис исходит из личной
ответственности пользователя перед законом и третьими лицами. MY категорически не приемлет любые виды пиратства, но признает за Пользователем право принятия самостоятельных решений. </span>
</p> ответственности пользователя перед законом и третьими лицами. MY категорически не приемлет любые виды пиратства, но признает за Пользователем право принятия самостоятельных решений.
<p className="flex flex-col"> </p>
<span className="tracking-[.25em] w-full"> <p className="flex flex-col">
Перед загрузкой контента <span className="tracking-[.25em] w-full">
</span> Перед загрузкой контента
необходимо убедиться, что первые 30 секунд контента, которые будут использоваться для превью, не содержат материалов, нарушающих возрастное ограничение 18+» </span>
</p> необходимо убедиться, что первые 30 секунд контента, которые будут использоваться для превью, не содержат материалов, нарушающих возрастное ограничение 18+
<Button </p>
className={"mt-[20px]"} </div>
label={"Принять и продолжить"}
includeArrows={false}
onClick={() => handleClick(onConfirm)}
/>
</div> </div>
<Button
className={"mt-[20px] sticky bottom-0"}
label={"Принять и продолжить"}
includeArrows={false}
onClick={() => handleClick(onConfirm)}
/>
</div> </div>
</div> </div>
); );
}; };

View File

@ -0,0 +1,75 @@
import { useHapticFeedback, useWebApp } from "@vkruglikov/react-telegram-web-app";
import { useEffect } from "react";
import { Button } from "~/shared/ui/button";
type ErrorUploadProps = {
onConfirm(): void;
};
export const ErrorUploadModal = ({
onConfirm,
}: ErrorUploadProps) => {
const [impactOccurred] = useHapticFeedback();
const WebApp = useWebApp();
useEffect(() => {
// Отключаем вертикальные свайпы при монтировании компонента
if (WebApp && WebApp.disableVerticalSwipes) {
WebApp.disableVerticalSwipes();
}
// Включаем вертикальные свайпы обратно при размонтировании
return () => {
if (WebApp && WebApp.enableVerticalSwipes) {
WebApp.enableVerticalSwipes();
}
};
}, []);
const handleClick = (fn: () => void) => {
impactOccurred("light");
fn();
};
return (
<div
className={
"fixed left-0 top-0 z-30 flex h-full w-full items-center justify-center bg-black/80 px-[15px]"
}
>
<div className={"flex flex-col max-h-[80vh] w-[85vw] max-w-md"}>
<div
className={
"border border-white bg-[#1D1D1B] px-[15px] py-[16px] text-start flex flex-col gap-12 h-full overflow-y-auto"
}
>
<div className="flex flex-col gap-6">
<p className="mt-2">
Внимание!
</p>
<p>
Произошла ошибка при загрузке видео.
</p>
<p className="flex flex-col">
<span className="tracking-[.25em] w-full">
Загрузка не завершена
</span>
из-за технических проблем или превышения допустимых ограничений. Вы можете попробовать загрузить файл ещё раз или выбрать другой файл.
</p>
<p className="flex flex-col">
<span className="tracking-[.25em] w-full">
Если проблема повторяется
</span>
обратитесь в техническую поддержку сервиса MY, предоставив подробную информацию о загружаемом файле и возникшей ошибке.
</p>
</div>
</div>
<Button
className={"mt-[20px] sticky bottom-0"}
label={"Понятно"}
includeArrows={false}
onClick={() => handleClick(onConfirm)}
/>
</div>
</div>
);
};

View File

@ -2,7 +2,7 @@ import {
useHapticFeedback, useHapticFeedback,
useWebApp, useWebApp,
} from "@vkruglikov/react-telegram-web-app"; } from "@vkruglikov/react-telegram-web-app";
import { useState } from "react"; import { useEffect, useState } from "react";
import ReactPlayer from "react-player/lazy"; import ReactPlayer from "react-player/lazy";
import { Button } from "~/shared/ui/button"; import { Button } from "~/shared/ui/button";
@ -13,6 +13,7 @@ import { Progress } from "~/shared/ui/progress";
import { useCreateNewContent } from "~/shared/services/content"; import { useCreateNewContent } from "~/shared/services/content";
import { BackButton } from "~/shared/ui/back-button"; import { BackButton } from "~/shared/ui/back-button";
import { useTonConnectUI } from "@tonconnect/ui-react"; import { useTonConnectUI } from "@tonconnect/ui-react";
import { ErrorUploadModal } from "./components/error-upload-modal";
@ -35,6 +36,20 @@ export const PresubmitStep = ({ prevStep }: PresubmitStepProps) => {
const createContent = useCreateNewContent(); const createContent = useCreateNewContent();
const [isErrorUploadModal, setIsErrorUploadModal] = useState(false);
const handleErrorUploadModal = () => {
setIsErrorUploadModal(false);
uploadFile.resetUploadError();
uploadCover.resetUploadError();
};
useEffect(() => {
if (uploadFile.uploadError || uploadCover.uploadError) {
setIsErrorUploadModal(true);
}
}, [uploadFile.uploadError, uploadCover.uploadError]);
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {
let coverUploadResult = { content_id_v1: "" }; let coverUploadResult = { content_id_v1: "" };
@ -119,6 +134,11 @@ export const PresubmitStep = ({ prevStep }: PresubmitStepProps) => {
return ( return (
<section className={"mt-4 px-4 pb-8"}> <section className={"mt-4 px-4 pb-8"}>
{isErrorUploadModal && (
<ErrorUploadModal
onConfirm={() => {
handleErrorUploadModal()}}
/>)}
<BackButton onClick={prevStep} /> <BackButton onClick={prevStep} />
<div className={"mb-[30px] flex flex-col text-sm"}> <div className={"mb-[30px] flex flex-col text-sm"}>

View File

@ -1,4 +1,5 @@
import { useHapticFeedback } from "@vkruglikov/react-telegram-web-app"; import { useHapticFeedback, useWebApp } from "@vkruglikov/react-telegram-web-app";
import { useEffect } from "react";
import { Button } from "~/shared/ui/button"; import { Button } from "~/shared/ui/button";
type CongratsModalProps = { type CongratsModalProps = {
@ -9,25 +10,38 @@ export const CongratsModal = ({
onConfirm, onConfirm,
}: CongratsModalProps) => { }: CongratsModalProps) => {
const [impactOccurred] = useHapticFeedback(); const [impactOccurred] = useHapticFeedback();
const WebApp = useWebApp();
const handleClick = (fn: () => void) => { const handleClick = (fn: () => void) => {
impactOccurred("light"); impactOccurred("light");
fn(); fn();
}; };
useEffect(() => {
// Отключаем вертикальные свайпы при монтировании компонента
if (WebApp && WebApp.disableVerticalSwipes) {
WebApp.disableVerticalSwipes();
}
// Включаем вертикальные свайпы обратно при размонтировании
return () => {
if (WebApp && WebApp.enableVerticalSwipes) {
WebApp.enableVerticalSwipes();
}
};
}, []);
return ( return (
<div <div
className={ className={
"fixed left-0 top-0 z-30 flex h-full w-full items-center justify-center bg-black/80 px-[15px]" "fixed left-0 top-0 z-30 flex h-full w-full items-center justify-center bg-black/80 px-[15px]"
} }
> >
<div className={"flex flex-col gap-[30px]"}> <div className={"flex flex-col max-h-[80vh] w-[85vw] max-w-md"}>
<div <div
className={ className={
"border border-white bg-[#1D1D1B] px-[10px] py-[16px] text-start flex flex-col gap-12" "border border-white bg-[#1D1D1B] px-[15px] py-[16px] text-start flex flex-col gap-6 h-full overflow-y-auto"
} }
> >
<p className="mt-12"> <p className="mt-4">
<span className="text-xl">🎉</span> <span className="text-xl">🎉</span>
<span className="px-1 font-bold">Поздравляем с покупкой!</span> <span className="px-1 font-bold">Поздравляем с покупкой!</span>
<span className="text-xl">🎉</span> <span className="text-xl">🎉</span>
@ -44,13 +58,13 @@ export const CongratsModal = ({
<p className="flex flex-col"> <p className="flex flex-col">
Спасибо, что выбираете MY! Спасибо, что выбираете MY!
</p> </p>
<Button
className={"mt-[20px]"}
label={"Ок"}
includeArrows={false}
onClick={() => handleClick(onConfirm)}
/>
</div> </div>
<Button
className={"mt-[20px]"}
label={"Ок"}
includeArrows={false}
onClick={() => handleClick(onConfirm)}
/>
</div> </div>
</div> </div>
); );

View File

@ -1,4 +1,5 @@
import { useHapticFeedback } from "@vkruglikov/react-telegram-web-app"; import { useHapticFeedback, useWebApp } from "@vkruglikov/react-telegram-web-app";
import { useEffect } from "react";
import { Button } from "~/shared/ui/button"; import { Button } from "~/shared/ui/button";
type ErrorModalProps = { type ErrorModalProps = {
@ -9,25 +10,39 @@ export const ErrorModal = ({
onConfirm, onConfirm,
}: ErrorModalProps) => { }: ErrorModalProps) => {
const [impactOccurred] = useHapticFeedback(); const [impactOccurred] = useHapticFeedback();
const WebApp = useWebApp();
const handleClick = (fn: () => void) => { const handleClick = (fn: () => void) => {
impactOccurred("light"); impactOccurred("light");
fn(); fn();
}; };
useEffect(() => {
// Отключаем вертикальные свайпы при монтировании компонента
if (WebApp && WebApp.disableVerticalSwipes) {
WebApp.disableVerticalSwipes();
}
// Включаем вертикальные свайпы обратно при размонтировании
return () => {
if (WebApp && WebApp.enableVerticalSwipes) {
WebApp.enableVerticalSwipes();
}
};
}, []);
return ( return (
<div
className={
"fixed left-0 top-0 z-30 flex h-full w-full items-center justify-center bg-black/80 px-[15px]"
}
>
<div className={"flex flex-col max-h-[80vh] w-[85vw] max-w-md"}>
<div <div
className={ className={
"fixed left-0 top-0 z-30 flex h-full w-full items-center justify-center bg-black/80 px-[15px]" "border border-white bg-[#1D1D1B] px-[15px] py-[16px] text-start flex flex-col gap-6 h-full overflow-y-auto"
} }
> >
<div className={"flex flex-col gap-[30px]"}> <p className="mt-4">
<div
className={
"border border-white bg-[#1D1D1B] px-[10px] py-[16px] text-start flex flex-col gap-12"
}
>
<p className="mt-12">
<span className="font-bold">Ошибка запроса транзакции</span> <span className="font-bold">Ошибка запроса транзакции</span>
</p> </p>
<p className=""> <p className="">
@ -41,13 +56,13 @@ export const ErrorModal = ({
<p className="flex flex-col"> <p className="flex flex-col">
Если проблема не исчезает, убедитесь, что ваш кошелек работает корректно, или свяжитесь с поддержкой. Если проблема не исчезает, убедитесь, что ваш кошелек работает корректно, или свяжитесь с поддержкой.
</p> </p>
<Button
className={"mt-[20px]"}
label={"Ок"}
includeArrows={false}
onClick={() => handleClick(onConfirm)}
/>
</div> </div>
<Button
className={"mt-[20px]"}
label={"Ок"}
includeArrows={false}
onClick={() => handleClick(onConfirm)}
/>
</div> </div>
</div> </div>
); );

View File

@ -1,46 +1,201 @@
import { useMutation } from "react-query"; import { useMutation } from "react-query";
import { useState } from "react"; import { useState } from "react";
import { request } from "~/shared/libs"; import { request } from "~/shared/libs";
const STORAGE_API_URL = import.meta.env.VITE_API_BASE_STORAGE_URL;
const MAX_CHUNK_SIZE = 80 * 1024 * 1024; // 80 MB
export const useUploadFile = () => { export const useUploadFile = () => {
const [uploadProgress, setUploadProgress] = useState(0); const [uploadProgress, setUploadProgress] = useState(0);
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [uploadError, setUploadError] = useState<Error | null>(null);
const mutation = useMutation(["upload-file"], (file: File) => { const mutation = useMutation(["upload-file"], async (file: File) => {
console.log(`Начинаем загрузку файла: ${file.name} (${file.size} байт)`);
setIsUploading(true); setIsUploading(true);
setUploadProgress(0);
setUploadError(null); // Сбрасываем ошибку перед началом новой загрузки
const formData = new FormData(); try {
formData.append("file", file); // Для маленьких файлов используем обычную загрузку, но с теми же заголовками
if (file.size <= MAX_CHUNK_SIZE) {
return request console.log("Используем обычную загрузку (файл <= MAX_CHUNK_SIZE)");
.post<{
content_sha256: string; // Подготавливаем заголовки - такие же, как для чанковой загрузки
content_id_v1: string; const headers: Record<string, string> = {
content_url: string; "X-File-Name": btoa(unescape(encodeURIComponent(file.name))), // Имя файла в base64
}>("/storage", formData, { "X-Chunk-Start": "0", // Начинаем с позиции 0
headers: { "Content-Type": file.type || "application/octet-stream",
"Content-Type": "multipart/form-data", "X-Last-Chunk": "1" // Это единственный и последний чанк
Authorization: localStorage.getItem('auth_v1_token') || "" };
},
// Добавляем заголовок авторизации
onUploadProgress: (progressEvent) => { const authToken = localStorage.getItem('auth_v1_token');
const percentCompleted = Math.round( if (authToken) {
(progressEvent.loaded * 100) / (progressEvent?.total as number) || headers["Authorization"] = authToken;
0, }
);
setUploadProgress(percentCompleted); console.log("Заголовки запроса:", headers);
},
}) try {
.then((response) => { const response = await request.post<{
setIsUploading(false); upload_id?: string;
return response.data; content_sha256?: string;
}) content_id?: string;
.catch((error) => { content_id_v1?: string;
setIsUploading(false); content_url?: string;
throw error; }>("", file, { // Отправляем файл напрямую вместо FormData
}); baseURL: STORAGE_API_URL,
headers,
onUploadProgress: (progressEvent) => {
const percentCompleted = Math.round(
(progressEvent.loaded * 100) / (progressEvent?.total as number || file.size)
);
setUploadProgress(Math.min(99, percentCompleted));
},
});
console.log("Ответ на обычную загрузку:", response.data);
setUploadProgress(100);
return {
content_sha256: response.data.content_sha256 || "",
content_id_v1: response.data.content_id_v1 || response.data.content_id || "",
content_url: response.data.content_url || ""
};
} catch (error) {
console.error("Ошибка при обычной загрузке:", error);
setUploadError(error instanceof Error ? error : new Error('Ошибка при загрузке файла'));
throw error;
}
}
// Для больших файлов используем чанковую загрузку
console.log("Используем чанкированную загрузку (файл > MAX_CHUNK_SIZE)");
let offset = 0;
let uploadId: string | null = null;
let chunkNumber = 0;
// Загружаем файл по чанкам
while (offset < file.size) {
chunkNumber++;
const chunkEnd = Math.min(offset + MAX_CHUNK_SIZE, file.size);
const chunk = file.slice(offset, chunkEnd);
console.log(`Загрузка чанка #${chunkNumber} начиная с байта ${offset}`);
// Определяем, является ли текущий чанк последним
const isLastChunk = chunkEnd === file.size;
// Подготавливаем заголовки
const headers: Record<string, string> = {
"X-File-Name": btoa(unescape(encodeURIComponent(file.name))), // Имя файла в base64
"X-Chunk-Start": offset.toString(),
"Content-Type": file.type || "application/octet-stream"
};
// Добавляем маркер последнего чанка, если это последний чанк
if (isLastChunk) {
headers["X-Last-Chunk"] = "1";
}
// Добавляем заголовок авторизации
const authToken = localStorage.getItem('auth_v1_token');
if (authToken) {
headers["Authorization"] = authToken;
}
// Если есть uploadId, добавляем его в заголовки
if (uploadId) {
headers["X-Upload-ID"] = uploadId;
}
console.log("Заголовки запроса:", headers);
try {
const response = await request.post<{
upload_id?: string;
current_size?: number;
content_id?: string;
content_sha256?: string;
content_id_v1?: string;
content_url?: string;
}>("", chunk, {
baseURL: STORAGE_API_URL,
headers,
onUploadProgress: (progressEvent) => {
// Прогресс загрузки текущего чанка
const overallProgress = offset + progressEvent.loaded;
const percentCompleted = Math.round((overallProgress / file.size) * 100);
setUploadProgress(Math.min(99, percentCompleted));
},
});
console.log(`Ответ на чанк #${chunkNumber}:`, response.data);
// Сохраняем uploadId из первого ответа, если не установлен
if (!uploadId && response.data.upload_id) {
uploadId = response.data.upload_id;
console.log("Получен upload_id:", uploadId);
}
// Проверка на наличие content_id
if (response.data.content_id) {
console.log("Загрузка завершена. ID файла:", response.data.content_id);
setUploadProgress(100);
return {
content_sha256: response.data.content_sha256 || "",
content_id_v1: response.data.content_id_v1 || response.data.content_id || "",
content_url: response.data.content_url || ""
};
}
// Обновляем смещение на основе ответа сервера
if (response.data.current_size !== undefined) {
offset = response.data.current_size;
console.log(`Сервер сообщает current_size: ${offset}`);
} else {
console.warn("Неожиданный ответ от сервера, отсутствует current_size");
const error = new Error("Missing current_size in response");
setUploadError(error);
throw error;
}
} catch (error: any) {
console.error(`Ошибка при загрузке чанка #${chunkNumber}:`, error);
if (error.response) {
console.error("Ответ сервера:", error.response.status, error.response.data);
}
setUploadError(error instanceof Error ? error : new Error(`Ошибка при загрузке чанка #${chunkNumber}`));
throw error;
}
}
const error = new Error("Ошибка загрузки файла: все чанки загружены, но content_id не получен");
console.error(error.message);
setUploadError(error);
throw error;
} catch (error: any) {
console.error("Ошибка при загрузке:", error);
if (error.response) {
console.error("Ответ сервера:", error.response.status, error.response.data);
}
setUploadError(error instanceof Error ? error : new Error("Неизвестная ошибка при загрузке"));
setIsUploading(false);
throw error;
} finally {
setIsUploading(false);
}
}); });
return { ...mutation, uploadProgress, isUploading }; // Сбросить ошибку
}; const resetUploadError = () => setUploadError(null);
return {
...mutation,
uploadProgress,
isUploading,
uploadError,
resetUploadError
};
};

View File

@ -2076,6 +2076,11 @@ json5@^2.2.3:
resolved "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz" resolved "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz"
integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==
jssha@^3.3.1:
version "3.3.1"
resolved "https://registry.npmjs.org/jssha/-/jssha-3.3.1.tgz"
integrity sha512-VCMZj12FCFMQYcFLPRm/0lOBbLi8uM2BhXPTqw3U4YAfs4AZfiApOoBLoN8cQE60Z50m1MYMTQVCfgF/KaCVhQ==
jssha@3.2.0: jssha@3.2.0:
version "3.2.0" version "3.2.0"
resolved "https://registry.npmjs.org/jssha/-/jssha-3.2.0.tgz" resolved "https://registry.npmjs.org/jssha/-/jssha-3.2.0.tgz"