From 0b9f32fd8c8377e20203c03842b4587ac92cec05 Mon Sep 17 00:00:00 2001 From: PhilReact Date: Thu, 22 May 2025 19:05:58 +0300 Subject: [PATCH] handle large uploads --- .../Qortal/qortalMobile/MainActivity.java | 7 +- capacitor.config.ts | 2 +- src/background-cases.ts | 2 + src/backgroundFunctions/encryption.ts | 21 +- src/components/Apps/AppPublish.tsx | 34 +- src/components/Apps/Apps.tsx | 8 +- src/components/Apps/AppsHome.tsx | 4 +- src/components/Apps/AppsPrivate.tsx | 58 +- .../Apps/useQortalMessageListener.tsx | 151 ++++- src/components/Group/Group.tsx | 4 +- .../Group/ListOfGroupPromotions.tsx | 1 + src/components/MainAvatar.tsx | 1 + src/components/Save/Save.tsx | 1 + src/hooks/useSortedMyNames.tsx | 11 + src/qdn/publish/pubish.ts | 518 +++++++++++------- src/qortalRequests/get.ts | 441 +++++++++------ 16 files changed, 827 insertions(+), 437 deletions(-) create mode 100644 src/hooks/useSortedMyNames.tsx diff --git a/android/app/src/main/java/com/github/Qortal/qortalMobile/MainActivity.java b/android/app/src/main/java/com/github/Qortal/qortalMobile/MainActivity.java index 5a50ead..0d9b09b 100644 --- a/android/app/src/main/java/com/github/Qortal/qortalMobile/MainActivity.java +++ b/android/app/src/main/java/com/github/Qortal/qortalMobile/MainActivity.java @@ -4,6 +4,8 @@ import com.getcapacitor.BridgeActivity; import com.github.Qortal.qortalMobile.NativeBcrypt; import com.github.Qortal.qortalMobile.NativePOW; import android.os.Bundle; +import android.webkit.WebSettings; +import android.webkit.WebView; public class MainActivity extends BridgeActivity { @Override @@ -12,6 +14,9 @@ public class MainActivity extends BridgeActivity { registerPlugin(NativePOW.class); super.onCreate(savedInstanceState); - + // ✅ Enable mixed content mode for WebView + WebView webView = this.bridge.getWebView(); + WebSettings webSettings = webView.getSettings(); + webSettings.setMixedContentMode(WebSettings.MIXED_CONTENT_ALWAYS_ALLOW); } } diff --git a/capacitor.config.ts b/capacitor.config.ts index 5b646fd..1e4f44b 100644 --- a/capacitor.config.ts +++ b/capacitor.config.ts @@ -19,7 +19,7 @@ const config: CapacitorConfig = { "splashImmersive": true }, CapacitorHttp: { - enabled: true, + enabled: false, } } }; diff --git a/src/background-cases.ts b/src/background-cases.ts index b50f774..0d88a53 100644 --- a/src/background-cases.ts +++ b/src/background-cases.ts @@ -1329,6 +1329,7 @@ export async function publishOnQDNCase(request, event) { try { const { data, + name = "", identifier, service, title, @@ -1346,6 +1347,7 @@ export async function publishOnQDNCase(request, event) { identifier, service, title, + name, description, category, tag1, diff --git a/src/backgroundFunctions/encryption.ts b/src/backgroundFunctions/encryption.ts index 94a3b8e..9628394 100644 --- a/src/backgroundFunctions/encryption.ts +++ b/src/backgroundFunctions/encryption.ts @@ -59,6 +59,16 @@ export async function getNameInfo() { return ""; } } + + export async function getAllUserNames() { + const wallet = await getSaveWallet(); + const address = wallet.address0; + const validApi = await getBaseApi(); + const response = await fetch(validApi + '/names/address/' + address); + const nameData = await response.json(); + return nameData.map((item) => item.name); + } + async function getKeyPair() { const res = await getData("keyPair").catch(() => null); if (res) { @@ -151,7 +161,7 @@ async function getKeyPair() { if(encryptedData){ const registeredName = await getNameInfo() const data = await publishData({ - registeredName, file: encryptedData, service: 'DOCUMENT_PRIVATE', identifier: `admins-symmetric-qchat-group-${groupId}`, uploadType: 'file', isBase64: true, withFee: true + registeredName, data: encryptedData, service: 'DOCUMENT_PRIVATE', identifier: `admins-symmetric-qchat-group-${groupId}`, uploadType: 'base64', withFee: true }) return { data, @@ -202,7 +212,7 @@ export const encryptAndPublishSymmetricKeyGroupChat = async ({groupId, previousD if(encryptedData){ const registeredName = await getNameInfo() const data = await publishData({ - registeredName, file: encryptedData, service: 'DOCUMENT_PRIVATE', identifier: `symmetric-qchat-group-${groupId}`, uploadType: 'file', isBase64: true, withFee: true + registeredName, data: encryptedData, service: 'DOCUMENT_PRIVATE', identifier: `symmetric-qchat-group-${groupId}`, uploadType: 'base64', withFee: true }) return { data, @@ -223,7 +233,7 @@ export const publishGroupEncryptedResource = async ({encryptedData, identifier}) const registeredName = await getNameInfo() if(!registeredName) throw new Error('You need a name to publish') const data = await publishData({ - registeredName, file: encryptedData, service: 'DOCUMENT', identifier, uploadType: 'file', isBase64: true, withFee: true + registeredName, data: encryptedData, service: 'DOCUMENT', identifier, uploadType: 'base64', withFee: true }) return data @@ -242,15 +252,16 @@ export const publishOnQDN = async ({data, identifier, service, title, tag3, tag4, tag5, + name, uploadType = 'file' }) => { if(data && service){ - const registeredName = await getNameInfo() + const registeredName = name || await getNameInfo() if(!registeredName) throw new Error('You need a name to publish') const res = await publishData({ - registeredName, file: data, service, identifier, uploadType, isBase64: true, withFee: true, title, + registeredName, data, service, identifier, uploadType, withFee: true, title, description, category, tag1, diff --git a/src/components/Apps/AppPublish.tsx b/src/components/Apps/AppPublish.tsx index 76bde28..545bea5 100644 --- a/src/components/Apps/AppPublish.tsx +++ b/src/components/Apps/AppPublish.tsx @@ -1,4 +1,4 @@ -import React, { useContext, useEffect, useMemo, useState } from "react"; +import React, { useCallback, useContext, useEffect, useMemo, useState } from "react"; import { AppCircle, AppCircleContainer, @@ -49,6 +49,7 @@ import { LoadingSnackbar } from "../Snackbar/LoadingSnackbar"; import { CustomizedSnackbars } from "../Snackbar/Snackbar"; import { getFee } from "../../background"; import { fileToBase64 } from "../../utils/fileReading"; +import { useSortedMyNames } from "../../hooks/useSortedMyNames"; const CustomSelect = styled(Select)({ border: "0.5px solid var(--50-white, #FFFFFF80)", @@ -82,7 +83,8 @@ const CustomMenuItem = styled(MenuItem)({ }, }); -export const AppPublish = ({ names, categories }) => { +export const AppPublish = ({ categories, myAddress, myName }) => { + const [names, setNames] = useState([]); const [name, setName] = useState(""); const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); @@ -99,6 +101,8 @@ export const AppPublish = ({ names, categories }) => { const [openSnack, setOpenSnack] = useState(false); const [infoSnack, setInfoSnack] = useState(null); const [isLoading, setIsLoading] = useState(""); + const mySortedNames = useSortedMyNames(names, myName); + const maxFileSize = appType === "APP" ? 50 * 1024 * 1024 : 400 * 1024 * 1024; // 50MB or 400MB const { getRootProps, getInputProps } = useDropzone({ accept: { @@ -162,6 +166,25 @@ export const AppPublish = ({ names, categories }) => { getQapp(name, appType); }, [name, appType]); + const getNames = useCallback(async () => { + if (!myAddress) return; + try { + setIsLoading('Loading names'); + const res = await fetch( + `${getBaseApiReact()}/names/address/${myAddress}?limit=0` + ); + const data = await res.json(); + setNames(data?.map((item) => item.name)); + } catch (error) { + console.error(error); + } finally { + setIsLoading(''); + } + }, [myAddress]); + useEffect(() => { + getNames(); + }, [getNames]); + const publishApp = async () => { try { const data = { @@ -199,10 +222,10 @@ export const AppPublish = ({ names, categories }) => { publishFee: fee.fee + " QORT", }); setIsLoading("Publishing... Please wait."); - const fileBase64 = await fileToBase64(file); + await new Promise((res, rej) => { window.sendMessage("publishOnQDN", { - data: fileBase64, + data: file, service: appType, title, description, @@ -213,6 +236,7 @@ export const AppPublish = ({ names, categories }) => { tag4, tag5, uploadType: "zip", + name }) .then((response) => { if (!response?.error) { @@ -287,7 +311,7 @@ export const AppPublish = ({ names, categories }) => { {" "} {/* This is the placeholder item */} - {names.map((name) => { + {mySortedNames.map((name) => { return {name}; })} diff --git a/src/components/Apps/Apps.tsx b/src/components/Apps/Apps.tsx index 054c871..699d49b 100644 --- a/src/components/Apps/Apps.tsx +++ b/src/components/Apps/Apps.tsx @@ -17,7 +17,7 @@ import { AppsLibrary } from "./AppsLibrary"; const uid = new ShortUniqueId({ length: 8 }); -export const Apps = ({ mode, setMode, show , myName}) => { +export const Apps = ({ mode, setMode, show , myName, myAddress}) => { const [availableQapps, setAvailableQapps] = useState([]); const [selectedAppInfo, setSelectedAppInfo] = useState(null); const [selectedCategory, setSelectedCategory] = useState(null) @@ -298,7 +298,7 @@ export const Apps = ({ mode, setMode, show , myName}) => { > {mode !== "viewer" && !selectedTab && } {mode === "home" && ( - + )} { {mode === "appInfo" && !selectedTab && } {mode === "appInfo-from-category" && !selectedTab && } - {mode === "publish" && !selectedTab && } + {mode === "publish" && !selectedTab && } {tabs.map((tab) => { if (!iframeRefs.current[tab.tabId]) { @@ -335,7 +335,7 @@ export const Apps = ({ mode, setMode, show , myName}) => { {isNewTabWindow && mode === "viewer" && ( <> - + )} {mode !== "viewer" && !selectedTab && } diff --git a/src/components/Apps/AppsHome.tsx b/src/components/Apps/AppsHome.tsx index f758c9e..e1eb3e4 100644 --- a/src/components/Apps/AppsHome.tsx +++ b/src/components/Apps/AppsHome.tsx @@ -20,7 +20,7 @@ import HelpIcon from '@mui/icons-material/Help'; import { useHandleTutorials } from "../Tutorials/useHandleTutorials"; import { AppsPrivate } from "./AppsPrivate"; -export const AppsHome = ({ setMode, myApp, myWebsite, availableQapps, myName }) => { +export const AppsHome = ({ setMode, myApp, myWebsite, availableQapps, myName, myAddress }) => { const [qortalUrl, setQortalUrl] = useState('') const { showTutorial } = useContext(GlobalContext); @@ -146,7 +146,7 @@ export const AppsHome = ({ setMode, myApp, myWebsite, availableQapps, myName } Library - + diff --git a/src/components/Apps/AppsPrivate.tsx b/src/components/Apps/AppsPrivate.tsx index d18fcad..f2dc302 100644 --- a/src/components/Apps/AppsPrivate.tsx +++ b/src/components/Apps/AppsPrivate.tsx @@ -1,4 +1,4 @@ -import React, { useContext, useMemo, useState } from "react"; +import React, { useCallback, useContext, useEffect, useMemo, useState } from "react"; import { Avatar, Box, @@ -30,15 +30,18 @@ import { PublishQAppInfo, } from "./Apps-styles"; import ImageUploader from "../../common/ImageUploader"; -import { isMobile, MyContext } from "../../App"; +import { getBaseApiReact, isMobile, MyContext } from "../../App"; import { fileToBase64 } from "../../utils/fileReading"; import { objectToBase64 } from "../../qdn/encryption/group-encryption"; import { getFee } from "../../background"; +import { useSortedMyNames } from "../../hooks/useSortedMyNames"; const maxFileSize = 50 * 1024 * 1024; // 50MB -export const AppsPrivate = ({myName}) => { +export const AppsPrivate = ({myName, myAddress}) => { const { openApp } = useHandlePrivateApps(); + const [names, setNames] = useState([]); + const [name, setName] = useState(0); const [file, setFile] = useState(null); const [logo, setLogo] = useState(null); const [qortalUrl, setQortalUrl] = useState(""); @@ -48,6 +51,7 @@ export const AppsPrivate = ({myName}) => { const [myGroupsWhereIAmAdminFromGlobal] = useRecoilState( myGroupsWhereIAmAdminAtom ); + const mySortedNames = useSortedMyNames(names, myName); const myGroupsWhereIAmAdmin = useMemo(()=> { return myGroupsWhereIAmAdminFromGlobal?.filter((group)=> groupsProperties[group?.groupId]?.isOpen === false) @@ -165,6 +169,8 @@ export const AppsPrivate = ({myName}) => { data: decryptedData, identifier: newPrivateAppValues?.identifier, service: newPrivateAppValues?.service, + uploadType: 'base64', + name, }) .then((response) => { if (!response?.error) { @@ -181,7 +187,7 @@ export const AppsPrivate = ({myName}) => { { identifier: newPrivateAppValues?.identifier, service: newPrivateAppValues?.service, - name: myName, + name, groupId: selectedGroup, }, true @@ -196,6 +202,22 @@ export const AppsPrivate = ({myName}) => { } }; + const getNames = useCallback(async () => { + if (!myAddress) return; + try { + const res = await fetch( + `${getBaseApiReact()}/names/address/${myAddress}?limit=0` + ); + const data = await res.json(); + setNames(data?.map((item) => item.name)); + } catch (error) { + console.error(error); + } + }, [myAddress]); + useEffect(() => { + getNames(); + }, [getNames]); + const handleChange = (event: React.SyntheticEvent, newValue: number) => { setValueTabPrivateApp(newValue); }; @@ -432,6 +454,34 @@ export const AppsPrivate = ({myName}) => { {file ? "Change" : "Choose"} File + + + + + + + { + let fileName = location.filename + let locationUrl = `/arbitrary/${location.service}/${location.name}`; + if (location.identifier) { + locationUrl = locationUrl + `/${location.identifier}`; + } + const endpoint = await createEndpoint( + locationUrl + + `?attachment=true&attachmentFilename=${location?.filename}` + ); + const response = await fetch(endpoint); + + if (!response.ok || !response.body) { + throw new Error('Failed to fetch file or no readable stream'); + } + + const contentType = response.headers.get('Content-Type') || 'application/octet-stream'; + const base64Prefix = `data:${contentType};base64,`; + + const getExtensionFromFileName = (name: string): string => { + const lastDotIndex = name.lastIndexOf('.'); + return lastDotIndex !== -1 ? name.substring(lastDotIndex) : ''; + }; + + const existingExtension = getExtensionFromFileName(fileName); + + if (existingExtension) { + fileName = fileName.substring(0, fileName.lastIndexOf('.')); + } + + const mimeTypeToExtension = (mimeType: string): string => { + return mimeToExtensionMap[mimeType] || existingExtension || ''; + }; + + const extension = mimeTypeToExtension(contentType); + const fullFileName = `${fileName}_${Date.now()}${extension}`; + const reader = response.body.getReader(); + let isFirstChunk = true; + let done = false; + + let buffer = new Uint8Array(0); + const preferredChunkSize = 1024 * 1024; // 1MB + + while (!done) { + const result = await reader.read(); + done = result.done; + + if (result.value) { + // Combine new value with existing buffer + const newBuffer = new Uint8Array(buffer.length + result.value.length); + newBuffer.set(buffer); + newBuffer.set(result.value, buffer.length); + buffer = newBuffer; + + // While we have enough data, process 1MB chunks + while (buffer.length >= preferredChunkSize) { + const chunk = buffer.slice(0, preferredChunkSize); + buffer = buffer.slice(preferredChunkSize); + + const base64Chunk = uint8ArrayToBase64(chunk); + await Filesystem.writeFile({ + path: fullFileName, + data: isFirstChunk ? base64Prefix + base64Chunk : base64Chunk, + directory: Directory.Documents, + recursive: true, + append: !isFirstChunk, + }); + + isFirstChunk = false; + } + } + } + + // Write remaining buffer (if any) + if (buffer.length > 0) { + const base64Chunk = uint8ArrayToBase64(buffer); + await Filesystem.writeFile({ + path: fullFileName, + data: isFirstChunk ? base64Prefix + base64Chunk : base64Chunk, + directory: Directory.Documents, + recursive: true, + append: !isFirstChunk, + }); + +} +}; export const saveFileInChunks = async ( blob: Blob, @@ -586,38 +676,26 @@ isDOMContentLoaded: false } else if(event?.data?.action === 'SAVE_FILE' ){ try { - const res = await saveFile( event.data, null, true, { - openSnackGlobal, - setOpenSnackGlobal, - infoSnackCustom, - setInfoSnackCustom + await saveFile(event.data, null, true, { + openSnackGlobal, + setOpenSnackGlobal, + infoSnackCustom, + setInfoSnackCustom, + }); + event.ports[0].postMessage({ + result: true, + error: null, }); - } catch (error) { - + event.ports[0].postMessage({ + result: null, + error: error?.message || 'Failed to save file', + }); } } else if ( - event?.data?.action === 'PUBLISH_MULTIPLE_QDN_RESOURCES' || - event?.data?.action === 'PUBLISH_QDN_RESOURCE' || event?.data?.action === 'ENCRYPT_DATA' || event?.data?.action === 'ENCRYPT_DATA_WITH_SHARING_KEY' || event?.data?.action === 'ENCRYPT_QORTAL_GROUP_DATA' ) { - if ( - event?.data?.action === 'PUBLISH_MULTIPLE_QDN_RESOURCES' || - event?.data?.action === 'PUBLISH_QDN_RESOURCE' - - ){ - try { - checkMobileSizeConstraints(event.data) - } catch (error) { - event.ports[0].postMessage({ - result: null, - error: error?.message, - }); - return; - } - } - let data; try { data = await storeFilesInIndexedDB(event.data); @@ -640,6 +718,29 @@ isDOMContentLoaded: false error: 'Failed to prepare data for publishing', }); } + } else if ( + event?.data?.action === 'PUBLISH_MULTIPLE_QDN_RESOURCES' || + event?.data?.action === 'PUBLISH_QDN_RESOURCE' + ) { + + const data = event.data; + + if (data) { + sendMessageToRuntime( + { + action: event.data.action, + type: 'qortalRequest', + payload: data, + isExtension: true, + }, + event.ports[0] + ); + } else { + event.ports[0].postMessage({ + result: null, + error: 'Failed to prepare data for publishing', + }); + } } else if(event?.data?.action === 'LINK_TO_QDN_RESOURCE' || event?.data?.action === 'QDN_RESOURCE_DISPLAYED'){ const pathUrl = event?.data?.path != null ? (event?.data?.path.startsWith('/') ? '' : '/') + event?.data?.path : null diff --git a/src/components/Group/Group.tsx b/src/components/Group/Group.tsx index 9ba26c7..c60df16 100644 --- a/src/components/Group/Group.tsx +++ b/src/components/Group/Group.tsx @@ -574,7 +574,7 @@ export const Group = ({ }); } catch (error) { - console.log("error", error); + console.error(error); } }; @@ -2756,7 +2756,7 @@ export const Group = ({ /> )} {isMobile && ( - + )} {!isMobile && ( { data: data, identifier: identifier, service: "DOCUMENT", + uploadType: 'base64', }) .then((response) => { if (!response?.error) { diff --git a/src/components/MainAvatar.tsx b/src/components/MainAvatar.tsx index b5f8e44..9ed451b 100644 --- a/src/components/MainAvatar.tsx +++ b/src/components/MainAvatar.tsx @@ -67,6 +67,7 @@ const [isLoading, setIsLoading] = useState(false) data: avatarBase64, identifier: "qortal_avatar", service: "THUMBNAIL", + uploadType: 'base64', }) .then((response) => { if (!response?.error) { diff --git a/src/components/Save/Save.tsx b/src/components/Save/Save.tsx index b27f9a2..993f1f7 100644 --- a/src/components/Save/Save.tsx +++ b/src/components/Save/Save.tsx @@ -155,6 +155,7 @@ export const Save = ({ isDesktop, disableWidth, myName }) => { data: encryptData, identifier: "ext_saved_settings", service: "DOCUMENT_PRIVATE", + uploadType: 'base64', }) .then((response) => { if (!response?.error) { diff --git a/src/hooks/useSortedMyNames.tsx b/src/hooks/useSortedMyNames.tsx new file mode 100644 index 0000000..6fb8917 --- /dev/null +++ b/src/hooks/useSortedMyNames.tsx @@ -0,0 +1,11 @@ +import { useMemo } from 'react'; + +export function useSortedMyNames(names, myName) { + return useMemo(() => { + return [...names].sort((a, b) => { + if (a === myName) return -1; + if (b === myName) return 1; + return 0; + }); + }, [names, myName]); +} diff --git a/src/qdn/publish/pubish.ts b/src/qdn/publish/pubish.ts index 79bd6f2..41f781b 100644 --- a/src/qdn/publish/pubish.ts +++ b/src/qdn/publish/pubish.ts @@ -1,265 +1,365 @@ // @ts-nocheck -import { Buffer } from "buffer" -import Base58 from "../../deps/Base58" -import nacl from "../../deps/nacl-fast" -import utils from "../../utils/utils" -import { createEndpoint, getBaseApi } from "../../background"; -import { getData } from "../../utils/chromeStorage"; +import { Buffer } from 'buffer'; +import Base58 from '../../deps/Base58'; +import nacl from '../../deps/nacl-fast'; +import utils from '../../utils/utils'; +import { createEndpoint, getBaseApi } from '../../background'; +import { getData } from '../../utils/chromeStorage'; -export async function reusableGet(endpoint){ - const validApi = await getBaseApi(); - - const response = await fetch(validApi + endpoint); - const data = await response.json(); - return data - } - - async function reusablePost(endpoint, _body){ - // const validApi = await findUsableApi(); - const url = await createEndpoint(endpoint) - const response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: _body +export async function reusableGet(endpoint) { + const validApi = await getBaseApi(); + + const response = await fetch(validApi + endpoint); + const data = await response.json(); + return data; +} + +async function reusablePost(endpoint, _body) { + // const validApi = await findUsableApi(); + const url = await createEndpoint(endpoint); + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: _body, }); - let data + let data; try { - data = await response.clone().json() + data = await response.clone().json(); } catch (e) { - data = await response.text() + data = await response.text(); } - return data + return data; +} + +async function reusablePostStream(endpoint, _body) { + const url = await createEndpoint(endpoint); + + const headers = {}; + + const response = await fetch(url, { + method: 'POST', + headers, + body: _body, + }); + + return response; // return the actual response so calling code can use response.ok +} + +async function uploadChunkWithRetry(endpoint, formData, index, maxRetries = 3) { + let attempt = 0; + while (attempt < maxRetries) { + try { + const response = await reusablePostStream(endpoint, formData); + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText); + } + return; // Success + } catch (err) { + attempt++; + console.warn( + `Chunk ${index} failed (attempt ${attempt}): ${err.message}` + ); + if (attempt >= maxRetries) { + throw new Error(`Chunk ${index} failed after ${maxRetries} attempts`); + } + // Wait 10 seconds before next retry + await new Promise((res) => setTimeout(res, 10_000)); + } } +} async function getKeyPair() { - const res = await getData("keyPair").catch(() => null); - if (res) { - return res - } else { - throw new Error("Wallet not authenticated"); - } + const res = await getData('keyPair').catch(() => null); + if (res) { + return res; + } else { + throw new Error('Wallet not authenticated'); } +} export const publishData = async ({ - registeredName, - file, - service, - identifier, - uploadType, - isBase64, - filename, - withFee, - title, - description, - category, - tag1, - tag2, - tag3, - tag4, - tag5, - feeAmount + registeredName, + data, + service, + identifier, + uploadType, + filename, + withFee, + title, + description, + category, + tag1, + tag2, + tag3, + tag4, + tag5, + feeAmount, }: any) => { - - const validateName = async (receiverName: string) => { - return await reusableGet(`/names/${receiverName}`) - } + const validateName = async (receiverName: string) => { + return await reusableGet(`/names/${receiverName}`); + }; - const convertBytesForSigning = async (transactionBytesBase58: string) => { - return await reusablePost('/transactions/convert', transactionBytesBase58) - } + const convertBytesForSigning = async (transactionBytesBase58: string) => { + return await reusablePost('/transactions/convert', transactionBytesBase58); + }; - const getArbitraryFee = async () => { - const timestamp = Date.now() + const getArbitraryFee = async () => { + const timestamp = Date.now(); - let fee = await reusableGet(`/transactions/unitfee?txType=ARBITRARY×tamp=${timestamp}`) + let fee = await reusableGet( + `/transactions/unitfee?txType=ARBITRARY×tamp=${timestamp}` + ); - return { - timestamp, - fee: Number(fee), - feeToShow: (Number(fee) / 1e8).toFixed(8) - } - } + return { + timestamp, + fee: Number(fee), + feeToShow: (Number(fee) / 1e8).toFixed(8), + }; + }; - const signArbitraryWithFee = (arbitraryBytesBase58, arbitraryBytesForSigningBase58, keyPair) => { - if (!arbitraryBytesBase58) { - throw new Error('ArbitraryBytesBase58 not defined') - } - - if (!keyPair) { - throw new Error('keyPair not defined') - } - - const arbitraryBytes = Base58.decode(arbitraryBytesBase58) - const _arbitraryBytesBuffer = Object.keys(arbitraryBytes).map(function (key) { return arbitraryBytes[key]; }) - const arbitraryBytesBuffer = new Uint8Array(_arbitraryBytesBuffer) - const arbitraryBytesForSigning = Base58.decode(arbitraryBytesForSigningBase58) - const _arbitraryBytesForSigningBuffer = Object.keys(arbitraryBytesForSigning).map(function (key) { return arbitraryBytesForSigning[key]; }) - const arbitraryBytesForSigningBuffer = new Uint8Array(_arbitraryBytesForSigningBuffer) - const signature = nacl.sign.detached(arbitraryBytesForSigningBuffer, keyPair.privateKey) - - return utils.appendBuffer(arbitraryBytesBuffer, signature) + const signArbitraryWithFee = ( + arbitraryBytesBase58, + arbitraryBytesForSigningBase58, + keyPair + ) => { + if (!arbitraryBytesBase58) { + throw new Error('ArbitraryBytesBase58 not defined'); } - const processTransactionVersion2 = async (bytes) => { + if (!keyPair) { + throw new Error('keyPair not defined'); + } - return await reusablePost('/transactions/process?apiVersion=2', Base58.encode(bytes)) - } + const arbitraryBytes = Base58.decode(arbitraryBytesBase58); + const _arbitraryBytesBuffer = Object.keys(arbitraryBytes).map( + function (key) { + return arbitraryBytes[key]; + } + ); + const arbitraryBytesBuffer = new Uint8Array(_arbitraryBytesBuffer); + const arbitraryBytesForSigning = Base58.decode( + arbitraryBytesForSigningBase58 + ); + const _arbitraryBytesForSigningBuffer = Object.keys( + arbitraryBytesForSigning + ).map(function (key) { + return arbitraryBytesForSigning[key]; + }); + const arbitraryBytesForSigningBuffer = new Uint8Array( + _arbitraryBytesForSigningBuffer + ); + const signature = nacl.sign.detached( + arbitraryBytesForSigningBuffer, + keyPair.privateKey + ); - const signAndProcessWithFee = async (transactionBytesBase58: string) => { - let convertedBytesBase58 = await convertBytesForSigning( - transactionBytesBase58 - ) + return utils.appendBuffer(arbitraryBytesBuffer, signature); + }; - if (convertedBytesBase58.error) { - throw new Error('Error when signing') - } + const processTransactionVersion2 = async (bytes) => { + return await reusablePost( + '/transactions/process?apiVersion=2', + Base58.encode(bytes) + ); + }; + const signAndProcessWithFee = async (transactionBytesBase58: string) => { + let convertedBytesBase58 = await convertBytesForSigning( + transactionBytesBase58 + ); - const resKeyPair = await getKeyPair() - const parsedData = resKeyPair - const uint8PrivateKey = Base58.decode(parsedData.privateKey); - const uint8PublicKey = Base58.decode(parsedData.publicKey); - const keyPair = { - privateKey: uint8PrivateKey, - publicKey: uint8PublicKey - }; + if (convertedBytesBase58.error) { + throw new Error('Error when signing'); + } - let signedArbitraryBytes = signArbitraryWithFee(transactionBytesBase58, convertedBytesBase58, keyPair) - const response = await processTransactionVersion2(signedArbitraryBytes) + const resKeyPair = await getKeyPair(); + const parsedData = resKeyPair; + const uint8PrivateKey = Base58.decode(parsedData.privateKey); + const uint8PublicKey = Base58.decode(parsedData.publicKey); + const keyPair = { + privateKey: uint8PrivateKey, + publicKey: uint8PublicKey, + }; - let myResponse = { error: '' } + let signedArbitraryBytes = signArbitraryWithFee( + transactionBytesBase58, + convertedBytesBase58, + keyPair + ); + const response = await processTransactionVersion2(signedArbitraryBytes); - if (response === false) { - throw new Error('Error when signing') - } else { - myResponse = response - } + let myResponse = { error: '' }; - return myResponse - } + if (response === false) { + throw new Error('Error when signing'); + } else { + myResponse = response; + } - const validate = async () => { - let validNameRes = await validateName(registeredName) + return myResponse; + }; - if (validNameRes.error) { - throw new Error('Name not found') - } + const validate = async () => { + let validNameRes = await validateName(registeredName); - let fee = null + if (validNameRes.error) { + throw new Error('Name not found'); + } - if (withFee && feeAmount) { - fee = feeAmount - } else if (withFee) { - const res = await getArbitraryFee() - if (res.fee) { - fee = res.fee - } else { - throw new Error('unable to get fee') - } - } - - let transactionBytes = await uploadData(registeredName, file, fee) - if (!transactionBytes || transactionBytes.error) { - throw new Error(transactionBytes?.message || 'Error when uploading') - } else if (transactionBytes.includes('Error 500 Internal Server Error')) { - throw new Error('Error when uploading') - } + let fee = null; - let signAndProcessRes + if (withFee && feeAmount) { + fee = feeAmount; + } else if (withFee) { + const res = await getArbitraryFee(); + if (res.fee) { + fee = res.fee; + } else { + throw new Error('unable to get fee'); + } + } - if (withFee) { - signAndProcessRes = await signAndProcessWithFee(transactionBytes) - } + let transactionBytes = await uploadData(registeredName, data, fee); + if (!transactionBytes || transactionBytes.error) { + throw new Error(transactionBytes?.message || 'Error when uploading'); + } else if (transactionBytes.includes('Error 500 Internal Server Error')) { + throw new Error('Error when uploading'); + } - if (signAndProcessRes?.error) { - throw new Error('Error when signing') - } + let signAndProcessRes; - return signAndProcessRes - } + if (withFee) { + signAndProcessRes = await signAndProcessWithFee(transactionBytes); + } - const uploadData = async (registeredName: string, file:any, fee: number) => { + if (signAndProcessRes?.error) { + throw new Error('Error when signing'); + } - let postBody = '' - let urlSuffix = '' + return signAndProcessRes; + }; - if (file != null) { - // If we're sending zipped data, make sure to use the /zip version of the POST /arbitrary/* API - if (uploadType === 'zip') { - urlSuffix = '/zip' - } + const uploadData = async (registeredName: string, data: any, fee: number) => { + let postBody = ''; + let urlSuffix = ''; - // If we're sending file data, use the /base64 version of the POST /arbitrary/* API - else if (uploadType === 'file') { - urlSuffix = '/base64' - } + if (data != null) { + if (uploadType === 'base64') { + urlSuffix = '/base64'; + } - // Base64 encode the file to work around compatibility issues between javascript and java byte arrays - if (isBase64) { - postBody = file - } + if (uploadType === 'base64') { + postBody = data; + } + } else { + throw new Error('No data provided'); + } - if (!isBase64) { - let fileBuffer = new Uint8Array(await file.arrayBuffer()) - postBody = Buffer.from(fileBuffer).toString("base64") - } + let uploadDataUrl = `/arbitrary/${service}/${registeredName}`; + let paramQueries = ''; + if (identifier?.trim().length > 0) { + uploadDataUrl = `/arbitrary/${service}/${registeredName}/${identifier}`; + } - } - - let uploadDataUrl = `/arbitrary/${service}/${registeredName}${urlSuffix}` - if (identifier?.trim().length > 0) { - uploadDataUrl = `/arbitrary/${service}/${registeredName}/${identifier}${urlSuffix}` - } - - uploadDataUrl = uploadDataUrl + `?fee=${fee}` - + paramQueries = paramQueries + `?fee=${fee}`; - if (filename != null && filename != 'undefined') { - uploadDataUrl = uploadDataUrl + '&filename=' + encodeURIComponent(filename) - } + if (filename != null && filename != 'undefined') { + paramQueries = paramQueries + '&filename=' + encodeURIComponent(filename); + } - if (title != null && title != 'undefined') { - uploadDataUrl = uploadDataUrl + '&title=' + encodeURIComponent(title) - } + if (title != null && title != 'undefined') { + paramQueries = paramQueries + '&title=' + encodeURIComponent(title); + } - if (description != null && description != 'undefined') { - uploadDataUrl = uploadDataUrl + '&description=' + encodeURIComponent(description) - } + if (description != null && description != 'undefined') { + paramQueries = + paramQueries + '&description=' + encodeURIComponent(description); + } - if (category != null && category != 'undefined') { - uploadDataUrl = uploadDataUrl + '&category=' + encodeURIComponent(category) - } + if (category != null && category != 'undefined') { + paramQueries = paramQueries + '&category=' + encodeURIComponent(category); + } - if (tag1 != null && tag1 != 'undefined') { - uploadDataUrl = uploadDataUrl + '&tags=' + encodeURIComponent(tag1) - } + if (tag1 != null && tag1 != 'undefined') { + paramQueries = paramQueries + '&tags=' + encodeURIComponent(tag1); + } - if (tag2 != null && tag2 != 'undefined') { - uploadDataUrl = uploadDataUrl + '&tags=' + encodeURIComponent(tag2) - } + if (tag2 != null && tag2 != 'undefined') { + paramQueries = paramQueries + '&tags=' + encodeURIComponent(tag2); + } - if (tag3 != null && tag3 != 'undefined') { - uploadDataUrl = uploadDataUrl + '&tags=' + encodeURIComponent(tag3) - } + if (tag3 != null && tag3 != 'undefined') { + paramQueries = paramQueries + '&tags=' + encodeURIComponent(tag3); + } - if (tag4 != null && tag4 != 'undefined') { - uploadDataUrl = uploadDataUrl + '&tags=' + encodeURIComponent(tag4) - } + if (tag4 != null && tag4 != 'undefined') { + paramQueries = paramQueries + '&tags=' + encodeURIComponent(tag4); + } - if (tag5 != null && tag5 != 'undefined') { - uploadDataUrl = uploadDataUrl + '&tags=' + encodeURIComponent(tag5) - } + if (tag5 != null && tag5 != 'undefined') { + paramQueries = paramQueries + '&tags=' + encodeURIComponent(tag5); + } + if (uploadType === 'zip') { + paramQueries = paramQueries + '&isZip=' + true; + } - return await reusablePost(uploadDataUrl, postBody) - - } + if (uploadType === 'base64') { + if (urlSuffix) { + uploadDataUrl = uploadDataUrl + urlSuffix; + } + uploadDataUrl = uploadDataUrl + paramQueries; + return await reusablePost(uploadDataUrl, postBody); + } - try { - return await validate() - } catch (error: any) { - throw new Error(error?.message) - } -} \ No newline at end of file + const file = data; + const urlCheck = `/arbitrary/check-tmp-space?totalSize=${file.size}`; + + const checkEndpoint = await createEndpoint(urlCheck); + const checkRes = await fetch(checkEndpoint); + if (!checkRes.ok) { + throw new Error('Not enough space on your hard drive'); + } + + const chunkUrl = uploadDataUrl + `/chunk`; + const chunkSize = 1 * 1024 * 1024; // 1MB + + const totalChunks = Math.ceil(file.size / chunkSize); + + for (let index = 0; index < totalChunks; index++) { + const start = index * chunkSize; + const end = Math.min(start + chunkSize, file.size); + const chunk = file.slice(start, end); + const formData = new FormData(); + formData.append('chunk', chunk, file.name); // Optional: include filename + formData.append('index', index); + + await uploadChunkWithRetry(chunkUrl, formData, index); + } + const finalizeUrl = uploadDataUrl + `/finalize` + paramQueries; + + const finalizeEndpoint = await createEndpoint(finalizeUrl); + + const response = await fetch(finalizeEndpoint, { + method: 'POST', + headers: {}, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Finalize failed: ${errorText}`); + } + + const result = await response.text(); // Base58-encoded unsigned transaction + return result; + }; + + try { + return await validate(); + } catch (error: any) { + throw new Error(error?.message); + } +}; \ No newline at end of file diff --git a/src/qortalRequests/get.ts b/src/qortalRequests/get.ts index ab45220..fac8bc5 100644 --- a/src/qortalRequests/get.ts +++ b/src/qortalRequests/get.ts @@ -37,10 +37,11 @@ import { getNameOrAddress, getAssetInfo, transferAsset, - getPublicKey + getPublicKey, + isNative } from "../background"; -import { getNameInfo, uint8ArrayToObject } from "../backgroundFunctions/encryption"; -import { showSaveFilePicker } from "../components/Apps/useQortalMessageListener"; +import { getNameInfo, uint8ArrayToObject,getAllUserNames } from "../backgroundFunctions/encryption"; +import { saveFileInChunksFromUrl, showSaveFilePicker } from "../components/Apps/useQortalMessageListener"; import { QORT_DECIMALS } from "../constants/constants"; import Base58 from "../deps/Base58"; import { @@ -74,6 +75,7 @@ import ed2curve from "../deps/ed2curve"; import { Sha256 } from "asmcrypto.js"; import { isValidBase64WithDecode } from "../utils/decode"; import ShortUniqueId from "short-unique-id"; +import { fileToBase64 } from "../utils/fileReading"; const uid = new ShortUniqueId({ length: 6 }); @@ -914,7 +916,7 @@ export const publishQDNResource = async ( sender, isFromExtension ) => { - const requiredFields = ["service"]; + const requiredFields = ['service']; const missingFields: string[] = []; requiredFields.forEach((field) => { if (!data[field]) { @@ -922,25 +924,26 @@ export const publishQDNResource = async ( } }); if (missingFields.length > 0) { - const missingFieldsString = missingFields.join(", "); + const missingFieldsString = missingFields.join(', '); const errorMsg = `Missing fields: ${missingFieldsString}`; throw new Error(errorMsg); } - if (!data.fileId && !data.data64 && !data.base64) { - throw new Error("No data or file was submitted"); + if (!data.file && !data.data64 && !data.base64) { + throw new Error('No data or file was submitted'); } - // Use "default" if user hasn't specified an identifer + // Use "default" if user hasn't specified an identifier const service = data.service; - const appFee = data?.appFee ? +data.appFee : undefined - const appFeeRecipient = data?.appFeeRecipient - let hasAppFee = false - if(appFee && appFee > 0 && appFeeRecipient){ - hasAppFee = true + const appFee = data?.appFee ? +data.appFee : undefined; + const appFeeRecipient = data?.appFeeRecipient; + let hasAppFee = false; + if (appFee && appFee > 0 && appFeeRecipient) { + hasAppFee = true; } - const registeredName = await getNameInfo(); + + const registeredName = data?.name || (await getNameInfo()); const name = registeredName; - if(!name){ - throw new Error('User has no Qortal name') + if (!name) { + throw new Error('User has no Qortal name'); } let identifier = data.identifier; let data64 = data.data64 || data.base64; @@ -948,39 +951,39 @@ export const publishQDNResource = async ( const title = data.title; const description = data.description; const category = data.category; - + const file = data?.file || data?.blob; const tags = data?.tags || []; const result = {}; - + // Fill tags dynamically while maintaining backward compatibility for (let i = 0; i < 5; i++) { result[`tag${i + 1}`] = tags[i] || data[`tag${i + 1}`] || undefined; } - + // Access tag1 to tag5 from result const { tag1, tag2, tag3, tag4, tag5 } = result; if (data.identifier == null) { - identifier = "default"; - } - if (data.fileId) { - data64 = await getFileFromContentScript(data.fileId); + identifier = 'default'; } + if ( data.encrypt && (!data.publicKeys || (Array.isArray(data.publicKeys) && data.publicKeys.length === 0)) ) { - throw new Error("Encrypting data requires public keys"); + throw new Error('Encrypting data requires public keys'); } - if (data.encrypt) { try { const resKeyPair = await getKeyPair(); const parsedData = resKeyPair; const privateKey = parsedData.privateKey; const userPublicKey = parsedData.publicKey; + if (data?.file || data?.blob) { + data64 = await fileToBase64(data?.file || data?.blob); + } const encryptDataResponse = encryptDataGroup({ data64, publicKeys: data.publicKeys, @@ -992,49 +995,46 @@ export const publishQDNResource = async ( } } catch (error) { throw new Error( - error.message || "Upload failed due to failed encryption" + error.message || 'Upload failed due to failed encryption' ); } } - const fee = await getFee("ARBITRARY"); + const fee = await getFee('ARBITRARY'); - const handleDynamicValues = {} - if(hasAppFee){ - const feePayment = await getFee("PAYMENT"); + const handleDynamicValues = {}; + if (hasAppFee) { + const feePayment = await getFee('PAYMENT'); - handleDynamicValues['appFee'] = +appFee + +feePayment.fee, - handleDynamicValues['checkbox1'] = { - value: true, - label: "accept app fee", - } + (handleDynamicValues['appFee'] = +appFee + +feePayment.fee), + (handleDynamicValues['checkbox1'] = { + value: true, + label: 'accept app fee', + }); } - if(data?.encrypt){ - handleDynamicValues['highlightedText'] = `isEncrypted: ${!!data.encrypt}` + if (!!data?.encrypt) { + handleDynamicValues['highlightedText'] = `isEncrypted: ${!!data.encrypt}`; } - const resPermission = await getUserPermission( { - text1: "Do you give this application permission to publish to QDN?", + text1: 'Do you give this application permission to publish to QDN?', text2: `service: ${service}`, text3: `identifier: ${identifier || null}`, + text4: `name: ${registeredName}`, fee: fee.fee, - ...handleDynamicValues + ...handleDynamicValues, }, isFromExtension ); const { accepted, checkbox1 = false } = resPermission; - if (accepted) { - try { const resPublish = await publishData({ registeredName: encodeURIComponent(name), - file: data64, + data: data64 ? data64 : file, service: service, identifier: encodeURIComponent(identifier), - uploadType: "file", - isBase64: true, + uploadType: data64 ? 'base64' : 'file', filename: filename, title, description, @@ -1047,18 +1047,21 @@ export const publishQDNResource = async ( apiVersion: 2, withFee: true, }); - if(resPublish?.signature && hasAppFee && checkbox1){ - sendCoinFunc({ - amount: appFee, - receiver: appFeeRecipient - }, true) - } + if (resPublish?.signature && hasAppFee && checkbox1) { + sendCoinFunc( + { + amount: appFee, + receiver: appFeeRecipient, + }, + true + ); + } return resPublish; } catch (error) { - throw new Error(error?.message || "Upload failed"); + throw new Error(error?.message || 'Upload failed'); } } else { - throw new Error("User declined request"); + throw new Error('User declined request'); } }; @@ -1096,8 +1099,12 @@ export const checkArrrSyncStatus = async (seed) => { throw new Error("Failed to synchronize after 36 attempts"); }; -export const publishMultipleQDNResources = async (data: any, sender, isFromExtension) => { - const requiredFields = ["resources"]; +export const publishMultipleQDNResources = async ( + data: any, + sender, + isFromExtension +) => { + const requiredFields = ['resources']; const missingFields: string[] = []; let feeAmount = null; requiredFields.forEach((field) => { @@ -1106,64 +1113,74 @@ export const publishMultipleQDNResources = async (data: any, sender, isFromExten } }); if (missingFields.length > 0) { - const missingFieldsString = missingFields.join(", "); + const missingFieldsString = missingFields.join(', '); const errorMsg = `Missing fields: ${missingFieldsString}`; throw new Error(errorMsg); } const resources = data.resources; if (!Array.isArray(resources)) { - throw new Error("Invalid data"); + throw new Error('Invalid data'); } if (resources.length === 0) { - throw new Error("No resources to publish"); + throw new Error('No resources to publish'); } - const encrypt = data?.encrypt + + const encrypt = data?.encrypt; for (const resource of resources) { - const resourceEncrypt = encrypt && resource?.disableEncrypt !== true - if (!resourceEncrypt && resource?.service.endsWith("_PRIVATE")) { - const errorMsg = "Only encrypted data can go into private services"; - throw new Error(errorMsg) - } else if(resourceEncrypt && !resource?.service.endsWith("_PRIVATE")){ - const errorMsg = "For an encrypted publish please use a service that ends with _PRIVATE"; - throw new Error(errorMsg) + const resourceEncrypt = encrypt && resource?.disableEncrypt !== true; + if (!resourceEncrypt && resource?.service.endsWith('_PRIVATE')) { + const errorMsg = 'Only encrypted data can go into private services'; + throw new Error(errorMsg); + } else if (resourceEncrypt && !resource?.service.endsWith('_PRIVATE')) { + const errorMsg = + 'For an encrypted publish please use a service that ends with _PRIVATE'; + throw new Error(errorMsg); } } - const fee = await getFee("ARBITRARY"); + + const fee = await getFee('ARBITRARY'); const registeredName = await getNameInfo(); + const name = registeredName; - if(!name){ - throw new Error('You need a Qortal name to publish.') + if (!name) { + throw new Error('You need a Qortal name to publish.'); } - const appFee = data?.appFee ? +data.appFee : undefined - const appFeeRecipient = data?.appFeeRecipient - let hasAppFee = false - if(appFee && appFee > 0 && appFeeRecipient){ - hasAppFee = true + const userNames = await getAllUserNames(); + data.resources?.forEach((item) => { + if (item?.name && !userNames?.includes(item.name)) + throw new Error( + `The name ${item.name}, does not belong to the publisher.` + ); + }); + + const appFee = data?.appFee ? +data.appFee : undefined; + const appFeeRecipient = data?.appFeeRecipient; + let hasAppFee = false; + if (appFee && appFee > 0 && appFeeRecipient) { + hasAppFee = true; } - const handleDynamicValues = {} - if(hasAppFee){ - const feePayment = await getFee("PAYMENT"); + const handleDynamicValues = {}; + if (hasAppFee) { + const feePayment = await getFee('PAYMENT'); - handleDynamicValues['appFee'] = +appFee + +feePayment.fee, - handleDynamicValues['checkbox1'] = { - value: true, - label: "accept app fee", - } + (handleDynamicValues['appFee'] = +appFee + +feePayment.fee), + (handleDynamicValues['checkbox1'] = { + value: true, + label: 'accept app fee', + }); } - if(data?.encrypt){ - handleDynamicValues['highlightedText'] = `isEncrypted: ${!!data.encrypt}` + if (data?.encrypt) { + handleDynamicValues['highlightedText'] = `isEncrypted: ${!!data.encrypt}`; } - const resPermission = await getUserPermission({ - text1: "Do you give this application permission to publish to QDN?", - html: ` + const resPermission = await getUserPermission( + { + text1: 'Do you give this application permission to publish to QDN?', + html: `