diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 872adc65f..a6ac0b0a7 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -9,7 +9,7 @@ jobs: strategy: matrix: - node-version: [18.20.4] + node-version: [20] fail-fast: true steps: diff --git a/__mocks__/react-native-localization.ts b/__mocks__/react-native-localization.ts deleted file mode 100644 index f1b3f767e..000000000 --- a/__mocks__/react-native-localization.ts +++ /dev/null @@ -1,22 +0,0 @@ -export default class mockRNLocalization { - _language = 'en'; - private props: Record = {}; - constructor(props) { - this.props = props; - this._setLanguage(this._language); - } - - _setLanguage(interfaceLanguage) { - this._language = interfaceLanguage; - if (this.props[interfaceLanguage]) { - const localizedStrings: Record = this.props[this._language]; - for (const key in localizedStrings) { - if (localizedStrings[key]) { - this[key] = localizedStrings[key]; - } - } - } - } -} - -jest.mock('react-native-localization', () => mockRNLocalization); diff --git a/app.config.ts b/app.config.ts index 8dbda1f93..2ab055f46 100644 --- a/app.config.ts +++ b/app.config.ts @@ -113,6 +113,7 @@ const appConfig: ExpoConfig & { extra: AppEnv & { NODE_ENV: AppStage; RELEASE_ID 'expo-localization', 'expo-secure-store', 'expo-sqlite', + ['expo-screen-orientation', { initialOrientation: 'PORTRAIT' }], [ 'expo-splash-screen', { diff --git a/assets/lang/strings.ts b/assets/lang/strings.ts index fa8f9401a..3ca3cad5c 100644 --- a/assets/lang/strings.ts +++ b/assets/lang/strings.ts @@ -554,7 +554,7 @@ const translations = { }, Language: { title: 'Select language', - info: 'Restart the application to see the language change.', + info: 'Language changed successfully.', }, ChangeProfilePicture: { title: 'Edit photo', @@ -738,10 +738,14 @@ const translations = { notEnoughSpaceOnDevice: 'Not enough storage space available for download', fileAlreadyDownloading: 'File is already downloading, stopping download', genericError: 'An unexpected error occurred. Please try again.', + rateLimitReached: "This action couldn't be completed right now. Please wait a moment and try again.", + rateLimitUpload: "Your upload couldn't be completed right now. Please wait a moment and try again.", + rateLimitDownload: "Your download couldn't be completed right now. Please wait a moment and try again.", + rateLimitContent: "Content couldn't be loaded right now. Please wait a moment and try again.", }, security: { alerts: { - dontDisplayAgain: 'Don’t show this again', + dontDisplayAgain: "Don't show this again", securityWarning: { title: '⚠️ Security Notice', message: 'Security concerns detected:\n\n{0}\n\nFor better security, consider addressing these issues.', @@ -1334,7 +1338,7 @@ const translations = { }, Language: { title: 'Selecciona idioma', - info: 'Reinicie la aplicación para ver el cambio de idioma.', + info: 'Idioma cambiado correctamente.', }, ChangeProfilePicture: { title: 'Editar foto', @@ -1522,6 +1526,10 @@ const translations = { notEnoughSpaceOnDevice: 'No hay suficiente espacio de almacenamiento disponible para la descarga', fileAlreadyDownloading: 'El archivo ya se está descargando, deteniendo la descarga', genericError: 'Se ha producido un error inesperado. Por favor, inténtelo de nuevo.', + rateLimitReached: 'No se ha podido completar esta acción en este momento. Por favor, espera un momento e inténtalo de nuevo.', + rateLimitUpload: 'No se ha podido completar la subida en este momento. Por favor, espera un momento e inténtalo de nuevo.', + rateLimitDownload: 'No se ha podido completar la descarga en este momento. Por favor, espera un momento e inténtalo de nuevo.', + rateLimitContent: 'No se ha podido cargar el contenido en este momento. Por favor, espera un momento e inténtalo de nuevo.', }, security: { alerts: { diff --git a/ios/Podfile.lock b/ios/Podfile.lock index ab0f74cc8..37a485435 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -102,6 +102,35 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga + - ExpoScreenOrientation (9.0.8): + - boost + - DoubleConversion + - ExpoModulesCore + - fast_float + - fmt + - glog + - hermes-engine + - RCT-Folly + - RCT-Folly/Fabric + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - SocketRocket + - Yoga - ExpoSecureStore (15.0.8): - ExpoModulesCore - ExpoSharing (14.0.8): @@ -3221,6 +3250,7 @@ DEPENDENCIES: - ExpoLocalization (from `../node_modules/expo-localization/ios`) - ExpoMediaLibrary (from `../node_modules/expo-media-library/ios`) - ExpoModulesCore (from `../node_modules/expo-modules-core`) + - ExpoScreenOrientation (from `../node_modules/expo-screen-orientation/ios`) - ExpoSecureStore (from `../node_modules/expo-secure-store/ios`) - ExpoSharing (from `../node_modules/expo-sharing/ios`) - ExpoSplashScreen (from `../node_modules/expo-splash-screen/ios`) @@ -3383,6 +3413,8 @@ EXTERNAL SOURCES: :path: "../node_modules/expo-media-library/ios" ExpoModulesCore: :path: "../node_modules/expo-modules-core" + ExpoScreenOrientation: + :path: "../node_modules/expo-screen-orientation/ios" ExpoSecureStore: :path: "../node_modules/expo-secure-store/ios" ExpoSharing: @@ -3615,6 +3647,7 @@ SPEC CHECKSUMS: ExpoLocalization: d9168d5300a5b03e5e78b986124d11fb6ec3ebbd ExpoMediaLibrary: a3093b04365b245bb23b25b6eba84eb6ab55fecb ExpoModulesCore: d86dab8a6c8f1184f9b0b0503ace93919882aa15 + ExpoScreenOrientation: b895491eb180dd92836f00198ac215f2fae2d45b ExpoSecureStore: d32f751874a2ceb5aaeebeb3578e165c1ba2b24a ExpoSharing: 0d983394ed4a80334bab5a0d5384f75710feb7e8 ExpoSplashScreen: bc3cffefca2716e5f22350ca109badd7e50ec14d diff --git a/package.json b/package.json index 8b522b2bc..db4657c8f 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "@internxt/lib": "^1.4.1", "@internxt/mobile-sdk": "https://github.com/internxt/mobile-sdk/releases/download/v0.3.1/internxt-mobile-sdk-v0.3.1_.tgz", "@internxt/rn-crypto": "0.1.15", - "@internxt/sdk": "1.11.25", + "@internxt/sdk": "1.12.3", "@react-native-async-storage/async-storage": "2.2.0", "@react-navigation/bottom-tabs": "^6.2.0", "@react-navigation/native": "^6.1.18", @@ -69,6 +69,7 @@ "expo-localization": "~17.0.8", "expo-media-library": "~18.2.1", "expo-navigation-bar": "~5.0.10", + "expo-screen-orientation": "^9.0.8", "expo-secure-store": "~15.0.8", "expo-sharing": "~14.0.8", "expo-splash-screen": "~31.0.13", diff --git a/src/@inxt-js/api/FileObject.ts b/src/@inxt-js/api/FileObject.ts index 9d912bfe8..dc41eee80 100644 --- a/src/@inxt-js/api/FileObject.ts +++ b/src/@inxt-js/api/FileObject.ts @@ -15,7 +15,6 @@ import { Logger } from '../lib/download'; import { wrap } from '../lib/utils/error'; import { EventEmitter } from '../lib/utils/eventEmitter'; import { logger } from '../lib/utils/logger'; -import { Bridge, InxtApiI } from '../services/api'; import FileManager from './FileManager'; import { DEFAULT_INXT_MIRRORS, DOWNLOAD_CANCELLED } from './constants'; @@ -34,7 +33,6 @@ export class FileObject extends EventEmitter { fileKey: Buffer; private aborted = false; - private api: InxtApiI; private file: FileManager; constructor(config: EnvironmentConfig, bucketId: string, fileId: string, debug: Logger, file: FileManager) { @@ -43,8 +41,6 @@ export class FileObject extends EventEmitter { this.bucketId = bucketId; this.fileId = fileId; this.fileKey = Buffer.alloc(0); - - this.api = new Bridge(config); this.file = file; this.once(DOWNLOAD_CANCELLED, this.abort.bind(this)); diff --git a/src/@inxt-js/api/FileObjectUpload.ts b/src/@inxt-js/api/FileObjectUpload.ts deleted file mode 100644 index 5bc111b65..000000000 --- a/src/@inxt-js/api/FileObjectUpload.ts +++ /dev/null @@ -1,315 +0,0 @@ -import { randomBytes, createCipheriv } from 'react-native-crypto'; - -import { EnvironmentConfig, UploadProgressCallback } from '..'; - -import EncryptStream from '../lib/encryptStream'; -import { GenerateFileKey, sha512HmacBuffer } from '../lib/crypto'; -import { getShardMeta, ShardMeta } from '../lib/shardMeta'; -import { ContractNegotiated } from '../lib/contracts'; -import { logger } from '../lib/utils/logger'; - -import { ExchangeReport } from './reports'; -import { determineShardSize } from '../lib/utils'; -import { - Bridge, - CreateEntryFromFrameBody, - CreateEntryFromFrameResponse, - FrameStaging, - InxtApiI, -} from '../services/api'; -import { EventEmitter } from '../lib/utils/eventEmitter'; -import { INXTRequest } from '../lib'; - -import { ShardObject } from './ShardObject'; -import { wrap } from '../lib/utils/error'; -import FileManager from './FileManager'; -import errorService from '../../services/ErrorService'; - -export interface FileMeta { - size: number; - name: string; - fileUri: string; -} - -export class FileObjectUpload extends EventEmitter { - private config: EnvironmentConfig; - private fileMeta: FileMeta; - private requests: INXTRequest[] = []; - private api: InxtApiI; - private id = ''; - private aborted = false; - - shardMetas: ShardMeta[] = []; - bucketId: string; - frameId: string; - index: Buffer; - encrypted = false; - - cipher: EncryptStream; - // funnel: FunnelStream; - fileEncryptionKey: Buffer; - - constructor(config: EnvironmentConfig, fileMeta: FileMeta, bucketId: string, api?: InxtApiI) { - super(); - - this.config = config; - this.index = Buffer.alloc(0); - this.fileMeta = fileMeta; - this.bucketId = bucketId; - this.frameId = ''; - // this.funnel = new FunnelStream(determineShardSize(fileMeta.size)); - this.cipher = new EncryptStream(randomBytes(32), randomBytes(16)); - this.fileEncryptionKey = randomBytes(32); - this.api = api ?? new Bridge(this.config); - } - - getSize(): number { - return this.fileMeta.size; - } - - getId(): string { - return this.id; - } - - getEncryptionKey(): Buffer { - return this.fileEncryptionKey; - } - - getIndex(): Buffer { - return this.index.slice(0, 16); - } - - checkIfIsAborted(): void { - if (this.isAborted()) { - throw new Error('Upload aborted'); - } - } - - async init(): Promise { - this.index = randomBytes(32); - this.fileEncryptionKey = await GenerateFileKey(this.config.encryptionKey || '', this.bucketId, this.index); - - this.cipher = new EncryptStream(this.fileEncryptionKey, this.index.slice(0, 16)); - - return this; - } - - checkBucketExistence(): Promise { - this.checkIfIsAborted(); - - const req = this.api.getBucketById(this.bucketId); - - this.requests.push(req); - - return req - .start() - .then(() => { - logger.info(`Bucket ${this.bucketId} exists`); - - return true; - }) - .catch((err) => { - throw wrap('Bucket existence check error', err); - }); - } - - stage(): Promise { - this.checkIfIsAborted(); - - const req = this.api.createFrame(); - - this.requests.push(req); - - return req - .start() - .then((frame) => { - if (!frame || !frame.id) { - throw new Error('Frame response is empty'); - } - - this.frameId = frame.id; - - logger.info(`Stage a file with frame ${this.frameId}`); - }) - .catch((err) => { - throw wrap('Bridge frame creation error', err); - }); - } - - SaveFileInNetwork(bucketEntry: CreateEntryFromFrameBody): Promise { - this.checkIfIsAborted(); - - const req = this.api.createEntryFromFrame(this.bucketId, bucketEntry); - - this.requests.push(req); - - return req.start().catch((err) => { - throw wrap('Saving file in network error', err); - }); - } - - NegotiateContract(frameId: string, shardMeta: ShardMeta): Promise { - this.checkIfIsAborted(); - - const req = this.api.addShardToFrame(frameId, shardMeta); - - this.requests.push(req); - - return req.start().catch((err) => { - throw wrap('Contract negotiation error', err); - }); - } - - GenerateHmac(shardMetas: ShardMeta[]): string { - const shardMetasCopy = [...shardMetas].sort((sA, sB) => sA.index - sB.index); - const hmac = sha512HmacBuffer(this.fileEncryptionKey); - - for (const shardMeta of shardMetasCopy) { - hmac.update(Buffer.from(shardMeta.hash, 'hex')); - } - - return hmac.digest().toString('hex'); - } - - encrypt(): EncryptStream { - this.encrypted = true; - - return this.cipher; - } - - private async parallelUpload(callback: UploadProgressCallback): Promise { - const nShards = Math.ceil(this.fileMeta.size / determineShardSize(this.fileMeta.size)); - const cipher = createCipheriv('aes-256-ctr', this.fileEncryptionKey, this.index.slice(0, 16)); - - let progress = 0; - let shardBuffer: Buffer; - let encryptedChunk: Buffer; - let shardMeta: ShardMeta; - - const fileManager = new FileManager(this.fileMeta.fileUri); - const fileIterator = fileManager.iterator(determineShardSize(this.fileMeta.size)); - - for (let i = 0; i < nShards; i++) { - const chunk = await fileIterator.next(); - shardBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); - - cipher.write(shardBuffer); - - encryptedChunk = cipher.read(); - - shardMeta = await this.uploadShard(encryptedChunk, encryptedChunk.length, this.frameId, i, 3, false); - - progress += encryptedChunk.length / this.fileMeta.size; - callback(progress, encryptedChunk.length, this.fileMeta.size); - this.shardMetas.push(shardMeta); - } - - return this.shardMetas; - } - - upload(callback: UploadProgressCallback): Promise { - this.checkIfIsAborted(); - - if (!this.encrypted) { - throw new Error('Tried to upload a file not encrypted. Call encrypt() before upload()'); - } - - return this.parallelUpload(callback); - } - - async uploadShard( - encryptedShard: Buffer, - shardSize: number, - frameId: string, - index: number, - attemps: number, - parity: boolean, - ): Promise { - const shardMeta: ShardMeta = getShardMeta(encryptedShard, shardSize, index, parity); - - logger.info(`Uploading shard ${shardMeta.hash} index ${shardMeta.index} size ${shardMeta.index} parity ${parity}`); - - const shardObject = new ShardObject(this.api, frameId, shardMeta); - - shardObject.once(ShardObject.Events.NodeTransferFinished, ({ success, nodeID, hash }) => { - const exchangeReport = new ExchangeReport(this.config); - - exchangeReport.params.dataHash = hash; - exchangeReport.params.farmerId = nodeID; - exchangeReport.params.exchangeEnd = new Date(); - - if (success) { - logger.debug(`Node ${nodeID} accepted shard ${hash}`); - exchangeReport.UploadOk(); - } else { - exchangeReport.UploadError(); - } - - exchangeReport.sendReport().catch(() => { - // no op - }); - }); - - let retries = attemps; - - do { - try { - await shardObject.upload(encryptedShard); - - logger.info(`Shard ${shardMeta.hash} uploaded succesfully`); - - retries = 0; - } catch (err) { - const castedError = errorService.castError(err); - logger.error(`Upload for shard ${shardMeta.hash} failed. Reason: ${castedError.message}. Retrying ...`); - - retries--; - } - } while (retries > 0); - - return shardMeta; - } - - createBucketEntry(shardMetas: ShardMeta[]): Promise { - return this.SaveFileInNetwork(generateBucketEntry(this, this.fileMeta, shardMetas, false)) - .then((bucketEntry) => { - if (!bucketEntry) { - throw new Error('Can not save the file in the network'); - } - this.id = bucketEntry.id; - }) - .catch((err) => { - throw wrap('Bucket entry creation error', err); - }); - } - - abort(): void { - logger.warn('Aborting file upload'); - - this.aborted = true; - } - - isAborted(): boolean { - return this.aborted; - } -} - -export function generateBucketEntry( - fileObject: FileObjectUpload, - fileMeta: FileMeta, - shardMetas: ShardMeta[], - rs: boolean, -): CreateEntryFromFrameBody { - const bucketEntry: CreateEntryFromFrameBody = { - frame: fileObject.frameId, - filename: fileMeta.name, - index: fileObject.index.toString('hex'), - hmac: { type: 'sha512', value: fileObject.GenerateHmac(shardMetas) }, - }; - - if (rs) { - bucketEntry.erasure = { type: 'reedsolomon' }; - } - - return bucketEntry; -} diff --git a/src/@inxt-js/api/ShardObject.ts b/src/@inxt-js/api/ShardObject.ts index a746bae93..e5c864720 100644 --- a/src/@inxt-js/api/ShardObject.ts +++ b/src/@inxt-js/api/ShardObject.ts @@ -1,136 +1,9 @@ -import { AxiosError } from 'axios'; -import { EventEmitter } from '../lib/utils/eventEmitter'; - -import { INXTRequest } from '../lib'; -import { ContractNegotiated } from '../lib/contracts'; -import { ShardMeta } from '../lib/shardMeta'; -import { wrap } from '../lib/utils/error'; -import { logger } from '../lib/utils/logger'; -import { InxtApiI } from '../services/api'; import { Shard } from './shard'; -import { get, getBuffer } from '../services/request'; - -type GetUrl = string; - -export class ShardObject extends EventEmitter { - private meta: ShardMeta; - private api: InxtApiI; - private frameId: string; - private requests: INXTRequest[] = []; - private shard?: Shard; - - static Events = { - NodeTransferFinished: 'node-transfer-finished', - }; - - constructor(api: InxtApiI, frameId: string | null, meta: ShardMeta | null, shard?: Shard) { - super(); - - // TODO: Clarify if meta and shard variables are both required. - this.frameId = frameId ?? ''; - this.meta = meta ?? { - hash: '', - index: 0, - parity: false, - challenges_as_str: [], - size: 0, - tree: [], - challenges: [], - exclude: [], - }; - this.api = api; - this.shard = shard; - } - - getSize(): number { - return this.meta.size; - } - - getHash(): string { - return this.meta.hash; - } - - getIndex(): number { - return this.meta.index; - } - - async upload(content: Buffer): Promise { - if (!this.frameId) { - throw new Error('Frame id not provided'); - } - - const contract = await this.negotiateContract(); - - logger.debug( - `Negotiated succesfully contract for shard - ${this.getHash()} (index ${this.getIndex()}, size ${this.getSize()}) with token ${contract.token}`, - ); - - const farmer = { ...contract.farmer, lastSeen: new Date() }; - const shard: Omit = { - index: this.getIndex(), - replaceCount: 0, - hash: this.getHash(), - size: this.getSize(), - parity: this.meta.parity, - token: contract.token, - farmer, - operation: contract.operation, - }; - - await this.put(shard, content); - - return this.meta; - } - - private negotiateContract(): Promise { - const req = this.api.addShardToFrame(this.frameId, this.meta); - - this.requests.push(req); - - return req.start().catch((err) => { - throw wrap('Contract negotiation error', err); - }); - } - - private put(shard: Omit, content: Buffer): Promise { - let success = true; - - return this.api - .requestPut(shard) - .start<{ result: string }>() - .then((res) => { - const putUrl = res.result; - - logger.debug(`Put url for shard ${shard.index} is ${putUrl}`); - - return this.api.putShard(putUrl, content).start(); - }) - .catch((err: AxiosError) => { - logger.error(`Error uploading shard ${shard.index}: ${err.message}`); - - if (err.response && err.response.status < 400) { - return { result: err.response.data && err.response.data.error }; - } - - success = false; - - throw wrap('Farmer request error', err); - }) - .finally(() => { - const hash = shard.hash; - const nodeID = shard.farmer.nodeID; - - this.emit(ShardObject.Events.NodeTransferFinished, [{ hash, nodeID, success }]); - }); - } - - static requestGet(url: string, useProxy = true): Promise { - return get<{ result: string }>({ url }, { useProxy }).then((res) => res.result); - } +import { getBuffer } from '../services/request'; +export class ShardObject { static download(shard: Shard, cb: (err: Error | null, content: Buffer | null) => void): void { - getBuffer(shard.url, { useProxy: false }) + getBuffer(shard.url) .then((content) => { cb(null, content); }) @@ -138,22 +11,4 @@ export class ShardObject extends EventEmitter { cb(err, null); }); } - - abort(): void { - this.requests.forEach((r) => { - r.abort(); - }); - } - - download(): Promise { - if (!this.shard) { - throw new Error('Provide shard info before trying to download a shard'); - } - - const req = this.api.getShardFromNode(this.shard); - - this.requests.push(req); - - return req.buffer(); - } } diff --git a/src/@inxt-js/api/fileinfo.ts b/src/@inxt-js/api/fileinfo.ts index 7d0113bab..c0c21fc99 100644 --- a/src/@inxt-js/api/fileinfo.ts +++ b/src/@inxt-js/api/fileinfo.ts @@ -23,7 +23,7 @@ export interface FileInfo { } export function GetFileInfo(config: EnvironmentConfig, bucketId: string, fileId: string): Promise { - return request(config, 'get', `${config.bridgeUrl}/buckets/${bucketId}/files/${fileId}/info`, {}, false) + return request(config, 'get', `${config.bridgeUrl}/buckets/${bucketId}/files/${fileId}/info`, {}) .then((res: AxiosResponse) => res.data) .catch((err: AxiosError) => { switch (err.response?.status) { @@ -46,7 +46,7 @@ export function GetFileMirror( const excludeNodeIds: string = excludeNodes.join(','); const targetUrl = `${config.bridgeUrl}/buckets/${bucketId}/files/${fileId}?limit=${limit}&skip=${skip}&exclude=${excludeNodeIds}`; - return request(config, 'GET', targetUrl, { responseType: 'json' }, false).then((res: AxiosResponse) => res.data); + return request(config, 'GET', targetUrl, { responseType: 'json' }).then((res: AxiosResponse) => res.data); } export function ReplacePointer( diff --git a/src/@inxt-js/api/reports.ts b/src/@inxt-js/api/reports.ts index 5314d3d7c..aaa1bba74 100644 --- a/src/@inxt-js/api/reports.ts +++ b/src/@inxt-js/api/reports.ts @@ -87,7 +87,7 @@ export class ExchangeReport { return Promise.reject(Error('Not valid report to send')); } - return request(this.config, 'POST', `${this.config.bridgeUrl}/reports/exchanges`, { data: this.params }, false); + return request(this.config, 'POST', `${this.config.bridgeUrl}/reports/exchanges`, { data: this.params }); } DownloadOk() { diff --git a/src/@inxt-js/api/shard.ts b/src/@inxt-js/api/shard.ts index da573ab95..ee6b535ac 100644 --- a/src/@inxt-js/api/shard.ts +++ b/src/@inxt-js/api/shard.ts @@ -1,5 +1,3 @@ -import { EnvironmentConfig } from '..'; - export interface Shard { index: number; replaceCount: number; @@ -17,26 +15,5 @@ export interface Shard { lastSeen: Date; }; operation: string; - url: string -} - -export function DownloadShardRequest( - config: EnvironmentConfig, - address: string, - port: number, - hash: string, - token: string, - nodeID: string, -): void { - const fetchUrl = `http://${address}:${port}/shards/${hash}?token=${token}`; -} - -export async function DownloadShard( - config: EnvironmentConfig, - shard: Shard, - bucketId: string, - fileId: string, - excludedNodes: string[] = [], -): Promise { - return null; + url: string; } diff --git a/src/@inxt-js/index.ts b/src/@inxt-js/index.ts index 1071c74cc..f5702be1a 100644 --- a/src/@inxt-js/index.ts +++ b/src/@inxt-js/index.ts @@ -1,37 +1,31 @@ -import { download } from './lib/download'; +/** + * @deprecated LEGACY DOWNLOAD SYSTEM + * + * This module is maintained for backwards compatibility with old files only. + * Modern downloads use @internxt/sdk via NetworkFacade. + * + * Only used when: + * 1. File has multiple mirrors (old redundancy system) + * 2. After modern download and V1 download both fail + * + */ + import { GenerateFileKey } from './lib/crypto'; +import { download } from './lib/download'; import { logger } from './lib/utils/logger'; -import { BUCKET_ID_NOT_PROVIDED, ENCRYPTION_KEY_NOT_PROVIDED } from './api/constants'; import { ActionState, ActionTypes } from './api/actionState'; +import { BUCKET_ID_NOT_PROVIDED, ENCRYPTION_KEY_NOT_PROVIDED } from './api/constants'; import { FileInfo, GetFileInfo } from './api/fileinfo'; -import { Bridge, CreateFileTokenResponse } from './services/api'; import FileManager from './api/FileManager'; -export type OnlyErrorCallback = (err: Error | null) => void; -export type UploadFinishCallback = (err: Error | null, response: string | null) => void; export type DownloadFinishedCallback = (err: Error | null) => void; export type DownloadProgressCallback = ( progress: number, downloadedBytes: number | null, totalBytes: number | null, ) => void; -export type DecryptionProgressCallback = ( - progress: number, - decryptedBytes: number | null, - totalBytes: number | null, -) => void; -export type UploadProgressCallback = ( - progress: number, - uploadedBytes: number | null, - totalBytes: number | null, -) => void; - -export interface ResolveFileOptions { - progressCallback: DownloadProgressCallback; - finishedCallback: OnlyErrorCallback; - overwritte?: boolean; -} +type DecryptionProgressCallback = (progress: number, decryptedBytes: number | null, totalBytes: number | null) => void; export interface DownloadFileOptions { fileManager: FileManager; @@ -90,22 +84,6 @@ export class Environment { getFileInfo(bucketId: string, fileId: string): Promise { return GetFileInfo(this.config, bucketId, fileId); } - - /** - * Creates file token - * @param bucketId Bucket id where file is stored - * @param fileId File id - * @param operation - * @param cb - */ - createFileToken(bucketId: string, fileId: string, operation: 'PUSH' | 'PULL'): Promise { - return new Bridge(this.config) - .createFileToken(bucketId, fileId, operation) - .start() - .then((res) => { - return res.token; - }); - } } export interface EnvironmentConfig { diff --git a/src/@inxt-js/lib/INXTRequest.ts b/src/@inxt-js/lib/INXTRequest.ts deleted file mode 100644 index a11ba0d93..000000000 --- a/src/@inxt-js/lib/INXTRequest.ts +++ /dev/null @@ -1,85 +0,0 @@ -import axios, { AxiosRequestConfig, Canceler } from 'axios'; - -import { request } from '../services/request'; -import { EnvironmentConfig } from '..'; - -enum Methods { - Get = 'GET', - Post = 'POST', - Put = 'PUT', -} - -export class INXTRequest { - private req: Promise | undefined; - private config: EnvironmentConfig; - private cancel: Canceler; - private useProxy: boolean; - - method: Methods; - targetUrl: string; - params: AxiosRequestConfig; - - static Events = { - UploadProgress: 'upload-progress', - DownloadProgress: 'download-progress', - }; - - constructor( - config: EnvironmentConfig, - method: Methods, - targetUrl: string, - params: AxiosRequestConfig, - useProxy?: boolean, - ) { - this.method = method; - this.config = config; - this.targetUrl = targetUrl; - this.useProxy = useProxy ?? false; - this.params = params; - - this.cancel = () => null; - } - - start(): Promise { - // TODO: Abstract from axios - const source = axios.CancelToken.source(); - - this.cancel = source.cancel; - - const cancelToken = source.token; - - this.req = request(this.config, this.method, this.targetUrl, { ...this.params, cancelToken }, this.useProxy).then< - JSON | K - >((res) => res.data); - - return this.req; - } - - buffer(): Promise { - const source = axios.CancelToken.source(); - - this.cancel = source.cancel; - - const cancelToken = source.token; - - this.req = request( - this.config, - this.method, - this.targetUrl, - { ...this.params, cancelToken }, - this.useProxy, - ).then((res) => { - return Buffer.from(res.request._response, 'base64'); - }); - - return this.req; - } - - abort(): void { - this.cancel(); - } - - isCancelled(err: Error): boolean { - return axios.isCancel(err); - } -} diff --git a/src/@inxt-js/lib/concurrentQueue.ts b/src/@inxt-js/lib/concurrentQueue.ts deleted file mode 100644 index 45a3fb206..000000000 --- a/src/@inxt-js/lib/concurrentQueue.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { QueueObject, queue, ErrorCallback } from 'async'; - -export class ConcurrentQueue { - private totalTasks: number; - protected concurrency: number; - private finishedTasks = 0; - protected queue: QueueObject; - - constructor(concurrency = 1, totalTasks = 1, task?: (content: K) => Promise) { - if (concurrency > totalTasks) { - throw new Error('ConcurrentQueue error: Concurrency can not be greater than total tasks to perform'); - } - - this.totalTasks = totalTasks; - this.concurrency = concurrency; - - if (task) { - this.queue = queue(async (content: K, cb: ErrorCallback) => { - task(content) - .then(() => { - this.finishedTasks++; - cb(); - }) - .catch(cb); - }, concurrency); - } else { - this.queue = queue(() => undefined, 1); - } - } - - setQueueTask(task: (content: K) => Promise): void { - this.queue = queue(async (content: K, cb: ErrorCallback) => { - task(content) - .then(() => { - this.finishedTasks++; - cb(); - }) - .catch(cb); - }, this.concurrency); - } - - push(content: K): Promise { - return this.queue.push(content); - } - - end(cb?: () => void): void | Promise { - if (cb) { - const intervalId = setInterval(() => { - if (this.totalTasks === this.finishedTasks) { - clearInterval(intervalId); - cb(); - } - }, 500); - } else { - return new Promise((r) => { - const intervalId = setInterval(() => { - if (this.totalTasks === this.finishedTasks) { - clearInterval(intervalId); - r(); - } - }, 500); - }); - } - } -} diff --git a/src/@inxt-js/lib/contracts.ts b/src/@inxt-js/lib/contracts.ts deleted file mode 100644 index 46a54e741..000000000 --- a/src/@inxt-js/lib/contracts.ts +++ /dev/null @@ -1,13 +0,0 @@ -export interface ContractNegotiated { - hash: string; - token: string; - operation: 'PUSH'; - farmer: { - userAgent: string; - protocol: string; - address: string; - port: number; - nodeID: string; - lastSeen: number; - }; -} diff --git a/src/@inxt-js/lib/crypto/constants.ts b/src/@inxt-js/lib/crypto/constants.ts deleted file mode 100644 index e73e8ac83..000000000 --- a/src/@inxt-js/lib/crypto/constants.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const GCM_DIGEST_SIZE = 16; -export const SHA256_DIGEST_SIZE = 32; -export const BUCKET_META_MAGIC = [ - 66, 150, 71, 16, 50, 114, 88, 160, 163, 35, 154, 65, 162, 213, 226, 215, 70, 138, 57, 61, 52, 19, 210, 170, 38, 164, - 162, 200, 86, 201, 2, 81, -]; -export const BUCKET_NAME_MAGIC = '398734aab3c4c30c9f22590e83a95f7e43556a45fc2b3060e0c39fde31f50272'; diff --git a/src/@inxt-js/lib/crypto/crypto.ts b/src/@inxt-js/lib/crypto/crypto.ts index 5e3150de3..d873f74fb 100644 --- a/src/@inxt-js/lib/crypto/crypto.ts +++ b/src/@inxt-js/lib/crypto/crypto.ts @@ -1,26 +1,30 @@ +/** + * Internxt Cryptographic Functions + * + * These functions are used by BOTH legacy and modern download systems. + * They implement Internxt-specific key derivation and hashing algorithms. + * + * Usage: + * - sha256: Used for file hashing + * - ripemd160: Used for file integrity verification (SHA256 + RIPEMD160) + * - GenerateFileKey: Derives encryption key from mnemonic + bucketId + index + * + * Used by: + * - NetworkFacade (modern) + * - NetworkService/download (v1) + * - @inxt-js/FileObject (legacy fallback) + */ + import * as crypto from 'react-native-crypto'; import { createHash, pbkdf2 } from '@internxt/rn-crypto'; import { HMAC } from '@internxt/rn-crypto/src/types/crypto'; -import { isValidFilename } from 'src/helpers'; import unorm from 'unorm'; -import { BUCKET_META_MAGIC, GCM_DIGEST_SIZE, SHA256_DIGEST_SIZE } from './constants'; + export function sha256(input: Buffer): Buffer { return crypto.createHash('sha256').update(input).digest(); } -export function sha256HashBuffer(): crypto.Hash { - return crypto.createHash('sha256'); -} - -export function sha512(input: Buffer): Buffer { - return crypto.createHash('sha512').update(input).digest(); -} - -export function sha512HmacBuffer(key: Buffer | string): crypto.Hmac { - return crypto.createHmac('sha512', key); -} - export function ripemd160(input: Buffer | string): Buffer { return crypto.createHash('ripemd160').update(input).digest(); } @@ -50,100 +54,3 @@ export async function GenerateFileKey(mnemonic: string, bucketId: string, index: const deterministicKey = await GetDeterministicKey(bucketKey.slice(0, 32), index); return deterministicKey.slice(0, 32); } - -export async function EncryptFilename(mnemonic: string, bucketId: string, filename: string): Promise { - if (!isValidFilename(filename)) { - throw new Error('This filename is not valid'); - } - - const bucketKey = await GenerateBucketKey(mnemonic, bucketId); - const GenerateEncryptionKey = () => { - const hasher = sha512HmacBuffer(bucketKey); - - hasher.update(Buffer.from(BUCKET_META_MAGIC)); - - return hasher.digest().slice(0, 32); - }; - - const GenerateEncryptionIv = () => { - const hasher = sha512HmacBuffer(bucketKey); - - hasher.update(bucketId).update(filename); - - return hasher.digest().slice(0, 32); - }; - - const encryptionKey = GenerateEncryptionKey(); - const encryptionIv = GenerateEncryptionIv(); - - return EncryptMeta(filename, encryptionKey, encryptionIv); -} - -export async function DecryptFileName( - mnemonic: string, - bucketId: string, - encryptedName: string, -): Promise { - const bucketKey = (await GenerateBucketKey(mnemonic, bucketId)).toString('hex'); - - if (!bucketKey) { - throw Error('Bucket key missing'); - } - - const key = crypto - .createHmac('sha512', Buffer.from(bucketKey, 'hex')) - .update(Buffer.from(BUCKET_META_MAGIC)) - .digest('hex'); - - const decryptedFilename = decryptMeta(encryptedName, key); - - return decryptedFilename; -} - -function decryptMeta(bufferBase64: string, decryptKey: string) { - const data = Buffer.from(bufferBase64, 'base64'); - - const digest = data.slice(0, GCM_DIGEST_SIZE); - const iv = data.slice(GCM_DIGEST_SIZE, GCM_DIGEST_SIZE + SHA256_DIGEST_SIZE); - const buffer = data.slice(GCM_DIGEST_SIZE + SHA256_DIGEST_SIZE); - - const decipher = crypto.createDecipheriv('aes-256-gcm', Buffer.from(decryptKey, 'hex').slice(0, 32), iv); - - decipher.setAuthTag(digest); - - try { - const dec = Buffer.concat([decipher.update(buffer), decipher.final()]); - - return dec.toString('utf8'); - } catch (e) { - return null; - } -} - -export function EncryptMeta(fileMeta: string, key: Buffer, iv: Buffer): string { - const cipher: crypto.CipherCCM = Aes256gcmEncrypter(key, iv); - const cipherTextBuf = Buffer.concat([cipher.update(fileMeta, 'utf-8'), cipher.final()]); - const digest = cipher.getAuthTag(); - - return Buffer.concat([digest, iv, cipherTextBuf]).toString('base64'); -} - -export function EncryptMetaBuffer(fileMeta: string, encryptKey: Buffer, iv: Buffer): Buffer { - const cipher: crypto.CipherGCM = Aes256gcmEncrypter(encryptKey, iv); - const cipherTextBuf = Buffer.concat([cipher.update(fileMeta, 'utf-8'), cipher.final()]); - const digest = cipher.getAuthTag(); - - return Buffer.concat([digest, iv, cipherTextBuf]); -} - -export function Aes256ctrDecrypter(key: Buffer, iv: Buffer): crypto.Decipher { - return crypto.createDecipheriv('aes-256-ctr', key, iv); -} - -export function Aes256ctrEncrypter(key: Buffer, iv: Buffer): crypto.Cipher { - return crypto.createCipheriv('aes-256-ctr', key, iv); -} - -export function Aes256gcmEncrypter(key: Buffer, iv: Buffer): crypto.CipherGCM { - return crypto.createCipheriv('aes-256-gcm', key, iv); -} diff --git a/src/@inxt-js/lib/crypto/index.ts b/src/@inxt-js/lib/crypto/index.ts index 0705c5268..f17024754 100644 --- a/src/@inxt-js/lib/crypto/index.ts +++ b/src/@inxt-js/lib/crypto/index.ts @@ -1,2 +1 @@ -export * from './constants'; export * from './crypto'; diff --git a/src/@inxt-js/lib/decryptstream.ts b/src/@inxt-js/lib/decryptstream.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/@inxt-js/lib/download/download.ts b/src/@inxt-js/lib/download/download.ts index 4b74f4623..41ee5a13d 100644 --- a/src/@inxt-js/lib/download/download.ts +++ b/src/@inxt-js/lib/download/download.ts @@ -1,6 +1,6 @@ import { DownloadProgressCallback, EnvironmentConfig } from '../..'; import { FileObject } from '../../api/FileObject'; -import { DOWNLOAD } from '../events'; +import { Download } from '../events'; import { ActionState } from '../../api/actionState'; import { DOWNLOAD_CANCELLED } from '../../api/constants'; import FileManager from '../../api/FileManager'; @@ -44,7 +44,7 @@ function handleProgress(fl: FileObject, progressCb: DownloadProgressCallback) { throw new Error('Total file size can not be 0'); } - fl.on(DOWNLOAD.PROGRESS, (addedBytes: number) => { + fl.on(Download.Progress, (addedBytes: number) => { totalBytesDownloaded += addedBytes; progress = totalBytesDownloaded / totalBytes; progressCb(progress, totalBytesDownloaded, totalBytes); diff --git a/src/@inxt-js/lib/encryptStream.ts b/src/@inxt-js/lib/encryptStream.ts deleted file mode 100644 index 8b73fd9b4..000000000 --- a/src/@inxt-js/lib/encryptStream.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { createCipheriv, Cipher } from 'react-native-crypto'; - -interface RawShard { - size: number; - index: number; -} - -type ErrorEvent = 'error'; -type ErrorListener = (err: Error) => void; -type onErrorListener = (event: ErrorEvent, listener: ErrorListener) => void; - -type DataEvent = 'data'; -type DataListener = (chunk: Buffer) => void; -type onDataListener = (event: DataEvent, listener: DataListener) => void; - -type EndEvent = 'end'; -type EndListener = (err?: Error) => void; -type onEndListener = (event: EndEvent, listener: EndListener) => void; - -type StreamEvent = ErrorEvent | DataEvent | EndEvent; -type StreamListener = ErrorListener & DataListener & EndListener; -type onListener = onDataListener & onEndListener & onErrorListener; - -export class EncryptStream { - private cipher: Cipher; - private indexCounter = 0; - private listeners: Map = new Map(); - - public shards: RawShard[] = []; - - constructor(key: Buffer, iv: Buffer) { - this.cipher = createCipheriv('aes-256-ctr', key, iv); - - this.listeners.set('end', []); - this.listeners.set('data', []); - this.listeners.set('error', []); - } - - on: onListener = (event: any, listener: any) => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - this.listeners.get(event)?.push(listener); - }; - - emit(event: StreamEvent, content?: any): void { - this.listeners.get(event)?.forEach((listener) => listener(content)); - } - - push(chunk: Buffer): void { - if (!chunk) { - return this.end(); - } - - this.cipher.write(chunk); - - this.shards.push({ size: chunk.byteLength, index: this.indexCounter }); - this.indexCounter++; - - this.emit('data', this.cipher.read()); - } - - end(): void { - const lastChunk = this.cipher.read(); - - if (lastChunk) { - this.emit('data', lastChunk); - } - this.emit('end'); - } -} - -export default EncryptStream; diff --git a/src/@inxt-js/lib/events.ts b/src/@inxt-js/lib/events.ts index 0ecb8a74a..6e86180b6 100644 --- a/src/@inxt-js/lib/events.ts +++ b/src/@inxt-js/lib/events.ts @@ -1,40 +1,3 @@ -export enum DOWNLOAD { - PROGRESS = 'download-progress', - ERROR = 'download-error', - END = 'download-end', -} - -export enum UPLOAD { - PROGRESS = 'upload-progress', - ERROR = 'upload-error', - END = 'upload-end', -} - -export enum DECRYPT { - PROGRESS = 'decrypt-progress', - ERROR = 'decrypt-error', - END = 'decrypt-end', -} - -export enum ENCRYPT { - PROGRESS = 'encrypt-progress', - ERROR = 'encrypt-error', - END = 'encrypt-end', -} - -export enum FILEMUXER { - PROGRESS = 'filemuxer-progress', - DATA = 'filemuxer-data', - ERROR = 'filemuxer-error', - END = 'filemuxer-end', -} - -export enum FILEOBJECT { - PROGRESS = 'fileobject-progress', - ERROR = 'fileobject-error', - END = 'fileobject-end', -} - export enum Download { Progress = 'download-progress', Error = 'download-error', diff --git a/src/@inxt-js/lib/filemuxer.ts b/src/@inxt-js/lib/filemuxer.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/@inxt-js/lib/funnelStream.ts b/src/@inxt-js/lib/funnelStream.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/@inxt-js/lib/hashstream.ts b/src/@inxt-js/lib/hashstream.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/@inxt-js/lib/index.ts b/src/@inxt-js/lib/index.ts deleted file mode 100644 index a63bb90be..000000000 --- a/src/@inxt-js/lib/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './INXTRequest'; diff --git a/src/@inxt-js/lib/merkleTree.ts b/src/@inxt-js/lib/merkleTree.ts deleted file mode 100644 index ef825a4f8..000000000 --- a/src/@inxt-js/lib/merkleTree.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { randomBytes } from 'react-native-crypto'; -import { ripemd160, sha256 } from './crypto'; - -interface MerkleTree { - leaf: string[]; - challenges: Buffer[]; - challenges_as_str: string[]; - preleaf: Buffer[]; -} - -const SHARD_CHALLENGES = 4; - -function arrayBufferToString(array: Buffer[]): string[] { - return array.map((b) => { - return b.toString('hex'); - }); -} - -export function preleaf(challenge: Uint8Array, encrypted: Uint8Array): Buffer { - const preleafContent = Buffer.concat([challenge, encrypted]); - - return ripemd160(sha256(preleafContent)); -} - -function preleafArray(encrypted: Buffer, challenge: Buffer[]): Buffer[] { - const preleafArray = challenge.map((challenge) => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - //@ts-ignore - return Buffer.concat([challenge, encrypted]); - }); - - return preleafArray; -} - -function leaf(preleaf: Buffer): Buffer { - return ripemd160(sha256(preleaf)); -} - -function leafArray(preleafArray: Buffer[]): Buffer[] { - return preleafArray.map((preleaf) => { - return leaf(preleaf); - }); -} -/* -function getChallenges(): Buffer[] { - let challenges: Buffer[] = new Array(SHARD_CHALLENGES); - for (let i = 0; i < SHARD_CHALLENGES; i++) { - challenges.push(randomBytes(16)) - } - return challenges -} -*/ - -function challenge(): Buffer { - return randomBytes(16); -} - -function challengeArray(): Buffer[] { - const challengeArray = []; - - for (let i = 0; i < SHARD_CHALLENGES; i++) { - challengeArray.push(challenge()); - } - - return challengeArray; -} - -function merkleTree(encrypted: Buffer): MerkleTree { - // set the challenges randomnly - const challenges = challengeArray(); - - const preleaves = preleafArray(encrypted, challenges); - const leaves = leafArray(preleaves); - - const merkleTree: MerkleTree = { - leaf: arrayBufferToString(leaves), - challenges, - challenges_as_str: arrayBufferToString(challenges), - preleaf: preleaves, - }; - - return merkleTree; -} - -function getChallenges(mT: MerkleTree): string[] { - const challenges = mT.challenges.map((challengeBuffer) => { - return challengeBuffer.toString('hex'); - }); - - return challenges; -} - -function getTree(mT: MerkleTree): string[] { - const tree = mT.leaf.map((leafBuffer) => { - return leafBuffer.toString(); - }); - - return tree; -} - -export { getChallenges, getTree, merkleTree, MerkleTree }; diff --git a/src/@inxt-js/lib/shardMeta.ts b/src/@inxt-js/lib/shardMeta.ts deleted file mode 100644 index 7cef68d9c..000000000 --- a/src/@inxt-js/lib/shardMeta.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { MerkleTree, merkleTree } from './merkleTree'; -import { ripemd160, sha256 } from './crypto'; - -// req object for put a frame -export interface ShardMeta { - hash: string; - size: number; // size of the actual file - index: number; - parity: boolean; - challenges?: Buffer[]; - challenges_as_str: string[]; - tree: string[]; - exclude?: any; -} - -const getShardHash = (encryptedShardData: Buffer) => ripemd160(sha256(encryptedShardData)); - -export function getShardMeta( - encryptedShardData: Buffer, - fileSize: number, - index: number, - parity: boolean, - exclude?: any, -): ShardMeta { - const mT: MerkleTree = merkleTree(encryptedShardData); - - return { - hash: getShardHash(encryptedShardData).toString('hex'), - size: fileSize, - index, - parity, - challenges_as_str: mT.challenges_as_str, - tree: mT.leaf, - }; -} diff --git a/src/@inxt-js/lib/utils/index.ts b/src/@inxt-js/lib/utils/index.ts deleted file mode 100644 index f90536605..000000000 --- a/src/@inxt-js/lib/utils/index.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { MIN_SHARD_SIZE, SHARD_MULTIPLE_BACK, MAX_SHARD_SIZE } from '../../api/constants'; - -/** - * Determines the best concurrency number of chunks in memory to fit - * desired ram usage - * @param desiredRamUsage Desired ram usage in bytes - * @param fileSize Size of the file to work with - * @returns Concurrency number - */ -export function determineConcurrency(desiredRamUsage: number, fileSize: number): number { - const shardSize = determineShardSize(fileSize); - - return Math.max(Math.floor(desiredRamUsage / shardSize), 1); -} - -function shardSize(hops: number): number { - return MIN_SHARD_SIZE * Math.pow(2, hops); -} - -export function _determineShardSize(fileSize: number, accumulator = 0): number { - if (fileSize < 0) { - return 0; - } - - let hops = accumulator - SHARD_MULTIPLE_BACK < 0 ? 0 : accumulator - SHARD_MULTIPLE_BACK; - - const byteMultiple = shardSize(accumulator); - - const check = fileSize / byteMultiple; - - if (check > 0 && check <= 1) { - while (hops > 0 && shardSize(hops) > MAX_SHARD_SIZE) { - hops = hops - 1 <= 0 ? 0 : hops - 1; - } - - return shardSize(hops); - } - - if (accumulator > 41) { - return 0; - } - - return _determineShardSize(fileSize, ++accumulator); -} - -export function determineParityShards(totalShards: number): number { - return Math.ceil((totalShards * 2) / 3); -} - -/** - * Determines the best shard size for a provided file size - * @param fileSize Size of the file to be sharded - * @returns Shard size - */ -export function determineShardSize(fileSize: number): number { - const oneMb = 1024 * 1024; - - const thirtyMb = 30 * oneMb; - const fiftyMb = 50 * oneMb; - const oneHundredMb = 100 * oneMb; - const twoHundredMb = 200 * oneMb; - const fourHundredMb = 400 * oneMb; - - if (fileSize < thirtyMb) { - return 4095 * 600; // 2Mb (rounded to base64 compatible size) - } - - if (fileSize < fiftyMb) { - return 4095 * 600 * 3; // 7Mb - } - - if (fileSize < oneHundredMb) { - return 4095 * 600 * 5; // 12Mb - } - - if (fileSize < twoHundredMb) { - return 4095 * 600 * 10; // 24Mb - } - - if (fileSize < fourHundredMb) { - return 4095 * 600 * 15; // 37Mb - } - - return 4095 * 600 * 20; // 49Mb -} - -export function determineTick(fileSize: number): number { - const oneMb = 1024 * 1024; - const oneHundredMb = 100 * oneMb; - const twoHundredMb = 200 * oneMb; - - if (fileSize < oneHundredMb) { - return 50; - } - - if (fileSize < twoHundredMb) { - return 200; - } - - return 1000; -} diff --git a/src/@inxt-js/lib/utils/mutex/index.ts b/src/@inxt-js/lib/utils/mutex/index.ts deleted file mode 100644 index b1ad937b0..000000000 --- a/src/@inxt-js/lib/utils/mutex/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './mutex'; diff --git a/src/@inxt-js/lib/utils/mutex/mutex.ts b/src/@inxt-js/lib/utils/mutex/mutex.ts deleted file mode 100644 index 059ac3712..000000000 --- a/src/@inxt-js/lib/utils/mutex/mutex.ts +++ /dev/null @@ -1,21 +0,0 @@ -export class Mutex { - private mutex = Promise.resolve(); - - lock(): PromiseLike<() => void> { - let begin: (unlock: () => void) => void; - - this.mutex = this.mutex.then(() => new Promise(begin)); - - return new Promise((res) => (begin = res)); - } - - async dispatch(fn: (() => void) | (() => PromiseLike)): Promise { - const unlock = await this.lock(); - - try { - return await Promise.resolve(fn()); - } finally { - unlock(); - } - } -} diff --git a/src/@inxt-js/lib/utils/shard.ts b/src/@inxt-js/lib/utils/shard.ts deleted file mode 100644 index 6c2836a54..000000000 --- a/src/@inxt-js/lib/utils/shard.ts +++ /dev/null @@ -1,50 +0,0 @@ -function computeShardSizeBits(fileSize: number): number { - // Check if fileSize == 0 - if (fileSize === 0) { - return 0; - } - - const MIN_SHARD_SIZE = 2097152; // 2Mb - const MAX_SHARD_SIZE = 4294967296; // 4 Gb - const SHARD_MULTIPLES_BACK = 4; - - const shardSize = function (hops: number): number { - return MIN_SHARD_SIZE * Math.pow(2, hops); - }; - - // Maximum of 2 ^ 41 * 8 * 1024 * 1024 - for (let accumulator = 0; accumulator < 41; accumulator++) { - let hops = accumulator - SHARD_MULTIPLES_BACK < 0 ? 0 : accumulator - SHARD_MULTIPLES_BACK; - const byteMultiple = shardSize(accumulator); - const check = fileSize / byteMultiple; - - if (check > 0 && check <= 1) { - while (hops > 0 && shardSize(hops) > MAX_SHARD_SIZE) { - hops = hops - 1 <= 0 ? 0 : hops - 1; - } - - return shardSize(hops); - } - } - - return 0; -} - -// Returns the shard size in Bytes -export function computeShardSize(fileSize: number): number { - const fileSizeBits = fileSize * 8; - const shardSizeBits = computeShardSizeBits(fileSizeBits); - // return the number of bytes - const shardBytes = Math.ceil(shardSizeBits / 8); - - return shardBytes; -} - -// Returns the number of shards -export function totalDataShards(fileSize: number): number { - // Convert to bits - const fileSizeBits = fileSize * 8; - const totalShards = Math.ceil(fileSizeBits / computeShardSize(fileSizeBits)); - - return totalShards; -} diff --git a/src/@inxt-js/lib/utils/slicer.ts b/src/@inxt-js/lib/utils/slicer.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/@inxt-js/services/api.ts b/src/@inxt-js/services/api.ts deleted file mode 100644 index bab60b31b..000000000 --- a/src/@inxt-js/services/api.ts +++ /dev/null @@ -1,335 +0,0 @@ -import { AxiosRequestConfig, AxiosResponse } from 'axios'; -import { EnvironmentConfig } from '..'; -import { ExchangeReport } from '../api/reports'; -import { Shard } from '../api/shard'; -import { INXTRequest } from '../lib'; -import { ShardMeta } from '../lib/shardMeta'; - -export enum Methods { - Get = 'GET', - Post = 'POST', - Put = 'PUT', -} - -export interface GetBucketByIdResponse { - user: string; - encryptionKey: string; - publicPermissions: string[]; - created: string; - name: string; - pubkeys: string[]; - status: 'Active' | 'Inactive'; - transfer: number; - storage: number; - id: string; -} - -export interface GetFileByIdResponse { - /* file-id */ - id: string; -} - -export interface FrameStaging { - /* frame id */ - id: string; - /* user email */ - user: string; - shards: []; - storageSize: number; - /* frame size */ - size: number; - locked: boolean; - /* created timestamp stringified */ - created: string; -} - -export interface CreateEntryFromFrameBody { - frame: string; - filename: string; - index: string; - hmac: { - type: string; - value: string; - }; - erasure?: { - type: string; - }; -} - -export interface CreateEntryFromFrameResponse { - /* bucket entry id */ - id: string; - index: string; - /* frame id */ - frame: string; - /* bucket id */ - bucket: string; - mimetype: string; - name: string; - renewal: string; - created: string; - hmac: { - value: string; - type: string; - }; - erasure: { - type: string; - }; - size: number; -} - -export interface SendShardToNodeResponse { - result: string; -} - -export interface AddShardToFrameBody { - /* shard hash */ - hash: string; - /* shard size */ - size: number; - /* shard index */ - index: number; - /* if exists a shard parity for this shard */ - parity: boolean; - /* shard challenges */ - challenges: string[]; - tree: string[]; - /* nodes excluded from being the shard's node */ - exclude: string[]; -} - -export interface SendShardToNodeResponse { - result: string; -} - -export interface CreateFileTokenResponse { - bucket: string; - encryptionKey: string; - expires: string; - id: string; - mimetype: string; - operation: 'PUSH' | 'PULL'; - size: number; - token: string; -} - -export interface InxtApiI { - getBucketById(bucketId: string, params?: AxiosRequestConfig): INXTRequest; - getFileById(bucketId: string, fileId: string, params?: AxiosRequestConfig): INXTRequest; - createFrame(params?: AxiosRequestConfig): INXTRequest; - createEntryFromFrame(bucketId: string, body: CreateEntryFromFrameBody, params?: AxiosRequestConfig): INXTRequest; - addShardToFrame(frameId: string, body: ShardMeta, params?: AxiosRequestConfig): INXTRequest; - sendUploadExchangeReport(exchangeReport: ExchangeReport): Promise>; - sendShardToNode(shard: Shard, shardContent: Buffer): INXTRequest; - getShardFromNode(shard: Shard): INXTRequest; - createFileToken(bucketId: string, fileId: string, operation: 'PUSH' | 'PULL'): INXTRequest; - requestPut(shard: Omit): INXTRequest; - requestGet(shard: Shard): INXTRequest; - putShard(url: string, content: Buffer): INXTRequest; -} - -function emptyINXTRequest(config: EnvironmentConfig): INXTRequest { - return new INXTRequest(config, Methods.Get, '', {}, false); -} - -class InxtApi implements InxtApiI { - protected config: EnvironmentConfig; - protected url: string; - - constructor(config: EnvironmentConfig) { - this.config = config; - this.url = config.bridgeUrl ?? ''; - } - - getBucketById(bucketId: string, params?: AxiosRequestConfig): INXTRequest { - return emptyINXTRequest(this.config); - } - - getFileById(bucketId: string, fileId: string, params?: AxiosRequestConfig): INXTRequest { - return emptyINXTRequest(this.config); - } - - createFrame(params?: AxiosRequestConfig): INXTRequest { - return emptyINXTRequest(this.config); - } - - createEntryFromFrame(bucketId: string, body: CreateEntryFromFrameBody, params?: AxiosRequestConfig): INXTRequest { - return emptyINXTRequest(this.config); - } - - addShardToFrame(frameId: string, body: ShardMeta, params?: AxiosRequestConfig): INXTRequest { - return emptyINXTRequest(this.config); - } - - sendUploadExchangeReport(exchangeReport: ExchangeReport): Promise> { - return exchangeReport.sendReport(); - } - - sendShardToNode(shard: Shard, shardContent: Buffer): INXTRequest { - return emptyINXTRequest(this.config); - } - - getShardFromNode(shard: Shard): INXTRequest { - return emptyINXTRequest(this.config); - } - - createFileToken(bucketId: string, fileId: string, operation: 'PUSH' | 'PULL'): INXTRequest { - return emptyINXTRequest(this.config); - } - - requestPut(shard: Shard): INXTRequest { - return emptyINXTRequest(this.config); - } - - requestGet(shard: Shard): INXTRequest { - return emptyINXTRequest(this.config); - } - - putShard(url: string, content: Buffer): INXTRequest { - return emptyINXTRequest(this.config); - } -} - -export class EmptyBridgeUrlError extends Error { - constructor() { - super('Empty bridge url'); - } -} -export class Bridge extends InxtApi { - constructor(config: EnvironmentConfig) { - if (config.bridgeUrl === '') { - throw new EmptyBridgeUrlError(); - } - super(config); - } - - getBucketById(bucketId: string, params?: AxiosRequestConfig): INXTRequest { - const targetUrl = `${this.url}/buckets/${bucketId}`; - const defParams: AxiosRequestConfig = { - headers: { - 'Content-Type': 'application/octet-stream', - }, - }; - - const finalParams = { ...defParams, ...params }; - - return new INXTRequest(this.config, Methods.Get, targetUrl, finalParams, false); - } - - getFileById(bucketId: string, fileId: string, params?: AxiosRequestConfig): INXTRequest { - const targetUrl = `${this.url}/buckets/${bucketId}/file-ids/${fileId}`; - const defParams: AxiosRequestConfig = { - headers: { - 'Content-Type': 'application/octet-stream', - }, - }; - - const finalParams = { ...defParams, ...params }; - - return new INXTRequest(this.config, Methods.Get, targetUrl, finalParams, false); - } - - createFrame(params?: AxiosRequestConfig): INXTRequest { - const targetUrl = `${this.url}/frames`; - const defParams: AxiosRequestConfig = { - headers: { - 'Content-Type': 'application/octet-stream', - }, - }; - - const finalParams = { ...defParams, ...params }; - - return new INXTRequest(this.config, Methods.Post, targetUrl, finalParams, false); - } - - createEntryFromFrame(bucketId: string, body: CreateEntryFromFrameBody, params?: AxiosRequestConfig): INXTRequest { - const targetUrl = `${this.url}/buckets/${bucketId}/files/ensure`; - const defParams: AxiosRequestConfig = { - headers: { - 'Content-Type': 'application/octet-stream', - }, - data: body, - }; - - const finalParams = { ...defParams, ...params }; - - return new INXTRequest(this.config, Methods.Post, targetUrl, finalParams, false); - } - - addShardToFrame(frameId: string, body: ShardMeta, params?: AxiosRequestConfig): INXTRequest { - const targetUrl = `${this.url}/frames/${frameId}`; - const defParams: AxiosRequestConfig = { - headers: { - 'Content-Type': 'application/octet-stream', - }, - data: { ...body, challenges: body.challenges_as_str }, - }; - - const finalParams = { ...defParams, ...params }; - - return new INXTRequest(this.config, Methods.Put, targetUrl, finalParams, false); - } - - sendUploadExchangeReport(exchangeReport: ExchangeReport): Promise> { - return exchangeReport.sendReport(); - } - - sendShardToNode(shard: Shard, shardContent: Buffer): INXTRequest { - const targetUrl = `http://${shard.farmer.address}:${shard.farmer.port}/shards/${shard.hash}?token=${shard.token}`; - - return new INXTRequest(this.config, Methods.Post, targetUrl, { data: shardContent }, true); - } - - getShardFromNode(shard: Shard): INXTRequest { - const { farmer, hash, token } = shard; - const { address, port } = farmer; - const targetUrl = `http://${address}:${port}/shards/${hash}?token=${token}`; - - return new INXTRequest( - this.config, - Methods.Get, - targetUrl, - { - headers: { - 'content-type': 'application/octet-stream', - }, - responseType: 'arraybuffer', - }, - this.config.useProxy ?? true, - ); - } - - createFileToken(bucketId: string, fileId: string, operation: 'PUSH' | 'PULL'): INXTRequest { - const targetUrl = `https://api.internxt.com/buckets/${bucketId}/tokens`; - - return new INXTRequest(this.config, Methods.Post, targetUrl, { data: { operation, file: fileId } }, false); - } - - requestPut(shard: Shard): INXTRequest { - const targetUrl = `http://${shard.farmer.address}:${shard.farmer.port}/upload/link/${shard.hash}`; - - return new INXTRequest(this.config, Methods.Get, targetUrl, {}, true); - } - - requestGet(shard: Shard): INXTRequest { - const targetUrl = `http://${shard.farmer.address}:${shard.farmer.port}/download/link/${shard.hash}`; - - return new INXTRequest(this.config, Methods.Get, targetUrl, {}, true); - } - - putShard(url: string, content: Buffer): INXTRequest { - return new INXTRequest(this.config, Methods.Put, url, { data: content }, false); - } - - getShard(url: string): INXTRequest { - return new INXTRequest( - this.config, - Methods.Get, - url, - { - responseType: 'arraybuffer', - }, - false, - ); - } -} diff --git a/src/@inxt-js/services/proxy.ts b/src/@inxt-js/services/proxy.ts deleted file mode 100644 index 162b40523..000000000 --- a/src/@inxt-js/services/proxy.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { Mutex } from '../lib/utils/mutex'; - -const wait = (ms: number) => new Promise((res) => setTimeout(res, ms)); - -const MAX_CONCURRENT_BROWSER_CONNECTIONS = 6; - -export class ProxyBalancer { - private proxies: Proxy[]; - - constructor() { - this.proxies = []; - } - - async getProxy(reqsLessThan: number): Promise { - const proxiesCopy = [...this.proxies]; - - let proxiesAvailable; - - while ((proxiesAvailable = proxiesCopy.filter((proxy) => proxy.requests() < reqsLessThan)).length === 0) { - await wait(500); - } - - return proxiesAvailable[0]; - } - - attach(p: Proxy): ProxyBalancer { - this.proxies.push(p); - - return this; - } - - del(p: Proxy): void { - this.proxies = this.proxies.filter((proxy) => proxy.url !== p.url); - } -} - -export class Proxy { - public url: string; - private currentRequests: ProxyRequest[]; - - constructor(url: string) { - this.url = url; - this.currentRequests = []; - } - - requests(): number { - return this.currentRequests.length; - } - - addReq(p: ProxyRequest): void { - this.currentRequests.push(p); - } - - removeReq(p: ProxyRequest): void { - this.currentRequests = this.currentRequests.filter((req) => req.id !== p.id); - } -} - -export interface ProxyRequest { - id: number; -} - -export interface ProxyManager { - url: string; - free: () => void; -} - -const proxyBalancer = new ProxyBalancer() - .attach(new Proxy('https://proxy01.api.internxt.com')) - .attach(new Proxy('https://proxy02.api.internxt.com')) - .attach(new Proxy('https://proxy03.api.internxt.com')) - .attach(new Proxy('https://proxy04.api.internxt.com')) - .attach(new Proxy('https://proxy05.api.internxt.com')) - .attach(new Proxy('https://proxy06.api.internxt.com')) - .attach(new Proxy('https://proxy07.api.internxt.com')); - -const mutex = new Mutex(); - -export const getProxy = async (): Promise => { - let response = { - ...new Proxy(''), - free: () => { - null; - }, - }; - - await mutex.dispatch(async () => { - const proxy = await proxyBalancer.getProxy(MAX_CONCURRENT_BROWSER_CONNECTIONS); - const proxyReq = { id: Math.random() * 9999999 }; - - proxy.addReq(proxyReq); - - response = { ...proxy, free: () => proxy.removeReq(proxyReq) }; - }); - - return response; -}; diff --git a/src/@inxt-js/services/request.ts b/src/@inxt-js/services/request.ts index abdd2b25a..aa1835af7 100644 --- a/src/@inxt-js/services/request.ts +++ b/src/@inxt-js/services/request.ts @@ -1,47 +1,27 @@ -import axios, { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios'; +import axios, { AxiosRequestConfig, AxiosResponse } from 'axios'; import { EnvironmentConfig } from '..'; import { sha256 } from '../lib/crypto'; -import { ExchangeReport } from '../api/reports'; - -import { ShardMeta } from '../lib/shardMeta'; -import { ContractNegotiated } from '../lib/contracts'; -import { Shard } from '../api/shard'; -import { getProxy, ProxyManager } from './proxy'; -import { constants } from 'src/services/AppService'; export async function request( config: EnvironmentConfig, method: AxiosRequestConfig['method'], targetUrl: string, params: AxiosRequestConfig, - useProxy = true, ): Promise> { - let reqUrl = targetUrl; - let proxy: ProxyManager; - - if (useProxy) { - proxy = await getProxy(); - reqUrl = `${proxy.url}/${targetUrl}`; - } - const DefaultOptions: AxiosRequestConfig = { method, auth: { username: config.bridgeUser, password: sha256(Buffer.from(config.bridgePass)).toString('hex'), }, - url: reqUrl, + url: targetUrl, maxContentLength: Infinity, }; const options = { ...DefaultOptions, ...params }; return axios.request(options).then((value: AxiosResponse) => { - if (useProxy && proxy) { - proxy.free(); - } - return value; }); } @@ -50,298 +30,28 @@ export async function plainRequest( method: AxiosRequestConfig['method'], targetUrl: string, params: AxiosRequestConfig, - useProxy = true, ): Promise> { - let reqUrl = targetUrl; - let proxy: ProxyManager; - - if (useProxy) { - proxy = await getProxy(); - reqUrl = `${proxy.url}/${targetUrl}`; - } - const DefaultOptions: AxiosRequestConfig = { method, - url: reqUrl, + url: targetUrl, maxContentLength: Infinity, }; const options = { ...DefaultOptions, ...params }; return axios.request(options).then((value: AxiosResponse) => { - if (useProxy && proxy) { - proxy.free(); - } - return value; }); } -export async function get(params: { responseType?: string; url: string }, config = { useProxy: false }): Promise { - return plainRequest('GET', params.url, { responseType: params.responseType as any }, config.useProxy).then( - (res) => { - return res.data as unknown as K; - }, - ); +export async function get(params: { responseType?: string; url: string }): Promise { + return plainRequest('GET', params.url, { responseType: params.responseType as any }).then((res) => { + return res.data as unknown as K; + }); } -export async function getBuffer(url: string, config = { useProxy: false }): Promise { - return plainRequest('GET', url, { responseType: 'arraybuffer' }, config.useProxy).then((res) => { +export async function getBuffer(url: string): Promise { + return plainRequest('GET', url, { responseType: 'arraybuffer' }).then((res) => { return Buffer.from(res.request._response, 'base64'); }); } - -interface getBucketByIdResponse { - user: string; - encryptionKey: string; - publicPermissions: string[]; - created: string; - name: string; - pubkeys: string[]; - status: 'Active' | 'Inactive'; - transfer: number; - storage: number; - id: string; -} - -/** - * Checks if a bucket exists given its id - * @param config App config - * @param bucketId - * @param token - * @param jwt JSON Web Token - * @param params - */ -export function getBucketById( - config: EnvironmentConfig, - bucketId: string, - params?: AxiosRequestConfig, -): Promise { - const URL = config.bridgeUrl ? config.bridgeUrl : constants.BRIDGE_URL; - const targetUrl = `${URL}/buckets/${bucketId}`; - const defParams: AxiosRequestConfig = { - headers: { - 'Content-Type': 'application/octet-stream', - }, - }; - - const finalParams = { ...defParams, ...params }; - - return request(config, 'get', targetUrl, finalParams, false).then( - (res: AxiosResponse) => res.data, - ); -} - -interface getFileByIdResponse { - /* file-id */ - id: string; -} - -/** - * Checks if a file exists given its id and a bucketId - * @param config App config - * @param bucketId - * @param fileId - * @param jwt JSON Web Token - * @param params - */ -export function getFileById( - config: EnvironmentConfig, - bucketId: string, - fileId: string, - params?: AxiosRequestConfig, -): Promise { - const URL = config.bridgeUrl ? config.bridgeUrl : constants.BRIDGE_URL; - const targetUrl = `${URL}/buckets/${bucketId}/file-ids/${fileId}`; - const defParams: AxiosRequestConfig = { - headers: { - 'Content-Type': 'application/octet-stream', - }, - }; - - const finalParams = { ...defParams, ...params }; - - return request(config, 'get', targetUrl, finalParams, false).then( - (res: AxiosResponse) => res.data, - ); -} - -export interface FrameStaging { - /* frame id */ - id: string; - /* user email */ - user: string; - shards: []; - storageSize: number; - /* frame size */ - size: number; - locked: boolean; - /* created timestamp stringified */ - created: string; -} - -/** - * Creates a file staging frame - * @param config App config - * @param params - */ -export function createFrame(config: EnvironmentConfig, params?: AxiosRequestConfig): Promise { - const URL = config.bridgeUrl ? config.bridgeUrl : constants.BRIDGE_URL; - const targetUrl = `${URL}/frames`; - const defParams: AxiosRequestConfig = { - headers: { - 'Content-Type': 'application/octet-stream', - }, - }; - - const finalParams = { ...defParams, ...params }; - - return request(config, 'post', targetUrl, finalParams, false).then((res: AxiosResponse) => res.data); -} - -export interface CreateEntryFromFrameBody { - frame: string; - filename: string; - index: string; - hmac: { - type: string; - value: string; - }; - erasure?: { - type: string; - }; -} - -export interface CreateEntryFromFrameResponse { - /* bucket entry id */ - id: string; - index: string; - /* frame id */ - frame: string; - /* bucket id */ - bucket: string; - mimetype: string; - name: string; - renewal: string; - created: string; - hmac: { - value: string; - type: string; - }; - erasure: { - type: string; - }; - size: number; -} - -/** - * Creates a bucket entry from the given frame object - * @param {EnvironmentConfig} config App config - * @param {string} bucketId - * @param {CreateEntryFromFrameBody} body - * @param {string} jwt JSON Web Token - * @param {AxiosRequestConfig} params - */ -export function createEntryFromFrame( - config: EnvironmentConfig, - bucketId: string, - body: CreateEntryFromFrameBody, - params?: AxiosRequestConfig, -): Promise { - const URL = config.bridgeUrl ? config.bridgeUrl : constants.BRIDGE_URL; - const targetUrl = `${URL}/buckets/${bucketId}/files/ensure`; - const defParams: AxiosRequestConfig = { - headers: { - 'Content-Type': 'application/octet-stream', - }, - data: body, - }; - - const finalParams = { ...defParams, ...params }; - - return request(config, 'post', targetUrl, finalParams, false) - .then((res: AxiosResponse) => res.data) - .catch((err: AxiosError) => { - const message = handleAxiosError(err); - - if (message.includes('duplicate key')) { - throw new Error('File already exists in the network'); - } - - throw new Error(message); - }); -} - -export function handleAxiosError(err: any): string { - return (err.response && err.response.data && err.response.data.error) || err.message; -} - -/** - * Negotiates a storage contract and adds the shard to the frame - * @param {EnvironmentConfig} config App config - * @param {string} frameId - * @param {AddShardToFrameBody} body - * @param {string} jwt JSON Web Token - * @param {AxiosRequestConfig} params - */ -export function addShardToFrame( - config: EnvironmentConfig, - frameId: string, - body: ShardMeta, - params?: AxiosRequestConfig, -): Promise { - const URL = config.bridgeUrl ? config.bridgeUrl : constants.BRIDGE_URL; - const targetUrl = `${URL}/frames/${frameId}`; - const defParams: AxiosRequestConfig = { - headers: { - 'Content-Type': 'application/octet-stream', - }, - data: { ...body, challenges: body.challenges_as_str }, - }; - - const finalParams = { ...defParams, ...params }; - - return request(config, 'put', targetUrl, finalParams, false).then( - (res: AxiosResponse) => res.data, - ); -} - -/** - * Sends an upload exchange report - * @param config App config - * @param body - */ -export function sendUploadExchangeReport( - config: EnvironmentConfig, - exchangeReport: ExchangeReport, -): Promise> { - return exchangeReport.sendReport(); -} - -interface SendShardToNodeResponse { - result: string; -} - -/** - * Stores a shard in a node - * @param config App config - * @param shard Interface that has the contact info - * @param content Buffer with shard content - */ -export function sendShardToNode( - config: EnvironmentConfig, - shard: Shard, - content: Buffer, -): Promise { - const targetUrl = `http://${shard.farmer.address}:${shard.farmer.port}/shards/${shard.hash}?token=${shard.token}`; - - const defParams: AxiosRequestConfig = { - headers: { - 'Content-Type': 'application/octet-stream', - 'x-storj-node-id': shard.farmer.nodeID, - }, - data: content, - }; - - return request(config, 'post', targetUrl, defParams).then((res: AxiosResponse) => res.data); -} diff --git a/src/App.tsx b/src/App.tsx index b8f16dbb7..f0bbb4214 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -145,6 +145,8 @@ function AppContent(): JSX.Element { try { logger.info(`--- Starting new app session at ${time.getFormattedDate(new Date(), 'dd/LL/yyyy - HH:mm')} ---`); + await dispatch(appThunks.initializeLanguageThunk()).unwrap(); + // 1. Get remote updates await getRemoteUpdateIfAvailable(); diff --git a/src/components/BottomTabNavigator/index.tsx b/src/components/BottomTabNavigator/index.tsx index eb5d7eb57..884380c4e 100644 --- a/src/components/BottomTabNavigator/index.tsx +++ b/src/components/BottomTabNavigator/index.tsx @@ -6,22 +6,24 @@ import { storageThunks } from 'src/store/slices/storage'; import { useTailwind } from 'tailwind-rn'; import strings from '../../../assets/lang/strings'; import useGetColor from '../../hooks/useColor'; +import { useLanguage } from '../../hooks/useLanguage'; import { useAppDispatch } from '../../store/hooks'; import { uiActions } from '../../store/slices/ui'; import globalStyle from '../../styles/global'; -const tabs = { - Home: { label: strings.tabs.Home, icon: House }, - Drive: { label: strings.tabs.Drive, icon: FolderSimple }, - Add: { label: strings.tabs.Add, icon: PlusCircle }, - Shared: { label: strings.tabs.Shared, icon: Users }, - Settings: { label: strings.tabs.Settings, icon: Gear }, -}; - function BottomTabNavigator(props: BottomTabBarProps): JSX.Element { const tailwind = useTailwind(); const getColor = useGetColor(); const dispatch = useAppDispatch(); + useLanguage(); + + const tabs = { + Home: { label: strings.tabs.Home, icon: House }, + Drive: { label: strings.tabs.Drive, icon: FolderSimple }, + Add: { label: strings.tabs.Add, icon: PlusCircle }, + Shared: { label: strings.tabs.Shared, icon: Users }, + Settings: { label: strings.tabs.Settings, icon: Gear }, + }; const items = props.state.routes .filter((route) => Object.keys(tabs).includes(route.name)) diff --git a/src/components/modals/AddModal/index.tsx b/src/components/modals/AddModal/index.tsx index 0f8d36284..83109c16d 100644 --- a/src/components/modals/AddModal/index.tsx +++ b/src/components/modals/AddModal/index.tsx @@ -34,6 +34,7 @@ import useGetColor from '../../../hooks/useColor'; import network from '../../../network'; import analytics, { DriveAnalyticsEvent } from '../../../services/AnalyticsService'; import { constants } from '../../../services/AppService'; +import { uploadQueueService } from '../../../services/drive/file/uploadQueue.service'; import { createUploadingFiles, handleDuplicateFiles, @@ -55,7 +56,7 @@ import AppText from '../../AppText'; import BottomModal from '../BottomModal'; import CreateFolderModal from '../CreateFolderModal'; -const MAX_FILES_BULK_UPLOAD = 25; +const MAX_FILES_BULK_UPLOAD = 50; function AddModal(): JSX.Element { const tailwind = useTailwind(); @@ -368,37 +369,47 @@ function AddModal(): JSX.Element { const { filesToUpload, filesExcluded } = validateAndFilterFiles(documents); showFileSizeAlert(filesExcluded); - const filesToProcess = await handleDuplicateFiles(filesToUpload, focusedFolder.uuid); - if (filesToProcess.length === 0) { + + if (filesToUpload.length === 0) { dispatch(uiActions.setShowUploadFileModal(false)); return; } - const preparedFiles = await prepareUploadFiles(filesToProcess, focusedFolder.uuid); - const formattedFiles = createUploadingFiles(preparedFiles, focusedFolder); + const batchId = `batch-${Date.now()}`; + const targetFolder = focusedFolder; - initializeUploads(formattedFiles, dispatch); + return uploadQueueService.enqueue(batchId, async () => { + const filesToProcess = await handleDuplicateFiles(filesToUpload, targetFolder.uuid); + if (filesToProcess.length === 0) { + return; + } - const processedFileIds: number[] = []; + const preparedFiles = await prepareUploadFiles(filesToProcess, targetFolder.uuid); + const formattedFiles = createUploadingFiles(preparedFiles, targetFolder); - for (const file of formattedFiles) { - try { - logger.info(`User from redux when upload: ${user?.username}, bucket: ${user?.bucket}`); - await uploadSingleFile(file, dispatch, uploadFile, uploadSuccess, user); - } catch (error) { - logger.error(`File ${file.name} failed to upload:`, error); + initializeUploads(formattedFiles, dispatch); - notificationsService.show({ - type: NotificationType.Error, - text1: strings.formatString(strings.errors.uploadFile, (error as Error).message) as string, - }); - } finally { - processedFileIds.push(file.id); + const processedFileIds: number[] = []; + const batchFileIds = formattedFiles.map((f) => f.id); + + for (const file of formattedFiles) { + try { + logger.info(`User from redux when upload: ${user?.username}, bucket: ${user?.bucket}`); + await uploadSingleFile(file, dispatch, uploadFile, uploadSuccess, user); + } catch (error) { + logger.error(`File ${file.name} failed to upload:`, error); + notificationsService.show({ + type: NotificationType.Error, + text1: strings.formatString(strings.errors.uploadFile, (error as Error).message) as string, + }); + } finally { + processedFileIds.push(file.id); + } } - } - cleanupStuckUploads(processedFileIds, formattedFiles); - dispatch(driveActions.clearUploadedFiles()); + cleanupStuckUploads(processedFileIds, formattedFiles); + dispatch(driveActions.clearBatchFiles(batchFileIds)); + }); } /** @@ -487,7 +498,7 @@ function AddModal(): JSX.Element { if (!fileSize) { try { const fileInfo = fileSystemService.getFileInfo(cleanUri); - fileSize = fileInfo.exists ? fileInfo.size ?? 0 : 0; + fileSize = fileInfo.exists ? (fileInfo.size ?? 0) : 0; } catch (error) { logger.warn('The file size could not be obtained:', error); fileSize = 0; @@ -532,9 +543,10 @@ function AddModal(): JSX.Element { }) .catch((err) => { logger.error('Error on handleUploadFromCameraRoll function:', JSON.stringify(err)); + const error = errorService.castError(err, 'upload'); notificationsService.show({ type: NotificationType.Error, - text1: strings.formatString(strings.errors.uploadFile, err.message) as string, + text1: error.message, }); }) .finally(() => { @@ -580,7 +592,7 @@ function AddModal(): JSX.Element { if (!fileSize) { try { const fileInfo = fileSystemService.getFileInfo(cleanUri); - fileSize = fileInfo.exists ? fileInfo.size ?? 0 : 0; + fileSize = fileInfo.exists ? (fileInfo.size ?? 0) : 0; } catch (error) { logger.warn('The file size could not be obtained:', error); fileSize = 0; @@ -627,9 +639,10 @@ function AddModal(): JSX.Element { }) .catch((err) => { logger.error('Error on handleUploadFromCameraRoll (Android):', JSON.stringify(err)); + const error = errorService.castError(err, 'upload'); notificationsService.show({ type: NotificationType.Error, - text1: strings.formatString(strings.errors.uploadFile, err.message) as string, + text1: error.message, }); }) .finally(() => { @@ -679,7 +692,7 @@ function AddModal(): JSX.Element { const fileInfo = fileSystemService.getFileInfo(assetToUpload.uri); const formatInfo = detectImageFormat(assetToUpload); const name = drive.file.removeExtension(assetToUpload.uri.split('/').pop() as string); - const size = fileInfo.exists ? fileInfo.size ?? 0 : 0; + const size = fileInfo.exists ? (fileInfo.size ?? 0) : 0; const file: UploadingFile = { id: new Date().getTime(), diff --git a/src/components/photos/VideoViewer/VideoViewer.tsx b/src/components/photos/VideoViewer/VideoViewer.tsx index c1775a7a1..ead1c04e2 100644 --- a/src/components/photos/VideoViewer/VideoViewer.tsx +++ b/src/components/photos/VideoViewer/VideoViewer.tsx @@ -1,4 +1,5 @@ import fileSystemService from '@internxt-mobile/services/FileSystemService'; +import * as ScreenOrientation from 'expo-screen-orientation'; import { Play } from 'phosphor-react-native'; import React, { useEffect, useRef, useState } from 'react'; import { Image, Platform, TouchableOpacity, View } from 'react-native'; @@ -12,46 +13,94 @@ interface VideoViewerProps { onPause?: () => void; onVideoLoadError?: () => void; } + +const isIOS = Platform.OS === 'ios'; +const isAndroid = Platform.OS === 'android'; + +const lockToPortrait = async () => { + if (isAndroid) { + await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP); + } +}; + +const unlockOrientation = async () => { + if (isAndroid) { + await ScreenOrientation.unlockAsync(); + } +}; + export const VideoViewer: React.FC = ({ source, onPlay, onPause, thumbnail, onVideoLoadError }) => { const tailwind = useTailwind(); const [playing, setPlaying] = useState(false); - const [loadError, setLoadError] = useState(); + const [loadError, setLoadError] = useState(); const videoPlayer = useRef(null); + useEffect(() => { if (playing) { - onPlay && onPlay(); + onPlay?.(); } else { - onPause && onPause(); + onPause?.(); } }, [playing]); - const play = () => { - // Don't play the video if it has a loadError such incompatible codec or format + useEffect(() => { + return () => { + lockToPortrait(); + }; + }, []); + + const handlePlay = () => { if (loadError) { - onPlay && onPlay(); + onPlay?.(); return; } - if (Platform.OS === 'ios') { - videoPlayer?.current?.presentFullscreenPlayer(); - } + if (isIOS) { + videoPlayer.current?.presentFullscreenPlayer(); + } setPlaying(true); }; - const handleFullScreenModeDismiss = () => { - videoPlayer.current?.seek(0); + const handleFullscreenPresent = () => { + unlockOrientation(); + }; + + const handleFullscreenWillDismiss = () => { + if (isIOS) { + setPlaying(false); + } + lockToPortrait(); + }; + + const handleFullscreenDidDismiss = () => { + if (isIOS) { + videoPlayer.current?.seek(0); + } + }; + + const handleError = (error: unknown) => { + setLoadError(error); + onVideoLoadError?.(); + }; + + const handleEnd = () => { + setPlaying(false); + videoPlayer.current?.dismissFullscreenPlayer(); + lockToPortrait(); }; - const displayThumbnailOnPlay = Platform.OS === 'android' ? false : true; - const displayPlayButtonOnPlay = Platform.OS === 'android' ? (playing ? false : true) : true; + // On iOS the native fullscreen player covers everything, so the thumbnail can stay. + // On Android the video plays inline, so the thumbnail must hide to reveal the Video component. + const showThumbnail = isIOS || !playing; + return ( - - {displayPlayButtonOnPlay ? ( + + {!playing && ( = ({ source, onPlay, onPaus - ) : null} - {displayThumbnailOnPlay && thumbnail ? ( + )} + {showThumbnail && thumbnail && ( - ) : null} - {source ? ( + )} + {source && ( ); diff --git a/src/contexts/Drive/Drive.context.tsx b/src/contexts/Drive/Drive.context.tsx index eb4f90b41..2eaa80940 100644 --- a/src/contexts/Drive/Drive.context.tsx +++ b/src/contexts/Drive/Drive.context.tsx @@ -8,7 +8,9 @@ import React, { useEffect, useRef, useState } from 'react'; import appService from '@internxt-mobile/services/AppService'; import errorService from '@internxt-mobile/services/ErrorService'; +import notificationsService from '@internxt-mobile/services/NotificationsService'; import { AppStateStatus, NativeEventSubscription } from 'react-native'; +import { NotificationType } from '@internxt-mobile/types/index'; import { driveFolderService } from '@internxt-mobile/services/drive/folder'; import { mapFileWithIsFolder, mapFolderWithIsFolder } from 'src/helpers/driveItemMappers'; @@ -87,7 +89,13 @@ export const DriveContextProvider: React.FC = ({ chil const handleAppStateChange = (state: AppStateStatus) => { if (state === 'active' && currentFolderId.current) { loadFolderContent(currentFolderId.current, { pullFrom: ['network'], resetPagination: true }).catch((error) => { + // TODO: Refactor to custom hook (useDriveWithNotifications) to separate notification concerns from context errorService.reportError(error); + const err = errorService.castError(error, 'content'); + notificationsService.show({ + type: NotificationType.Error, + text1: err.message, + }); }); } }; @@ -115,7 +123,13 @@ export const DriveContextProvider: React.FC = ({ chil setDriveFoldersTree({ [rootFolderId]: ROOT_FOLDER_NODE }); loadFolderContent(rootFolderId, { pullFrom: ['network'], resetPagination: true, focusFolder: true }).catch( (err) => { + // TODO: Refactor to custom hook (useDriveWithNotifications) to separate notification concerns from context errorService.reportError(err); + const error = errorService.castError(err, 'content'); + notificationsService.show({ + type: NotificationType.Error, + text1: error.message, + }); }, ); }, [rootFolderId]); diff --git a/src/hooks/useLanguage.ts b/src/hooks/useLanguage.ts new file mode 100644 index 000000000..9b2a80178 --- /dev/null +++ b/src/hooks/useLanguage.ts @@ -0,0 +1,6 @@ +import { useAppSelector } from '../store/hooks'; + +export const useLanguage = () => { + const language = useAppSelector((state) => state.app.language); + return language; +}; diff --git a/src/lib/network.ts b/src/lib/network.ts index 4a390a937..c7f5a3e17 100644 --- a/src/lib/network.ts +++ b/src/lib/network.ts @@ -1,3 +1,15 @@ +/** + * Legacy Network Wrapper + * + * @deprecated This class wraps the legacy @inxt-js download system. + * Only used by downloadLegacy.ts as a final fallback for very old files. + * + * Modern downloads should use: + * - src/network/NetworkFacade.ts (primary) + * - src/services/NetworkService/download.ts (v1 fallback) + * + */ + import { Environment } from '../@inxt-js'; import { ActionState } from '../@inxt-js/api/actionState'; import { FileInfo } from '../@inxt-js/api/fileinfo'; @@ -5,7 +17,6 @@ import FileManager from '../@inxt-js/api/FileManager'; import appService from '../services/AppService'; import { UserSettings } from '@internxt/sdk/dist/shared/types/userSettings'; -import asyncStorage from '../services/AsyncStorageService'; type ProgressCallback = (progress: number, uploadedBytes: number, totalBytes: number) => void; @@ -97,10 +108,6 @@ export class Network { getFileInfo(bucketId: string, fileId: string): Promise { return this.environment.getFileInfo(bucketId, fileId); } - - createFileToken(bucketId: string, fileId: string, operation: 'PULL' | 'PUSH'): Promise { - return this.environment.createFileToken(bucketId, fileId, operation); - } } /** @@ -118,21 +125,4 @@ export function getEnvironmentConfigFromUser(user: UserSettings): EnvironmentCon }; } -/** - * Returns required config to download/upload files - * - * @deprecated Use getEnvironmentConfigFromUser(user) instead to avoid iOS SecureStore errors. - * This function reads from SecureStore which can fail in background contexts on iOS. - * - * @returns Promise resolving to environment configuration - */ -export function getEnvironmentConfig(): Promise { - return asyncStorage.getUser().then((user) => ({ - bridgeUser: user.bridgeUser, - bridgePass: user.userId, - encryptionKey: user.mnemonic, - bucketId: user.bucket, - })); -} - export const generateFileKey = Environment.utils.generateFileKey; diff --git a/src/network/upload.ts b/src/network/upload.ts index dcf069b5f..8e6cbfdd4 100644 --- a/src/network/upload.ts +++ b/src/network/upload.ts @@ -1,5 +1,6 @@ import * as RNFS from '@dr.pogodin/react-native-fs'; import { logger } from '../services/common'; +import { withRateLimitRetry } from '../services/common/rate-limit'; import { Abortable } from '../types'; import { getNetwork } from './NetworkFacade'; import { NetworkCredentials } from './requests'; @@ -32,18 +33,19 @@ export async function uploadFile( async function retryUpload(): Promise { const MAX_TRIES = 3; const RETRY_DELAY = 1000; - let uploadPromise: Promise; let lastTryError; for (let attempt = 1; attempt <= MAX_TRIES; attempt++) { try { - if (useMultipart) { - uploadPromise = network.uploadMultipart(bucketId, mnemonic, filePath, { - partSize: MULTIPART_PART_SIZE, - uploadingCallback: params.notifyProgress, - abortController: uploadAbortController.signal, - }); - } else { + const result = await withRateLimitRetry(async () => { + if (useMultipart) { + return await network.uploadMultipart(bucketId, mnemonic, filePath, { + partSize: MULTIPART_PART_SIZE, + uploadingCallback: params.notifyProgress, + abortController: uploadAbortController.signal, + }); + } + const [promise, abortable] = await network.upload(bucketId, mnemonic, filePath, { progress: params.notifyProgress, }); @@ -52,10 +54,10 @@ export async function uploadFile( onAbortableReady(abortable); } - uploadPromise = promise; - } + return await promise; + }, 'Upload'); - return await uploadPromise; + return result; } catch (err) { logger.error(`Upload attempt ${attempt} of ${MAX_TRIES} failed:`, err); diff --git a/src/plugins/RateLimitPlugin.ts b/src/plugins/RateLimitPlugin.ts new file mode 100644 index 000000000..0b6496876 --- /dev/null +++ b/src/plugins/RateLimitPlugin.ts @@ -0,0 +1,44 @@ +import { HttpClient } from '@internxt/sdk/dist/shared/http/client'; +import axios from 'axios'; +import { extractEndpointKey, rateLimitInterceptors, rateLimitService } from '../services/common/rate-limit'; +import { AppPlugin } from '../types'; + +/** + * Registers rate limit interceptors at two levels: + * - SDK HttpClient: intercepts all SDK calls before extractData/normalizeError + * - Global axios: intercepts direct axios calls outside the SDK (PaymentService, downloads, etc.) + * + * Must be installed BEFORE AxiosPlugin so rate limit interceptors run first in the chain. + */ +const rateLimitPlugin: AppPlugin = { + install(_store): void { + HttpClient.setGlobalInterceptors(rateLimitInterceptors); + + axios.interceptors.request.use( + async (config) => { + const endpointKey = extractEndpointKey(config); + await rateLimitService.waitIfNeeded(endpointKey); + return config; + }, + (error) => Promise.reject(error), + ); + axios.interceptors.response.use( + (response) => { + if (response.headers) { + const endpointKey = extractEndpointKey(response.config); + rateLimitService.updateFromHeaders(response.headers as Record, endpointKey); + } + return response; + }, + (error) => { + if (error.response?.headers) { + const endpointKey = extractEndpointKey(error.config); + rateLimitService.updateFromHeaders(error.response.headers as Record, endpointKey); + } + return Promise.reject(error); + }, + ); + }, +}; + +export default rateLimitPlugin; diff --git a/src/plugins/index.ts b/src/plugins/index.ts index 3e198ae19..c7b694537 100644 --- a/src/plugins/index.ts +++ b/src/plugins/index.ts @@ -1,6 +1,7 @@ // import sentryPlugin from './SentryPlugin'; import axiosPlugin from './AxiosPlugin'; +import rateLimitPlugin from './RateLimitPlugin'; -const plugins = [axiosPlugin]; +const plugins = [rateLimitPlugin, axiosPlugin]; export default plugins; diff --git a/src/screens/HomeScreen/index.tsx b/src/screens/HomeScreen/index.tsx index 79f49edfc..f2996b78a 100644 --- a/src/screens/HomeScreen/index.tsx +++ b/src/screens/HomeScreen/index.tsx @@ -10,6 +10,7 @@ import { useTailwind } from 'tailwind-rn'; import { useUseCase } from '@internxt-mobile/hooks/common'; import * as useCases from '@internxt-mobile/useCases/drive'; import { SearchInput } from 'src/components/SearchInput'; +import { useLanguage } from '../../hooks/useLanguage'; enum HomeTab { Recents = 'recents', @@ -17,6 +18,7 @@ enum HomeTab { const HomeScreen = (): JSX.Element => { const tailwind = useTailwind(); + useLanguage(); const [searchText, setSearchText] = useState(''); const { diff --git a/src/screens/SettingsScreen/index.tsx b/src/screens/SettingsScreen/index.tsx index cad1d292d..5247e4fe2 100644 --- a/src/screens/SettingsScreen/index.tsx +++ b/src/screens/SettingsScreen/index.tsx @@ -25,6 +25,7 @@ import AppVersionWidget from '../../components/AppVersionWidget'; import SettingsGroup from '../../components/SettingsGroup'; import UserProfilePicture from '../../components/UserProfilePicture'; import useGetColor from '../../hooks/useColor'; +import { useLanguage } from '../../hooks/useLanguage'; import { useScreenProtection } from '../../hooks/useScreenProtection'; import appService from '../../services/AppService'; import { useAppDispatch, useAppSelector } from '../../store/hooks'; @@ -50,6 +51,8 @@ function SettingsScreen({ navigation }: SettingsScreenProps<'SettingsHome'>): JS const isDarkMode = theme === 'dark'; const { isEnabled: isScreenProtectionEnabled, setScreenProtection } = useScreenProtection(); + useLanguage(); + const showBilling = useAppSelector(paymentsSelectors.shouldShowBilling); const { user } = useAppSelector((state) => state.auth); const usagePercent = useAppSelector(storageSelectors.usagePercent); diff --git a/src/screens/SignInScreen/index.tsx b/src/screens/SignInScreen/index.tsx index e74e7841f..ac2e6ef59 100644 --- a/src/screens/SignInScreen/index.tsx +++ b/src/screens/SignInScreen/index.tsx @@ -13,19 +13,20 @@ import AppScreen from '../../components/AppScreen'; import AppVersionWidget from '../../components/AppVersionWidget'; import { useTheme } from '../../contexts/Theme/Theme.context'; import useGetColor from '../../hooks/useColor'; +import { useLanguage } from '../../hooks/useLanguage'; import analytics, { AnalyticsEventKey } from '../../services/AnalyticsService'; import appService from '../../services/AppService'; import { logger } from '../../services/common'; import errorService from '../../services/ErrorService'; import notificationsService from '../../services/NotificationsService'; import { NotificationType } from '../../types'; -import { RootStackScreenProps } from '../../types/navigation'; -function SignInScreen({ navigation }: RootStackScreenProps<'SignIn'>): JSX.Element { +function SignInScreen(): JSX.Element { const tailwind = useTailwind(); const getColor = useGetColor(); const { theme } = useTheme(); const isDark = theme === 'dark'; + useLanguage(); const [error, setError] = useState(''); const dimensions = Dimensions.get('screen'); diff --git a/src/screens/drive/DriveFolderScreen/DriveFolderScreen.tsx b/src/screens/drive/DriveFolderScreen/DriveFolderScreen.tsx index b4f01cd36..89bdfe9ba 100644 --- a/src/screens/drive/DriveFolderScreen/DriveFolderScreen.tsx +++ b/src/screens/drive/DriveFolderScreen/DriveFolderScreen.tsx @@ -20,12 +20,14 @@ import { NotificationType } from '../../../types'; import { DriveItemStatus, DriveListItem } from '../../../types/drive/item'; import { DriveListType, SortDirection, SortType } from '../../../types/drive/ui'; import { DriveScreenProps, DriveStackParamList } from '../../../types/navigation'; +import { useLanguage } from '../../../hooks/useLanguage'; import { DriveFolderEmpty } from './DriveFolderEmpty'; import { DriveFolderError } from './DriveFolderError'; import { DriveFolderScreenHeader } from './DriveFolderScreenHeader'; export function DriveFolderScreen({ navigation }: DriveScreenProps<'DriveFolder'>): JSX.Element { const route = useRoute>(); + useLanguage(); const [loadingMore, setLoadingMore] = useState(false); const { isRootFolder, folderUuid, folderName, parentFolderName, parentUuid } = route.params; @@ -99,6 +101,12 @@ export function DriveFolderScreen({ navigation }: DriveScreenProps<'DriveFolder' }) .catch((error) => { logger.error('Error loading folder content in DriveFolderScreen:', error); + errorService.reportError(error); + const err = errorService.castError(error, 'content'); + notificationsService.show({ + type: NotificationType.Error, + text1: err.message, + }); }); } }, [folderUuid]); @@ -109,7 +117,14 @@ export function DriveFolderScreen({ navigation }: DriveScreenProps<'DriveFolder' if (parentUuid) { driveCtx .loadFolderContent(parentUuid, { pullFrom: ['network'], resetPagination: false, focusFolder: true }) - .catch(errorService.reportError); + .catch((error) => { + errorService.reportError(error); + const err = errorService.castError(error, 'content'); + notificationsService.show({ + type: NotificationType.Error, + text1: err.message, + }); + }); } }; @@ -193,7 +208,14 @@ export function DriveFolderScreen({ navigation }: DriveScreenProps<'DriveFolder' ); handleOnFilePress(driveItem); } else if (driveItem.data.uuid) { - driveCtx.loadFolderContent(driveItem.data.uuid, { focusFolder: true, resetPagination: true }); + driveCtx.loadFolderContent(driveItem.data.uuid, { focusFolder: true, resetPagination: true }).catch((error) => { + errorService.reportError(error); + const err = errorService.castError(error, 'content'); + notificationsService.show({ + type: NotificationType.Error, + text1: err.message, + }); + }); // Navigate to the folder, this is the minimal data navigation.push('DriveFolder', { @@ -277,6 +299,11 @@ export function DriveFolderScreen({ navigation }: DriveScreenProps<'DriveFolder' }); } catch (error) { errorService.reportError(error); + const err = errorService.castError(error, 'content'); + notificationsService.show({ + type: NotificationType.Error, + text1: err.message, + }); } finally { setLoadingMore(false); } @@ -296,11 +323,20 @@ export function DriveFolderScreen({ navigation }: DriveScreenProps<'DriveFolder' }, [driveSortedItems, searchValue]); async function handleRefresh() { - await driveCtx.loadFolderContent(folderUuid, { - focusFolder: true, - pullFrom: ['network'], - resetPagination: true, - }); + try { + await driveCtx.loadFolderContent(folderUuid, { + focusFolder: true, + pullFrom: ['network'], + resetPagination: true, + }); + } catch (error) { + errorService.reportError(error); + const err = errorService.castError(error, 'content'); + notificationsService.show({ + type: NotificationType.Error, + text1: err.message, + }); + } } return ( diff --git a/src/screens/drive/SharedScreen/SharedScreen.tsx b/src/screens/drive/SharedScreen/SharedScreen.tsx index f497f2ea0..da0166eee 100644 --- a/src/screens/drive/SharedScreen/SharedScreen.tsx +++ b/src/screens/drive/SharedScreen/SharedScreen.tsx @@ -18,12 +18,14 @@ import DriveItem from '../../../components/drive/lists/items'; import DriveItemSkinSkeleton from '../../../components/DriveItemSkinSkeleton'; import EmptyList from '../../../components/EmptyList'; import useGetColor from '../../../hooks/useColor'; +import { useLanguage } from '../../../hooks/useLanguage'; import { DriveItemStatus } from '../../../types/drive/item'; type SharedItem = SharedFolders & SharedFiles; export const SharedScreen: React.FC> = (props) => { const tailwind = useTailwind(); const getColor = useGetColor(); + useLanguage(); const { loading: sharedLoading, executeUseCase: getSharedItems } = useUseCase(driveUseCases.getSharedItems); const [sharedItemsPage, setSharedItemsPage] = useState(1); diff --git a/src/services/AsyncStorageService.ts b/src/services/AsyncStorageService.ts index 21023eb7d..1883ba9b1 100644 --- a/src/services/AsyncStorageService.ts +++ b/src/services/AsyncStorageService.ts @@ -119,6 +119,7 @@ class AsyncStorageService { AsyncStorageKey.ScreenLockIsEnabled, AsyncStorageKey.LastScreenLock, AsyncStorageKey.ThemePreference, + AsyncStorageKey.Language, ]; await AsyncStorage.multiRemove(nonSensitiveKeys); diff --git a/src/services/ErrorService.spec.ts b/src/services/ErrorService.spec.ts new file mode 100644 index 000000000..a8bbd04e1 --- /dev/null +++ b/src/services/ErrorService.spec.ts @@ -0,0 +1,472 @@ +import strings from '../../assets/lang/strings'; +import AppError from '../types'; +import { HTTP_TOO_MANY_REQUESTS } from './common'; +import errorService from './ErrorService'; + +describe('ErrorService', () => { + describe('castError', () => { + describe('extractStatus - multiple locations', () => { + it('when status is directly in error.status, extracts it correctly', () => { + const error = { status: 404, message: 'Not found' }; + const result = errorService.castError(error); + + expect(result).toBeInstanceOf(AppError); + expect(result.status).toBe(404); + expect(result.message).toBe('Not found'); + }); + + it('when status is in error.response.status (Axios format), extracts it correctly', () => { + const error = { + response: { + status: 500, + data: { message: 'Internal server error' }, + }, + }; + const result = errorService.castError(error); + + expect(result).toBeInstanceOf(AppError); + expect(result.status).toBe(500); + expect(result.message).toBe('Internal server error'); + }); + + it('when status is in both places, prioritizes error.status', () => { + const error = { + status: 403, + message: 'Forbidden', + response: { + status: 404, + data: { message: 'Not found' }, + }, + }; + const result = errorService.castError(error); + + expect(result.status).toBe(403); + expect(result.message).toBe('Forbidden'); + }); + + it('when there is no status in any location, returns generic error', () => { + const error = { message: 'Network error' }; + const result = errorService.castError(error); + + expect(result.status).toBeUndefined(); + expect(result.message).toBe(strings.errors.genericError); + }); + + it('when status is a string number in error.status, parses and extracts it correctly', () => { + const error = { status: '404', message: 'Not found' }; + const result = errorService.castError(error); + + expect(result.status).toBe(404); + expect(result.message).toBe('Not found'); + }); + + it('when status is a string number in error.response.status, parses and extracts it correctly', () => { + const error = { + response: { + status: '500', + data: { message: 'Internal server error' }, + }, + }; + const result = errorService.castError(error); + + expect(result.status).toBe(500); + expect(result.message).toBe('Internal server error'); + }); + + it('when status is a string with leading/trailing spaces, parses it correctly', () => { + const error = { status: ' 429 ', message: 'Rate limit exceeded' }; + const result = errorService.castError(error, 'upload'); + + expect(result.status).toBe(429); + expect(result.message).toBe(strings.errors.rateLimitUpload); + }); + + it('when status is an invalid string (non-numeric), ignores it and returns generic error', () => { + const error = { status: 'not-a-number', message: 'Some error' }; + const result = errorService.castError(error); + + expect(result.status).toBeUndefined(); + expect(result.message).toBe(strings.errors.genericError); + }); + + it('when status is out of valid HTTP range (e.g., 999), ignores it and returns generic error', () => { + const error = { status: 999, message: 'Invalid status' }; + const result = errorService.castError(error); + + expect(result.status).toBeUndefined(); + expect(result.message).toBe(strings.errors.genericError); + }); + + it('when status is below valid HTTP range (e.g., 99), ignores it and returns generic error', () => { + const error = { status: 99, message: 'Invalid status' }; + const result = errorService.castError(error); + + expect(result.status).toBeUndefined(); + expect(result.message).toBe(strings.errors.genericError); + }); + + it('when string status is at lower edge of error range (400), parses it correctly', () => { + const error = { status: '400', message: 'Bad request' }; + const result = errorService.castError(error); + + expect(result.status).toBe(400); + expect(result.message).toBe('Bad request'); + }); + + it('when string status is at upper edge of error range (599), parses it correctly', () => { + const error = { status: '599', message: 'Network error' }; + const result = errorService.castError(error); + + expect(result.status).toBe(599); + expect(result.message).toBe('Network error'); + }); + + it('when string status is informational (100-199), ignores it as not an error', () => { + const error = { status: '100', message: 'Continue' }; + const result = errorService.castError(error); + + expect(result.status).toBeUndefined(); + expect(result.message).toBe(strings.errors.genericError); + }); + + it('when string status is successful (200-299), ignores it as not an error', () => { + const error = { status: '200', message: 'OK' }; + const result = errorService.castError(error); + + expect(result.status).toBeUndefined(); + expect(result.message).toBe(strings.errors.genericError); + }); + + it('when status is a float string, parses only the integer part', () => { + const error = { status: '404.5', message: 'Not found' }; + const result = errorService.castError(error); + + expect(result.status).toBe(404); + expect(result.message).toBe('Not found'); + }); + }); + + describe('extractMessage - multiple locations', () => { + it('when message is directly in error.message, extracts it correctly', () => { + const error = { status: 400, message: 'Bad request' }; + const result = errorService.castError(error); + + expect(result.message).toBe('Bad request'); + }); + + it('when message is in error.response.data.message (API format), extracts it correctly', () => { + const error = { + response: { + status: 401, + data: { message: 'Unauthorized access' }, + }, + }; + const result = errorService.castError(error); + + expect(result.message).toBe('Unauthorized access'); + }); + + it('when message is in both places, prioritizes error.message', () => { + const error = { + message: 'Direct message', + response: { + status: 400, + data: { message: 'API message' }, + }, + }; + const result = errorService.castError(error); + + expect(result.message).toBe('Direct message'); + }); + + it('when message is an empty string, ignores it and searches in response.data.message', () => { + const error = { + message: ' ', + response: { + status: 400, + data: { message: 'Validation error' }, + }, + }; + const result = errorService.castError(error); + + expect(result.message).toBe('Validation error'); + }); + + it('when there is no message anywhere but has status, returns generic error', () => { + const error = { + response: { + status: 500, + data: {}, + }, + }; + const result = errorService.castError(error); + + expect(result.message).toBe(strings.errors.genericError); + }); + }); + + describe('rate limit (429) - detection and contextual messages', () => { + it('when receives 429 in error.status without context, returns generic rate limit message', () => { + const error = { + status: HTTP_TOO_MANY_REQUESTS, + message: 'Rate limit exceeded', + }; + const result = errorService.castError(error); + + expect(result.status).toBe(HTTP_TOO_MANY_REQUESTS); + expect(result.message).toBe(strings.errors.rateLimitReached); + }); + + it('when receives 429 in error.response.status without context, returns generic rate limit message', () => { + const error = { + response: { + status: HTTP_TOO_MANY_REQUESTS, + data: { message: 'Too many requests' }, + }, + }; + const result = errorService.castError(error); + + expect(result.status).toBe(HTTP_TOO_MANY_REQUESTS); + expect(result.message).toBe(strings.errors.rateLimitReached); + }); + + it('when receives 429 with "upload" context, returns specific upload message', () => { + const error = { + status: HTTP_TOO_MANY_REQUESTS, + message: 'Rate limit exceeded', + }; + const result = errorService.castError(error, 'upload'); + + expect(result.status).toBe(HTTP_TOO_MANY_REQUESTS); + expect(result.message).toBe(strings.errors.rateLimitUpload); + }); + + it('when receives 429 with "download" context, returns specific download message', () => { + const error = { + status: HTTP_TOO_MANY_REQUESTS, + message: 'Rate limit exceeded', + }; + const result = errorService.castError(error, 'download'); + + expect(result.status).toBe(HTTP_TOO_MANY_REQUESTS); + expect(result.message).toBe(strings.errors.rateLimitDownload); + }); + + it('when receives 429 with "content" context, returns specific content message', () => { + const error = { + status: HTTP_TOO_MANY_REQUESTS, + message: 'Rate limit exceeded', + }; + const result = errorService.castError(error, 'content'); + + expect(result.status).toBe(HTTP_TOO_MANY_REQUESTS); + expect(result.message).toBe(strings.errors.rateLimitContent); + }); + + it('when receives 429 from Axios format with "upload" context, returns correct message', () => { + const error = { + response: { + status: HTTP_TOO_MANY_REQUESTS, + headers: { + 'x-internxt-ratelimit-limit': '100', + 'x-internxt-ratelimit-remaining': '0', + 'x-internxt-ratelimit-reset': String(Date.now() + 60000), + }, + data: { message: 'Rate limit exceeded' }, + }, + }; + const result = errorService.castError(error, 'upload'); + + expect(result.status).toBe(HTTP_TOO_MANY_REQUESTS); + expect(result.message).toBe(strings.errors.rateLimitUpload); + }); + }); + + describe('server errors (4xx/5xx) - different formats', () => { + it('when receives error 400 with message, processes it correctly', () => { + const error = { + status: 400, + message: 'Invalid request parameters', + }; + const result = errorService.castError(error); + + expect(result.status).toBe(400); + expect(result.message).toBe('Invalid request parameters'); + }); + + it('when receives error 401 from API, processes it correctly', () => { + const error = { + response: { + status: 401, + data: { message: 'Authentication required' }, + }, + }; + const result = errorService.castError(error); + + expect(result.status).toBe(401); + expect(result.message).toBe('Authentication required'); + }); + + it('when receives error 403 with Spanish message, preserves it', () => { + const error = { + status: 403, + message: 'No tienes permisos para acceder a este recurso', + }; + const result = errorService.castError(error); + + expect(result.status).toBe(403); + expect(result.message).toBe('No tienes permisos para acceder a este recurso'); + }); + + it('when receives error 404 without message, returns generic error', () => { + const error = { + status: 404, + message: '', + }; + const result = errorService.castError(error); + + expect(result.status).toBeUndefined(); + expect(result.message).toBe(strings.errors.genericError); + }); + + it('when receives error 500 from server, processes it correctly', () => { + const error = { + response: { + status: 500, + data: { message: 'Internal server error' }, + }, + }; + const result = errorService.castError(error); + + expect(result.status).toBe(500); + expect(result.message).toBe('Internal server error'); + }); + + it('when receives error 503 (service unavailable), processes it correctly', () => { + const error = { + status: 503, + message: 'Service temporarily unavailable', + }; + const result = errorService.castError(error); + + expect(result.status).toBe(503); + expect(result.message).toBe('Service temporarily unavailable'); + }); + }); + + describe('edge cases and unexpected errors', () => { + it('when receives null, returns generic error', () => { + const result = errorService.castError(null); + + expect(result.status).toBeUndefined(); + expect(result.message).toBe(strings.errors.genericError); + }); + + it('when receives undefined, returns generic error', () => { + const result = errorService.castError(undefined); + + expect(result.status).toBeUndefined(); + expect(result.message).toBe(strings.errors.genericError); + }); + + it('when receives a string, returns generic error', () => { + const result = errorService.castError('Something went wrong'); + + expect(result.status).toBeUndefined(); + expect(result.message).toBe(strings.errors.genericError); + }); + + it('when receives a number, returns generic error', () => { + const result = errorService.castError(404); + + expect(result.status).toBeUndefined(); + expect(result.message).toBe(strings.errors.genericError); + }); + + it('when receives an empty object, returns generic error', () => { + const result = errorService.castError({}); + + expect(result.status).toBeUndefined(); + expect(result.message).toBe(strings.errors.genericError); + }); + + it('when status is not 4xx/5xx but has message, returns generic error', () => { + const error = { + status: 200, + message: 'Success but treated as error', + }; + const result = errorService.castError(error); + + expect(result.status).toBeUndefined(); + expect(result.message).toBe(strings.errors.genericError); + }); + + it('when status is 399 (out of range 400-599), returns generic error', () => { + const error = { + status: 399, + message: 'Not a valid error status', + }; + const result = errorService.castError(error); + + expect(result.status).toBeUndefined(); + expect(result.message).toBe(strings.errors.genericError); + }); + + it('when status is 600 (out of range 400-599), returns generic error', () => { + const error = { + status: 600, + message: 'Not a valid error status', + }; + const result = errorService.castError(error); + + expect(result.status).toBeUndefined(); + expect(result.message).toBe(strings.errors.genericError); + }); + + it('when status is valid but message has only spaces, returns generic error', () => { + const error = { + status: 500, + message: ' ', + response: { + data: {}, + }, + }; + const result = errorService.castError(error); + + expect(result.status).toBeUndefined(); + expect(result.message).toBe(strings.errors.genericError); + }); + }); + + describe('extraction priority verification', () => { + it('when 429 in error.status but different status in response, uses error.status', () => { + const error = { + status: HTTP_TOO_MANY_REQUESTS, + message: 'Rate limit exceeded', + response: { + status: 500, + data: { message: 'Server error' }, + }, + }; + const result = errorService.castError(error, 'upload'); + + expect(result.status).toBe(HTTP_TOO_MANY_REQUESTS); + expect(result.message).toBe(strings.errors.rateLimitUpload); + }); + + it('when direct message and message in response, uses direct message', () => { + const error = { + status: 400, + message: 'Direct error message', + response: { + status: 400, + data: { message: 'API error message' }, + }, + }; + const result = errorService.castError(error); + + expect(result.message).toBe('Direct error message'); + }); + }); + }); +}); diff --git a/src/services/ErrorService.ts b/src/services/ErrorService.ts index 76b4c0177..aea118121 100644 --- a/src/services/ErrorService.ts +++ b/src/services/ErrorService.ts @@ -1,5 +1,6 @@ import strings from '../../assets/lang/strings'; import AppError from '../types'; +import { HTTP_TOO_MANY_REQUESTS } from './common'; import { BaseLogger } from './common/logger'; export interface GlobalErrorContext { @@ -22,6 +23,9 @@ export interface ErrorContext extends GlobalErrorContext { tags: { [tagName: string]: string }; extra?: Record; } + +export type CastErrorContext = 'upload' | 'download' | 'content'; + class ErrorService { private logger = new SentryLogger(); public setGlobalErrorContext(globalContext: Partial) { @@ -31,25 +35,117 @@ class ErrorService { // }); } - public castError(err: unknown): AppError { + /** + * Converts errors from different formats to a consistent and user-friendly AppError. + * + * @param err - Error in any format (Axios, API, JavaScript, etc.) + * @param context - Optional context for specific rate limit messages + * + * @returns AppError with user-friendly message and status code (if available) + */ + public castError(err: unknown, context?: CastErrorContext): AppError { if (err && typeof err === 'object') { const map = err as Record; + const status = this.extractStatus(map); + const message = this.extractMessage(map); + + if (status === HTTP_TOO_MANY_REQUESTS) { + const rateLimitMessage = this.getRateLimitMessage(context); + return new AppError(rateLimitMessage, status); + } + const isServerReturnedError = - typeof map.message === 'string' && - map.message.trim().length > 0 && - typeof map.status === 'number' && - map.status >= 400 && - map.status < 600; + typeof message === 'string' && + message.trim().length > 0 && + typeof status === 'number' && + status >= 400 && + status < 600; if (isServerReturnedError) { - return new AppError(map.message as string, map.status as number); + return new AppError(message, status); } } return new AppError(strings.errors.genericError); } + /** + * Extract status from error object, checking multiple possible locations: + * - error.status (direct) + * - error.response.status (axios format) + */ + private extractStatus(err: Record): number | undefined { + const directStatus = this.parseStatus(err.status); + if (directStatus !== undefined) { + return directStatus; + } + + const response = err.response as Record | undefined; + if (response) { + const responseStatus = this.parseStatus(response.status); + if (responseStatus !== undefined) { + return responseStatus; + } + } + + return undefined; + } + + /** + * Parse a status value that can be either a number or a string. + * Returns undefined if the value cannot be parsed as a valid HTTP status code. + */ + private parseStatus(value: unknown): number | undefined { + if (typeof value === 'number') { + return this.isValidHttpStatus(value) ? value : undefined; + } + + if (typeof value === 'string') { + const parsed = parseInt(value, 10); + return !Number.isNaN(parsed) && this.isValidHttpStatus(parsed) ? parsed : undefined; + } + + return undefined; + } + + /** + * Check if a number is a valid HTTP status code (100-599) + */ + private isValidHttpStatus(status: number): boolean { + return status >= 100 && status < 600; + } + + /** + * Extract message from error object, checking multiple possible locations + */ + private extractMessage(err: Record): string { + if (typeof err.message === 'string' && err.message.trim().length > 0) { + return err.message; + } + + const response = err.response as Record | undefined; + const responseData = response?.data as Record | undefined; + if (responseData && typeof responseData.message === 'string') { + return responseData.message; + } + + return ''; + } + + private getRateLimitMessage(context?: CastErrorContext): string { + switch (context) { + case 'upload': + return strings.errors.rateLimitUpload; + case 'download': + return strings.errors.rateLimitDownload; + case 'content': + return strings.errors.rateLimitContent; + default: + return strings.errors.rateLimitReached; + } + } + public reportError = (error: Error | unknown, context: Partial = {}) => { this.log(context.level || 'error', error); if (!__DEV__) { diff --git a/src/services/LanguageService.ts b/src/services/LanguageService.ts index a0f52e882..bbc27fd9f 100644 --- a/src/services/LanguageService.ts +++ b/src/services/LanguageService.ts @@ -4,25 +4,13 @@ import { Settings } from 'luxon'; import { AsyncStorageKey, Language, NotificationType } from 'src/types'; import asyncStorageService from './AsyncStorageService'; import notificationsService from './NotificationsService'; -class LanguageService { - constructor() { - this.initialize(); - } - private async initialize() { - const language = await asyncStorageService.getItem(AsyncStorageKey.Language); - - Settings.defaultLocale = language ?? strings.getLanguage(); - - language && strings.setLanguage(language); - } +class LanguageService { public async setLanguage(language: Language) { await asyncStorageService.saveItem(AsyncStorageKey.Language, language); strings.setLanguage(language); - Settings.defaultLocale = language ?? strings.getLanguage(); notificationsService.show({ text1: strings.modals.Language.info, type: NotificationType.Info }); - // TODO: ADD WAY TO RESTART THE LANGUAGE IN RUNTIME WHEN IT CHANGES } } diff --git a/src/services/NetworkService/downloadLegacy.ts b/src/services/NetworkService/downloadLegacy.ts index 4f1122096..5810f0557 100644 --- a/src/services/NetworkService/downloadLegacy.ts +++ b/src/services/NetworkService/downloadLegacy.ts @@ -1,3 +1,16 @@ +/** + * Legacy Download Entry Point + * + * This is the ONLY place that should call the legacy @inxt-js download system. + * + * Called when: + * 1. Modern download fails (FileVersionOneError) + * 2. V1 download fails (LegacyDownloadRequiredError - mirrors.length > 1) + * + * This handles very old files with multiple mirrors (legacy redundancy system). + * + */ + import FileManager from '../../@inxt-js/api/FileManager'; import { Network } from '../../lib/network'; import { Abortable, NetworkCredentials } from '../../types'; diff --git a/src/services/common/index.ts b/src/services/common/index.ts index 648aa86d2..4bf78f369 100644 --- a/src/services/common/index.ts +++ b/src/services/common/index.ts @@ -4,3 +4,4 @@ export * from './errors'; export * from './media'; export * from './filesystem'; export * from './biometrics'; +export * from './rate-limit'; diff --git a/src/services/common/rate-limit/index.ts b/src/services/common/rate-limit/index.ts new file mode 100644 index 000000000..8133da356 --- /dev/null +++ b/src/services/common/rate-limit/index.ts @@ -0,0 +1,12 @@ +export { + rateLimitService, + extractEndpointKey, + MAX_RATE_LIMIT_RETRIES, + HTTP_TOO_MANY_REQUESTS, + HEADER_RATELIMIT_LIMIT, + HEADER_RATELIMIT_REMAINING, + HEADER_RATELIMIT_RESET, + HEADER_RETRY_AFTER, +} from './rate-limit.service'; +export { rateLimitInterceptors } from './rate-limit.interceptors'; +export { withRateLimitRetry } from './rate-limit.retry'; diff --git a/src/services/common/rate-limit/rate-limit.interceptors.ts b/src/services/common/rate-limit/rate-limit.interceptors.ts new file mode 100644 index 000000000..2e49342ee --- /dev/null +++ b/src/services/common/rate-limit/rate-limit.interceptors.ts @@ -0,0 +1,111 @@ +import { logger } from '@internxt-mobile/services/common'; +import axios, { AxiosResponse, InternalAxiosRequestConfig } from 'axios'; +import { + HEADER_RATELIMIT_LIMIT, + HEADER_RATELIMIT_REMAINING, + HEADER_RATELIMIT_RESET, + HEADER_RETRY_AFTER, + HTTP_TOO_MANY_REQUESTS, + MAX_RATE_LIMIT_RETRIES, + extractEndpointKey, + rateLimitService, +} from './rate-limit.service'; + +interface AxiosErrorLike { + response?: { status?: number; headers?: Record }; + config?: InternalAxiosRequestConfig & { __rateLimitRetry?: number }; +} + +/** + * Interceptors to pass to SDK's HttpClient. + * They run BEFORE extractData/normalizeError so they see full response headers. + * + * - Request: proactive throttle when remaining quota is low + * - Response success: track rate limit state from headers + * - Response error: on 429, wait and retry transparently using raw axios + * (raw axios avoids the double-processing issue with extractData) + */ +export const rateLimitInterceptors = [ + { + request: { + onFulfilled: async (config: InternalAxiosRequestConfig): Promise => { + const endpointKey = extractEndpointKey(config); + await rateLimitService.waitIfNeeded(endpointKey); + return config; + }, + }, + response: { + onFulfilled: (response: AxiosResponse): AxiosResponse => { + if (response.headers) { + const endpointKey = extractEndpointKey(response.config); + logResponseHeaders(response); + rateLimitService.updateFromHeaders(response.headers as Record, endpointKey); + } + return response; + }, + onRejected: async (error: unknown): Promise => { + const axiosError = error as AxiosErrorLike; + const endpointKey = axiosError.config ? extractEndpointKey(axiosError.config) : 'unknown'; + + if (axiosError.response?.headers) { + logErrorHeaders(axiosError); + rateLimitService.updateFromHeaders(axiosError.response.headers, endpointKey); + } + + if (axiosError.response?.status === HTTP_TOO_MANY_REQUESTS && axiosError.config) { + const attempt = (axiosError.config.__rateLimitRetry ?? 0) + 1; + + if (attempt <= MAX_RATE_LIMIT_RETRIES) { + const retryAfter = axiosError.response.headers?.[HEADER_RETRY_AFTER]; + const delay = rateLimitService.getRetryDelay(retryAfter, endpointKey); + logRetry(attempt, delay); + + await new Promise((resolve) => setTimeout(resolve, delay)); + + // Retry with raw axios (not the intercepted instance) to avoid + // extractData processing the result twice on the original chain. + const config = { ...axiosError.config, __rateLimitRetry: attempt }; + return axios(config); + } + + logRetriesExhausted(); + } + + throw error; + }, + }, + }, +]; + +const logResponseHeaders = (response: AxiosResponse) => { + if (!__DEV__) return; + const h = response.headers as Record; + const method = response.config?.method?.toUpperCase(); + const endpoint = response.config?.url ?? 'unknown'; + const limit = h[HEADER_RATELIMIT_LIMIT]; + const remaining = h[HEADER_RATELIMIT_REMAINING]; + const reset = h[HEADER_RATELIMIT_RESET]; + if (limit || remaining || reset) { + logger.info(`[RateLimit] ${method} ${endpoint} → limit=${limit} remaining=${remaining} reset=${reset}`); + } +}; + +const logErrorHeaders = (axiosError: AxiosErrorLike) => { + if (!__DEV__ || !axiosError.response?.headers) return; + const h = axiosError.response.headers; + const method = axiosError.config?.method?.toUpperCase(); + const endpoint = axiosError.config?.url ?? 'unknown'; + const status = axiosError.response.status; + logger.warn( + `[RateLimit] ${method} ${endpoint} → ${status} | ` + + `limit=${h[HEADER_RATELIMIT_LIMIT]} remaining=${h[HEADER_RATELIMIT_REMAINING]} reset=${h[HEADER_RATELIMIT_RESET]}`, + ); +}; + +const logRetry = (attempt: number, delay: number) => { + logger.warn(`[RateLimit] 429 received, retry ${attempt}/${MAX_RATE_LIMIT_RETRIES} after ${delay}ms`); +}; + +const logRetriesExhausted = () => { + logger.error(`[RateLimit] 429 max retries (${MAX_RATE_LIMIT_RETRIES}) exhausted`); +}; diff --git a/src/services/common/rate-limit/rate-limit.retry.spec.ts b/src/services/common/rate-limit/rate-limit.retry.spec.ts new file mode 100644 index 000000000..a8c14cc70 --- /dev/null +++ b/src/services/common/rate-limit/rate-limit.retry.spec.ts @@ -0,0 +1,177 @@ +import { withRateLimitRetry } from './rate-limit.retry'; +import { HTTP_TOO_MANY_REQUESTS, MAX_RATE_LIMIT_RETRIES, rateLimitService } from './rate-limit.service'; + +jest.mock('@internxt-mobile/services/common', () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + +const { logger } = jest.requireMock('@internxt-mobile/services/common'); + +const make429Error = (status = HTTP_TOO_MANY_REQUESTS) => ({ status, message: 'Too Many Requests' }); + +describe('withRateLimitRetry', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.spyOn(rateLimitService, 'getRetryDelay').mockReturnValue(1000); + (logger.warn as jest.Mock).mockClear(); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it('when operation succeeds on first try, then returns the result without retrying', async () => { + const operation = jest.fn().mockResolvedValue('ok'); + + const promise = withRateLimitRetry(operation, 'test-context'); + await jest.advanceTimersByTimeAsync(0); + const result = await promise; + + expect(result).toBe('ok'); + expect(operation).toHaveBeenCalledTimes(1); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('when operation fails with 429 then succeeds, then retries and returns the result', async () => { + const operation = jest.fn().mockRejectedValueOnce(make429Error()).mockResolvedValue('recovered'); + + const promise = withRateLimitRetry(operation, 'upload'); + await jest.advanceTimersByTimeAsync(1000); + const result = await promise; + + expect(result).toBe('recovered'); + expect(operation).toHaveBeenCalledTimes(2); + expect(logger.warn).toHaveBeenCalledTimes(1); + }); + + it('when operation fails with non-429 error, then throws immediately without retrying', async () => { + const nonRateLimitError = { status: 500, message: 'Server Error' }; + const operation = jest.fn().mockRejectedValue(nonRateLimitError); + + const promise = withRateLimitRetry(operation, 'test-context'); + await expect(promise).rejects.toEqual(nonRateLimitError); + expect(operation).toHaveBeenCalledTimes(1); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('when operation fails with error without status, then throws immediately', async () => { + const plainError = new Error('network failure'); + const operation = jest.fn().mockRejectedValue(plainError); + + const promise = withRateLimitRetry(operation, 'test-context'); + await expect(promise).rejects.toThrow('network failure'); + expect(operation).toHaveBeenCalledTimes(1); + }); + + it(`when operation fails with 429 ${MAX_RATE_LIMIT_RETRIES} times, then exhausts retries and throws`, async () => { + const error429 = make429Error(); + const operation = jest.fn().mockRejectedValue(error429); + + let caughtError: unknown; + const promise = withRateLimitRetry(operation, 'upload').catch((err) => { + caughtError = err; + }); + + for (let i = 0; i < MAX_RATE_LIMIT_RETRIES; i++) { + await jest.advanceTimersByTimeAsync(1000); + } + + await promise; + expect(caughtError).toEqual(error429); + expect(operation).toHaveBeenCalledTimes(MAX_RATE_LIMIT_RETRIES + 1); + expect(logger.warn).toHaveBeenCalledTimes(MAX_RATE_LIMIT_RETRIES); + }); + + it('when operation fails with 429 twice then succeeds, then returns the result', async () => { + const operation = jest + .fn() + .mockRejectedValueOnce(make429Error()) + .mockRejectedValueOnce(make429Error()) + .mockResolvedValue('third-time-charm'); + + const promise = withRateLimitRetry(operation, 'upload'); + await jest.advanceTimersByTimeAsync(1000); + await jest.advanceTimersByTimeAsync(1000); + const result = await promise; + + expect(result).toBe('third-time-charm'); + expect(operation).toHaveBeenCalledTimes(3); + expect(logger.warn).toHaveBeenCalledTimes(2); + }); + + it('when endpointKey is provided, then passes it to getRetryDelay', async () => { + const operation = jest.fn().mockRejectedValueOnce(make429Error()).mockResolvedValue('ok'); + + const promise = withRateLimitRetry(operation, 'upload', 'https://gw.internxt.com/drive/files'); + await jest.advanceTimersByTimeAsync(1000); + await promise; + + expect(rateLimitService.getRetryDelay).toHaveBeenCalledWith(undefined, 'https://gw.internxt.com/drive/files'); + }); + + it('when endpointKey is not provided, then passes undefined to getRetryDelay', async () => { + const operation = jest.fn().mockRejectedValueOnce(make429Error()).mockResolvedValue('ok'); + + const promise = withRateLimitRetry(operation, 'upload'); + await jest.advanceTimersByTimeAsync(1000); + await promise; + + expect(rateLimitService.getRetryDelay).toHaveBeenCalledWith(undefined, undefined); + }); + + it('when retrying, then logs the correct context and retry count', async () => { + const operation = jest + .fn() + .mockRejectedValueOnce(make429Error()) + .mockRejectedValueOnce(make429Error()) + .mockResolvedValue('ok'); + + const promise = withRateLimitRetry(operation, 'file-upload'); + await jest.advanceTimersByTimeAsync(1000); + await jest.advanceTimersByTimeAsync(1000); + await promise; + + expect(logger.warn).toHaveBeenCalledWith( + `[RateLimit] file-upload 429, retry 1/${MAX_RATE_LIMIT_RETRIES} after 1000ms`, + ); + expect(logger.warn).toHaveBeenCalledWith( + `[RateLimit] file-upload 429, retry 2/${MAX_RATE_LIMIT_RETRIES} after 1000ms`, + ); + }); + + it('when getRetryDelay returns different values per call, then uses the correct delay each time', async () => { + (rateLimitService.getRetryDelay as jest.Mock).mockReturnValueOnce(500).mockReturnValueOnce(2000); + const setTimeoutSpy = jest.spyOn(global, 'setTimeout'); + + const operation = jest + .fn() + .mockRejectedValueOnce(make429Error()) + .mockRejectedValueOnce(make429Error()) + .mockResolvedValue('ok'); + + const promise = withRateLimitRetry(operation, 'upload'); + await jest.advanceTimersByTimeAsync(500); + await jest.advanceTimersByTimeAsync(2000); + await promise; + + const delayCalls = setTimeoutSpy.mock.calls + .filter((args) => typeof args[1] === 'number' && args[1] > 0) + .map((args) => args[1]); + expect(delayCalls).toEqual([500, 2000]); + }); + + it('when operation returns a typed result, then preserves the type', async () => { + const operation = jest.fn().mockResolvedValue({ id: 1, name: 'test' }); + + const promise = withRateLimitRetry(operation, 'typed'); + await jest.advanceTimersByTimeAsync(0); + const result = await promise; + + expect(result).toEqual({ id: 1, name: 'test' }); + }); +}); diff --git a/src/services/common/rate-limit/rate-limit.retry.ts b/src/services/common/rate-limit/rate-limit.retry.ts new file mode 100644 index 000000000..6cf4791e7 --- /dev/null +++ b/src/services/common/rate-limit/rate-limit.retry.ts @@ -0,0 +1,38 @@ +import { logger } from '@internxt-mobile/services/common'; +import { HTTP_TOO_MANY_REQUESTS, MAX_RATE_LIMIT_RETRIES, rateLimitService } from './rate-limit.service'; + +/** + * Wraps an async operation with rate-limit-aware retry logic. + * On 429, waits using rateLimitService delay and retries without consuming caller retries. + * Returns the result or throws the original error if retries are exhausted. + */ +export const withRateLimitRetry = async ( + operation: () => Promise, + context: string, + endpointKey?: string, +): Promise => { + let rateLimitRetries = 0; + + while (rateLimitRetries <= MAX_RATE_LIMIT_RETRIES) { + try { + return await operation(); + } catch (err) { + const errorStatus = (err as { status?: number }).status; + const isRateLimited = errorStatus === HTTP_TOO_MANY_REQUESTS; + const canRetry = isRateLimited && rateLimitRetries < MAX_RATE_LIMIT_RETRIES; + + if (!canRetry) throw err; + + rateLimitRetries++; + const delay = rateLimitService.getRetryDelay(undefined, endpointKey); + logger.warn( + `[RateLimit] ${context} 429, retry ${rateLimitRetries}/${MAX_RATE_LIMIT_RETRIES} after ${delay}ms`, + ); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + // Safety net: This should never be reached due to the loop logic above. + // If somehow the loop exits without returning or throwing, we throw an explicit error. + throw new Error(`[RateLimit] ${context} exhausted retries`); +}; diff --git a/src/services/common/rate-limit/rate-limit.service.spec.ts b/src/services/common/rate-limit/rate-limit.service.spec.ts new file mode 100644 index 000000000..426601953 --- /dev/null +++ b/src/services/common/rate-limit/rate-limit.service.spec.ts @@ -0,0 +1,513 @@ +import { extractEndpointKey, rateLimitService } from './rate-limit.service'; + +const makeHeaders = (limit: number, remaining: number, reset: number): Record => ({ + 'x-internxt-ratelimit-limit': String(limit), + 'x-internxt-ratelimit-remaining': String(remaining), + 'x-internxt-ratelimit-reset': String(reset), +}); + +describe('extractEndpointKey', () => { + describe('when joining baseURL and url', () => { + it('when url starts with /, then joins correctly', () => { + const result = extractEndpointKey({ + baseURL: 'https://gateway.internxt.com/drive', + url: '/auth/login', + }); + expect(result).toBe('https://gateway.internxt.com/drive/auth/login'); + }); + + it('when url does NOT start with /, then adds separator', () => { + const result = extractEndpointKey({ + baseURL: 'https://gateway.internxt.com/drive', + url: 'folders/content/some-id/files', + }); + expect(result).toContain('/drive/folders/'); + }); + + it('when baseURL ends with / and url starts with /, then does not double slash', () => { + const result = extractEndpointKey({ + baseURL: 'https://gateway.internxt.com/drive/', + url: '/files/recents', + }); + expect(result).toBe('https://gateway.internxt.com/drive/files/recents'); + }); + + it('when only url is provided (direct axios), then uses full url', () => { + const result = extractEndpointKey({ + url: 'https://gateway.internxt.com/payments/display-billing', + }); + expect(result).toBe('https://gateway.internxt.com/payments/display-billing'); + }); + + it('when both are empty, then returns unknown', () => { + expect(extractEndpointKey({})).toBe('unknown'); + expect(extractEndpointKey({ baseURL: '', url: '' })).toBe('unknown'); + }); + }); + + describe('when normalizing UUID path segments', () => { + it('when path contains a UUID, then replaces with :id', () => { + const result = extractEndpointKey({ + baseURL: 'https://gateway.internxt.com/drive', + url: '/folders/c6fe170d-f348-63c1-7343-0633abcdef12/content', + }); + expect(result).toBe('https://gateway.internxt.com/drive/folders/:id/content'); + }); + + it('when path contains multiple UUIDs, then replaces all', () => { + const result = extractEndpointKey({ + baseURL: 'https://gateway.internxt.com/drive', + url: '/folders/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/files/11111111-2222-3333-4444-555555555555', + }); + expect(result).toBe('https://gateway.internxt.com/drive/folders/:id/files/:id'); + }); + }); + + describe('when normalizing hex ID path segments (MongoDB ObjectIDs)', () => { + it('when path has a 24-char hex id, then replaces with :id', () => { + const result = extractEndpointKey({ + baseURL: 'https://gateway.internxt.com/network', + url: '/buckets/c6fe170df34863c173430633/files', + }); + expect(result).toBe('https://gateway.internxt.com/network/buckets/:id/files'); + }); + + it('when path has multiple hex ids, then replaces all', () => { + const result = extractEndpointKey({ + baseURL: 'https://gateway.internxt.com/network', + url: '/buckets/c6fe170df34863c173430633/files/c5bf086c78a0ada492d1f/info', + }); + expect(result).toBe('https://gateway.internxt.com/network/buckets/:id/files/:id/info'); + }); + + it('when hex id starts with digits, then replaces the full segment', () => { + const result = extractEndpointKey({ + baseURL: 'https://gateway.internxt.com/network', + url: '/buckets/c6fe170df34863c173430633/files/5bf086c78a0ada492d1f/info', + }); + expect(result).toBe('https://gateway.internxt.com/network/buckets/:id/files/:id/info'); + }); + }); + + describe('when normalizing numeric path segments', () => { + it('when path has a pure numeric segment, then replaces with :id', () => { + const result = extractEndpointKey({ + baseURL: 'https://gateway.internxt.com/drive', + url: '/files/12345/info', + }); + expect(result).toBe('https://gateway.internxt.com/drive/files/:id/info'); + }); + + it('when path has mixed text and numbers, then does NOT replace', () => { + const result = extractEndpointKey({ + baseURL: 'https://gateway.internxt.com/drive', + url: '/files/recents', + }); + expect(result).toBe('https://gateway.internxt.com/drive/files/recents'); + }); + }); + + describe('when handling query parameters', () => { + it('when url has query params, then strips them', () => { + const result = extractEndpointKey({ + baseURL: 'https://gateway.internxt.com/drive', + url: '/files?limit=50&offset=0', + }); + expect(result).toBe('https://gateway.internxt.com/drive/files'); + }); + }); + + describe('when handling real endpoints from logs', () => { + it('drive auth login', () => { + expect(extractEndpointKey({ baseURL: 'https://gateway.internxt.com/drive', url: '/auth/login' })).toBe( + 'https://gateway.internxt.com/drive/auth/login', + ); + }); + + it('drive users usage', () => { + expect(extractEndpointKey({ baseURL: 'https://gateway.internxt.com/drive', url: '/users/usage' })).toBe( + 'https://gateway.internxt.com/drive/users/usage', + ); + }); + + it('drive users limit', () => { + expect(extractEndpointKey({ baseURL: 'https://gateway.internxt.com/drive', url: '/users/limit' })).toBe( + 'https://gateway.internxt.com/drive/users/limit', + ); + }); + + it('drive files recents', () => { + expect(extractEndpointKey({ baseURL: 'https://gateway.internxt.com/drive', url: '/files/recents' })).toBe( + 'https://gateway.internxt.com/drive/files/recents', + ); + }); + + it('drive folders content with uuid', () => { + expect( + extractEndpointKey({ + baseURL: 'https://gateway.internxt.com/drive', + url: '/folders/content/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/files', + }), + ).toBe('https://gateway.internxt.com/drive/folders/content/:id/files'); + }); + + it('drive sharings (url without leading /)', () => { + expect(extractEndpointKey({ baseURL: 'https://gateway.internxt.com/drive', url: 'sharings/folders' })).toBe( + 'https://gateway.internxt.com/drive/sharings/folders', + ); + }); + + it('network buckets with hex ids', () => { + expect( + extractEndpointKey({ + baseURL: 'https://gateway.internxt.com/network', + url: '/buckets/c6fe170df34863c173430633/files/a73b3f5dfe500088647b6/info', + }), + ).toBe('https://gateway.internxt.com/network/buckets/:id/files/:id/info'); + }); + + it('network buckets with digit-starting hex ids', () => { + expect( + extractEndpointKey({ + baseURL: 'https://gateway.internxt.com/network', + url: '/buckets/c6fe170df34863c173430633/files/65c5bf086c78a0ada492d1f/info', + }), + ).toBe('https://gateway.internxt.com/network/buckets/:id/files/:id/info'); + }); + }); + + describe('when short hex words appear in paths', () => { + it('when a segment is a known word like "cafe", then does NOT replace', () => { + const result = extractEndpointKey({ + baseURL: 'https://example.com', + url: '/api/dead/beef', + }); + expect(result).toBe('https://example.com/api/dead/beef'); + }); + }); +}); + +describe('RateLimitService', () => { + beforeEach(() => { + jest.spyOn(console, 'log').mockImplementation(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('updateFromHeaders', () => { + it('when all rate limit headers are present, then stores state for the endpoint', () => { + const endpoint = 'update-all-headers'; + rateLimitService.updateFromHeaders(makeHeaders(200, 180, Date.now() + 60000), endpoint); + + expect(rateLimitService.shouldThrottle(endpoint)).toBe(false); + }); + + it('when limit header is missing, then does not store state', () => { + const endpoint = 'update-missing-limit'; + rateLimitService.updateFromHeaders({ 'x-internxt-ratelimit-remaining': '100', 'x-internxt-ratelimit-reset': '1000' }, endpoint); + + expect(rateLimitService.shouldThrottle(endpoint)).toBe(false); + }); + + it('when remaining header is missing, then does not store state', () => { + const endpoint = 'update-missing-remaining'; + rateLimitService.updateFromHeaders({ 'x-internxt-ratelimit-limit': '200', 'x-internxt-ratelimit-reset': '1000' }, endpoint); + + expect(rateLimitService.shouldThrottle(endpoint)).toBe(false); + }); + + it('when reset header is missing, then does not store state', () => { + const endpoint = 'update-missing-reset'; + rateLimitService.updateFromHeaders({ 'x-internxt-ratelimit-limit': '200', 'x-internxt-ratelimit-remaining': '100' }, endpoint); + + expect(rateLimitService.shouldThrottle(endpoint)).toBe(false); + }); + + it('when header values are non-numeric, then does not store state', () => { + const endpoint = 'update-non-numeric'; + rateLimitService.updateFromHeaders( + { 'x-internxt-ratelimit-limit': 'abc', 'x-internxt-ratelimit-remaining': '100', 'x-internxt-ratelimit-reset': '1000' }, + endpoint, + ); + + expect(rateLimitService.shouldThrottle(endpoint)).toBe(false); + }); + + it('when two endpoints receive headers, then states are independent', () => { + const endpointA = 'update-independent-a'; + const endpointB = 'update-independent-b'; + const futureReset = Date.now() + 60000; + + rateLimitService.updateFromHeaders(makeHeaders(200, 10, futureReset), endpointA); + rateLimitService.updateFromHeaders(makeHeaders(200, 180, futureReset), endpointB); + + expect(rateLimitService.shouldThrottle(endpointA)).toBe(true); + expect(rateLimitService.shouldThrottle(endpointB)).toBe(false); + }); + }); + + describe('shouldThrottle', () => { + it('when no state exists for the endpoint, then returns false', () => { + expect(rateLimitService.shouldThrottle('throttle-no-state')).toBe(false); + }); + + it('when remaining is above 40% of limit, then returns false', () => { + const endpoint = 'throttle-above'; + rateLimitService.updateFromHeaders(makeHeaders(200, 81, Date.now() + 60000), endpoint); + + expect(rateLimitService.shouldThrottle(endpoint)).toBe(false); + }); + + it('when remaining equals 40% of limit, then returns false', () => { + const endpoint = 'throttle-equal'; + rateLimitService.updateFromHeaders(makeHeaders(200, 80, Date.now() + 60000), endpoint); + + expect(rateLimitService.shouldThrottle(endpoint)).toBe(false); + }); + + it('when remaining is below 40% of limit, then returns true', () => { + const endpoint = 'throttle-below'; + rateLimitService.updateFromHeaders(makeHeaders(200, 79, Date.now() + 60000), endpoint); + + expect(rateLimitService.shouldThrottle(endpoint)).toBe(true); + }); + + it('when remaining is 0, then returns true', () => { + const endpoint = 'throttle-zero'; + rateLimitService.updateFromHeaders(makeHeaders(200, 0, Date.now() + 60000), endpoint); + + expect(rateLimitService.shouldThrottle(endpoint)).toBe(true); + }); + }); + + describe('waitIfNeeded', () => { + it('when no state exists, then returns immediately without sleeping', async () => { + const spy = jest.spyOn(global, 'setTimeout'); + await rateLimitService.waitIfNeeded('wait-no-state'); + + const sleepCalls = spy.mock.calls.filter((args) => typeof args[1] === 'number' && args[1] > 0); + expect(sleepCalls).toHaveLength(0); + }); + + it('when remaining is above threshold, then returns immediately', async () => { + const spy = jest.spyOn(global, 'setTimeout'); + const endpoint = 'wait-above-threshold'; + rateLimitService.updateFromHeaders(makeHeaders(200, 100, Date.now() + 60000), endpoint); + + await rateLimitService.waitIfNeeded(endpoint); + + const sleepCalls = spy.mock.calls.filter((args) => typeof args[1] === 'number' && args[1] > 0); + expect(sleepCalls).toHaveLength(0); + }); + + it('when quota is exhausted, then waits with delay capped at MAX_BACKOFF_MS', async () => { + jest.useFakeTimers(); + const sleepSpy = jest.spyOn(global, 'setTimeout'); + const endpoint = 'wait-exhausted'; + const now = Date.now(); + rateLimitService.updateFromHeaders(makeHeaders(200, 0, now + 10000), endpoint); + + const waitPromise = rateLimitService.waitIfNeeded(endpoint); + jest.advanceTimersByTime(5000); + await waitPromise; + + // timeUntilReset ≈ 10000, delay = min(10000 + 2000, 5000) = 5000 (MAX_BACKOFF_MS) + expect(sleepSpy).toHaveBeenCalledWith(expect.any(Function), 5000); + jest.useRealTimers(); + }); + + it('when quota is low, then waits with proportional delay', async () => { + jest.useFakeTimers(); + const sleepSpy = jest.spyOn(global, 'setTimeout'); + const endpoint = 'wait-low'; + const now = Date.now(); + // remaining=20, timeUntilReset≈10000 → delay = min(10000/20, 2000) = 500ms + rateLimitService.updateFromHeaders(makeHeaders(200, 20, now + 10000), endpoint); + + const waitPromise = rateLimitService.waitIfNeeded(endpoint); + jest.advanceTimersByTime(500); + await waitPromise; + + expect(sleepSpy).toHaveBeenCalledWith(expect.any(Function), 500); + jest.useRealTimers(); + }); + + it('when quota is low but proportional delay exceeds cap, then caps at MAX_THROTTLE_DELAY_MS', async () => { + jest.useFakeTimers(); + const sleepSpy = jest.spyOn(global, 'setTimeout'); + const endpoint = 'wait-low-capped'; + const now = Date.now(); + // remaining=1, timeUntilReset≈60000 → delay = min(60000/1, 2000) = 2000ms + rateLimitService.updateFromHeaders(makeHeaders(200, 1, now + 60000), endpoint); + + const waitPromise = rateLimitService.waitIfNeeded(endpoint); + jest.advanceTimersByTime(2000); + await waitPromise; + + expect(sleepSpy).toHaveBeenCalledWith(expect.any(Function), 2000); + jest.useRealTimers(); + }); + }); + + describe('getRetryDelay', () => { + it('when retry-after header is present and valid, then returns its value in ms', () => { + const delay = rateLimitService.getRetryDelay('30'); + expect(delay).toBe(30000); + }); + + it('when retry-after header is 0, then ignores it and falls back', () => { + const delay = rateLimitService.getRetryDelay('0'); + // 0 is not > 0, so falls back to BASE_BACKOFF_MS + expect(delay).toBe(3000); + }); + + it('when retry-after header is non-numeric, then ignores it and falls back', () => { + const delay = rateLimitService.getRetryDelay('abc'); + expect(delay).toBe(3000); + }); + + it('when endpoint has state with pending reset, then uses time until reset', () => { + const now = 1700000000000; + jest.spyOn(Date, 'now').mockReturnValue(now); + const endpoint = 'retry-with-state'; + // resetMs in future, timeUntilReset ≈ 3000 → delay = min(3000 + 2000, 5000) = 5000 + rateLimitService.updateFromHeaders(makeHeaders(200, 0, now + 3000), endpoint); + + const delay = rateLimitService.getRetryDelay(undefined, endpoint); + expect(delay).toBe(5000); + }); + + it('when endpoint has state with short reset, then returns proportional delay', () => { + const endpoint = 'retry-short-reset'; + const now = Date.now(); + // resetMs in future, timeUntilReset ≈ 1000 → delay = min(1000 + 2000, 5000) = 3000 + rateLimitService.updateFromHeaders(makeHeaders(200, 0, now + 1000), endpoint); + + const delay = rateLimitService.getRetryDelay(undefined, endpoint); + expect(delay).toBe(3000); + }); + + it('when no retry-after and no endpoint state, then returns BASE_BACKOFF_MS', () => { + const delay = rateLimitService.getRetryDelay(undefined, 'retry-no-state'); + expect(delay).toBe(3000); + }); + + it('when retry-after header is present, then it takes priority over endpoint state', () => { + const endpoint = 'retry-header-priority'; + rateLimitService.updateFromHeaders(makeHeaders(200, 0, Date.now() + 60000), endpoint); + + const delay = rateLimitService.getRetryDelay('2', endpoint); + expect(delay).toBe(2000); + }); + }); + + describe('parseResetValue (via updateFromHeaders + getRetryDelay)', () => { + it('when reset is epoch seconds (> 1e9), then converts to epoch ms', () => { + const now = 1770190042000; + jest.spyOn(Date, 'now').mockReturnValue(now); + const endpoint = 'parse-epoch-seconds'; + // 1770190044 is epoch seconds → resetMs = 1770190044000 + rateLimitService.updateFromHeaders(makeHeaders(200, 0, 1770190044), endpoint); + + // timeUntilReset = 1770190044000 - 1770190042000 = 2000 + // delay = min(2000 + 2000, 5000) = 4000 + expect(rateLimitService.getRetryDelay(undefined, endpoint)).toBe(4000); + }); + + it('when reset is epoch ms (> 1e12), then uses as-is', () => { + const now = 1770190042000; + jest.spyOn(Date, 'now').mockReturnValue(now); + const endpoint = 'parse-epoch-ms'; + // 1770190044000 is epoch ms → resetMs = 1770190044000 + rateLimitService.updateFromHeaders(makeHeaders(200, 0, 1770190044000), endpoint); + + // timeUntilReset = 1770190044000 - 1770190042000 = 2000 + // delay = min(2000 + 2000, 5000) = 4000 + expect(rateLimitService.getRetryDelay(undefined, endpoint)).toBe(4000); + }); + + it('when reset is microseconds remaining (> 1e6), then converts to absolute ms', () => { + const now = 1000000; + jest.spyOn(Date, 'now').mockReturnValue(now); + const endpoint = 'parse-microseconds'; + // 2000000 µs = 2000ms → resetMs = 1000000 + 2000 = 1002000 + rateLimitService.updateFromHeaders(makeHeaders(200, 0, 2000000), endpoint); + + // timeUntilReset = 1002000 - 1000000 = 2000 + // delay = min(2000 + 2000, 5000) = 4000 + expect(rateLimitService.getRetryDelay(undefined, endpoint)).toBe(4000); + }); + + it('when reset is milliseconds remaining (> 1000), then adds to Date.now()', () => { + const now = 1000000; + jest.spyOn(Date, 'now').mockReturnValue(now); + const endpoint = 'parse-milliseconds'; + // 1500ms remaining → resetMs = 1000000 + 1500 = 1001500 + rateLimitService.updateFromHeaders(makeHeaders(200, 0, 1500), endpoint); + + // timeUntilReset = 1001500 - 1000000 = 1500 + // delay = min(1500 + 2000, 5000) = 3500 + expect(rateLimitService.getRetryDelay(undefined, endpoint)).toBe(3500); + }); + + it('when reset is seconds remaining (<= 1000), then converts to ms and adds to Date.now()', () => { + const now = 1000000; + jest.spyOn(Date, 'now').mockReturnValue(now); + const endpoint = 'parse-seconds'; + // 60s → resetMs = 1000000 + 60000 = 1060000 + rateLimitService.updateFromHeaders(makeHeaders(200, 0, 60), endpoint); + + // timeUntilReset = 1060000 - 1000000 = 60000 + // delay = min(60000 + 2000, 5000) = 5000 (capped) + expect(rateLimitService.getRetryDelay(undefined, endpoint)).toBe(5000); + }); + }); + + describe('parseHeader (via updateFromHeaders)', () => { + it('when headers have string numeric values, then parses them correctly', () => { + const endpoint = 'parse-header-numeric'; + rateLimitService.updateFromHeaders(makeHeaders(1000, 500, Date.now() + 60000), endpoint); + + // remaining 500 is 50% of 1000 → above threshold → no throttle + expect(rateLimitService.shouldThrottle(endpoint)).toBe(false); + }); + + it('when a header value is a float string, then parses only the integer part', () => { + const endpoint = 'parse-header-float'; + rateLimitService.updateFromHeaders( + { 'x-internxt-ratelimit-limit': '200.5', 'x-internxt-ratelimit-remaining': '10.9', 'x-internxt-ratelimit-reset': '60.3' }, + endpoint, + ); + + // parseInt('200.5') = 200, parseInt('10.9') = 10 → 10 < 200*0.4=80 → throttle + expect(rateLimitService.shouldThrottle(endpoint)).toBe(true); + }); + + it('when a header value is empty string, then does not store state', () => { + const endpoint = 'parse-header-empty'; + rateLimitService.updateFromHeaders( + { 'x-internxt-ratelimit-limit': '', 'x-internxt-ratelimit-remaining': '100', 'x-internxt-ratelimit-reset': '1000' }, + endpoint, + ); + + expect(rateLimitService.shouldThrottle(endpoint)).toBe(false); + }); + + it('when headers are completely missing, then does not store state', () => { + const endpoint = 'parse-header-none'; + rateLimitService.updateFromHeaders({}, endpoint); + + expect(rateLimitService.shouldThrottle(endpoint)).toBe(false); + }); + + it('when limit is 0, then stores state but never throttles (0 * threshold = 0)', () => { + const endpoint = 'parse-header-zero-limit'; + rateLimitService.updateFromHeaders(makeHeaders(0, 0, Date.now() + 60000), endpoint); + + // remaining 0 < 0 * 0.4 = 0 → false (not strictly less than) + expect(rateLimitService.shouldThrottle(endpoint)).toBe(false); + }); + }); +}); diff --git a/src/services/common/rate-limit/rate-limit.service.ts b/src/services/common/rate-limit/rate-limit.service.ts new file mode 100644 index 000000000..916598349 --- /dev/null +++ b/src/services/common/rate-limit/rate-limit.service.ts @@ -0,0 +1,187 @@ +import { logger } from '@internxt-mobile/services/common'; + +interface RateLimitState { + limit: number; + remaining: number; + resetMs: number; +} + +interface EndpointConfig { + baseURL?: string; + url?: string; +} + +/** + * Individual patterns for dynamic ID path segments: + * - UUIDs: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + * - Hex IDs (12+ chars): c6fe170df34863c173430633 (MongoDB ObjectIDs, etc.) + * - Numeric IDs: 12345 + */ +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const HEX_ID_PATTERN = /^[0-9a-f]{12,}$/i; +const NUMERIC_ID_PATTERN = /^\d+$/; + +const isIdSegment = (segment: string): boolean => { + if (!segment) return false; + return UUID_PATTERN.test(segment) || HEX_ID_PATTERN.test(segment) || NUMERIC_ID_PATTERN.test(segment); +}; + +const normalizePathSegments = (pathname: string): string => { + return pathname + .split('/') + .map((segment) => (isIdSegment(segment) ? ':id' : segment)) + .join('/'); +}; + +const UNKNOWN_ENDPOINT = 'unknown'; + +export const HTTP_TOO_MANY_REQUESTS = 429; + +export const HEADER_RATELIMIT_LIMIT = 'x-internxt-ratelimit-limit'; +export const HEADER_RATELIMIT_REMAINING = 'x-internxt-ratelimit-remaining'; +export const HEADER_RATELIMIT_RESET = 'x-internxt-ratelimit-reset'; +export const HEADER_RETRY_AFTER = 'retry-after'; + +const THROTTLE_THRESHOLD = 0.4; +const MAX_THROTTLE_DELAY_MS = 2000; +export const MAX_RATE_LIMIT_RETRIES = 3; +const RETRY_BUFFER_MS = 2000; +const BASE_BACKOFF_MS = 3000; +const MAX_BACKOFF_MS = 5000; + +/** + * Extracts a normalized endpoint key from an axios config. + * Combines baseURL + url, strips query params, and replaces + * UUIDs and numeric IDs with `:id` for consistent grouping. + * + * Examples: + * { baseURL: "https://gw.internxt.com/drive", url: "/folders/abc-uuid/content" } + * → "https://gw.internxt.com/drive/folders/:id/content" + * { url: "https://gw.internxt.com/payments/display-billing" } + * → "https://gw.internxt.com/payments/display-billing" + */ +export const extractEndpointKey = (config: EndpointConfig): string => { + const base = config.baseURL ?? ''; + const path = config.url ?? ''; + if (!base && !path) return UNKNOWN_ENDPOINT; + + let fullUrl: string; + if (base.endsWith('/') && path.startsWith('/')) { + fullUrl = base + path.slice(1); + } else if (base && path && !base.endsWith('/') && !path.startsWith('/')) { + fullUrl = base + '/' + path; + } else { + fullUrl = base + path; + } + + try { + const urlObj = new URL(fullUrl); + const normalizedPath = normalizePathSegments(urlObj.pathname); + return `${urlObj.origin}${normalizedPath}`; + } catch { + const pathWithoutQuery = fullUrl.split('?')[0]; + return normalizePathSegments(pathWithoutQuery); + } +}; + +class RateLimitService { + private readonly states = new Map(); + + updateFromHeaders(headers: Record, endpointKey: string): void { + const limit = this.parseHeader(headers, HEADER_RATELIMIT_LIMIT); + const remaining = this.parseHeader(headers, HEADER_RATELIMIT_REMAINING); + const reset = this.parseHeader(headers, HEADER_RATELIMIT_RESET); + + if (limit === null || remaining === null || reset === null) return; + + this.states.set(endpointKey, { + limit, + remaining, + resetMs: this.parseResetValue(reset), + }); + } + + shouldThrottle(endpointKey: string): boolean { + const state = this.states.get(endpointKey); + if (!state) return false; + const isQuotaBelowThreshold = state.remaining < state.limit * THROTTLE_THRESHOLD; + return isQuotaBelowThreshold; + } + + async waitIfNeeded(endpointKey: string): Promise { + const state = this.states.get(endpointKey); + if (!state || !this.shouldThrottle(endpointKey)) return; + + const { remaining, resetMs } = state; + const timeUntilReset = Math.max(0, resetMs - Date.now()); + + const isQuotaExhausted = remaining <= 0 && timeUntilReset > 0; + if (isQuotaExhausted) { + const delay = Math.min(timeUntilReset + RETRY_BUFFER_MS, MAX_BACKOFF_MS); + logger.info(`[RateLimit] ${endpointKey} limit reached, waiting ${delay}ms until reset`); + await this.sleep(delay); + return; + } + + const isQuotaLow = remaining > 0 && timeUntilReset > 0; + if (isQuotaLow) { + const delay = Math.min(timeUntilReset / remaining, MAX_THROTTLE_DELAY_MS); + logger.info(`[RateLimit] ${endpointKey} throttling: ${remaining} left, waiting ${Math.round(delay)}ms`); + await this.sleep(delay); + } + } + + getRetryDelay(retryAfterHeader?: string, endpointKey?: string): number { + if (retryAfterHeader) { + const seconds = Number.parseInt(retryAfterHeader, 10); + const isValidRetryAfter = !Number.isNaN(seconds) && seconds > 0; + if (isValidRetryAfter) return seconds * 1000; + } + + const state = endpointKey ? this.states.get(endpointKey) : undefined; + if (state) { + const timeUntilReset = state.resetMs - Date.now(); + const isResetPending = timeUntilReset > 0; + if (isResetPending) return Math.min(timeUntilReset + RETRY_BUFFER_MS, MAX_BACKOFF_MS); + } + + return BASE_BACKOFF_MS; + } + + /** + * Parse reset value heuristically: + * - > 1e12: epoch milliseconds + * - > 1e9: epoch seconds + * - > 1e6: microseconds remaining (backend returns µs, e.g. 33293277 µs ≈ 33s) + * - > 1000: milliseconds remaining + * - otherwise: seconds remaining + */ + private parseResetValue(value: number): number { + const isEpochMs = value > 1e12; + if (isEpochMs) return value; + + const isEpochSeconds = value > 1e9; + if (isEpochSeconds) return value * 1000; + + const isMicroseconds = value > 1e6; + if (isMicroseconds) return Date.now() + value / 1000; + + const isMilliseconds = value > 1000; + if (isMilliseconds) return Date.now() + value; + + return Date.now() + value * 1000; + } + + private parseHeader(headers: Record, key: string): number | null { + const val = headers[key] ?? headers[key.toLowerCase()]; + if (val === undefined || val === null) return null; + const num = Number.parseInt(String(val), 10); + return Number.isNaN(num) ? null : num; + } + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} + +export const rateLimitService = new RateLimitService(); diff --git a/src/services/drive/file/uploadQueue.service.spec.ts b/src/services/drive/file/uploadQueue.service.spec.ts new file mode 100644 index 000000000..700ba4e1d --- /dev/null +++ b/src/services/drive/file/uploadQueue.service.spec.ts @@ -0,0 +1,313 @@ +import { UploadQueueService } from './uploadQueue.service'; + +jest.mock('@internxt-mobile/services/common', () => ({ + logger: { + info: jest.fn(), + error: jest.fn(), + }, +})); + +type Deferred = { + promise: Promise; + resolve: () => void; + reject: (error: Error) => void; +}; + +const createDeferred = (): Deferred => { + let resolve!: () => void; + let reject!: (error: Error) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +}; + +describe('Upload Queue Service', () => { + let sut: UploadQueueService; + + beforeEach(() => { + sut = new UploadQueueService(); + }); + + describe('When enqueueing a single job', () => { + it('when a job is enqueued, then it executes immediately', async () => { + const jobExecuted = jest.fn(); + + await sut.enqueue('batch-1', async () => { + jobExecuted(); + }); + + expect(jobExecuted).toHaveBeenCalledTimes(1); + }); + + it('when a job completes successfully, then the enqueue promise resolves', async () => { + const result = sut.enqueue('batch-1', async () => { + // no-op + }); + + await expect(result).resolves.toBeUndefined(); + }); + + it('when a job throws an error, then the enqueue promise rejects with that error', async () => { + const error = new Error('upload failed'); + + const result = sut.enqueue('batch-1', async () => { + throw error; + }); + + await expect(result).rejects.toBe(error); + }); + }); + + describe('When enqueueing multiple jobs', () => { + it('when multiple jobs are enqueued, then they execute in FIFO order', async () => { + const executionOrder: string[] = []; + + const promise1 = sut.enqueue('batch-1', async () => { + executionOrder.push('first'); + }); + + const promise2 = sut.enqueue('batch-2', async () => { + executionOrder.push('second'); + }); + + const promise3 = sut.enqueue('batch-3', async () => { + executionOrder.push('third'); + }); + + await Promise.all([promise1, promise2, promise3]); + + expect(executionOrder).toEqual(['first', 'second', 'third']); + }); + + it('when a job is running, then subsequent jobs wait until it completes', async () => { + const deferred = createDeferred(); + const executionOrder: string[] = []; + + const promise1 = sut.enqueue('batch-1', async () => { + executionOrder.push('first-start'); + await deferred.promise; + executionOrder.push('first-end'); + }); + + const promise2 = sut.enqueue('batch-2', async () => { + executionOrder.push('second'); + }); + + // At this point, first job is running but blocked, second is waiting + // Give microtasks time to settle + await Promise.resolve(); + + expect(executionOrder).toEqual(['first-start']); + expect(sut.isBusy()).toBe(true); + + // Unblock the first job + deferred.resolve(); + await Promise.all([promise1, promise2]); + + expect(executionOrder).toEqual(['first-start', 'first-end', 'second']); + }); + + it('when a job fails, then the next job still executes', async () => { + const error = new Error('batch-1 failed'); + const secondJobExecuted = jest.fn(); + + const promise1 = sut.enqueue('batch-1', async () => { + throw error; + }); + + const promise2 = sut.enqueue('batch-2', async () => { + secondJobExecuted(); + }); + + await expect(promise1).rejects.toBe(error); + await promise2; + + expect(secondJobExecuted).toHaveBeenCalledTimes(1); + }); + + it('when multiple jobs fail, then all subsequent jobs still execute', async () => { + const executionOrder: string[] = []; + + const promise1 = sut.enqueue('batch-1', async () => { + executionOrder.push('first'); + throw new Error('fail-1'); + }); + + const promise2 = sut.enqueue('batch-2', async () => { + executionOrder.push('second'); + throw new Error('fail-2'); + }); + + const promise3 = sut.enqueue('batch-3', async () => { + executionOrder.push('third'); + }); + + await promise1.catch(() => undefined); + await promise2.catch(() => undefined); + await promise3; + + expect(executionOrder).toEqual(['first', 'second', 'third']); + }); + }); + + describe('When checking queue state', () => { + it('when no jobs are running, then isBusy returns false', () => { + expect(sut.isBusy()).toBe(false); + }); + + it('when a job is running, then isBusy returns true', () => { + const deferred = createDeferred(); + + sut.enqueue('batch-1', () => deferred.promise); + + expect(sut.isBusy()).toBe(true); + + deferred.resolve(); + }); + + it('when all jobs complete, then isBusy returns false', async () => { + await sut.enqueue('batch-1', async () => { return; }); + + expect(sut.isBusy()).toBe(false); + }); + + it('when no jobs are pending, then getPendingCount returns 0', () => { + expect(sut.getPendingCount()).toBe(0); + }); + + it('when jobs are waiting behind an active job, then getPendingCount returns the correct count', () => { + const deferred = createDeferred(); + + sut.enqueue('batch-1', () => deferred.promise); + sut.enqueue('batch-2', async () => { return; }); + sut.enqueue('batch-3', async () => { return; }); + + // batch-1 is active, batch-2 and batch-3 are pending + expect(sut.getPendingCount()).toBe(2); + + deferred.resolve(); + }); + + it('when all jobs complete, then getPendingCount returns 0', async () => { + const promise1 = sut.enqueue('batch-1', async () => { return; }); + const promise2 = sut.enqueue('batch-2', async () => { return; }); + + await Promise.all([promise1, promise2]); + + expect(sut.getPendingCount()).toBe(0); + }); + }); + + describe('When clearing the pending queue', () => { + it('when clearPending is called, then waiting jobs are removed from the queue', () => { + const deferred = createDeferred(); + + sut.enqueue('batch-1', () => deferred.promise); + sut.enqueue('batch-2', async () => { return; }); + sut.enqueue('batch-3', async () => { return; }); + + expect(sut.getPendingCount()).toBe(2); + + sut.clearPending(); + + expect(sut.getPendingCount()).toBe(0); + + deferred.resolve(); + }); + + it('when clearPending is called, then the active job continues running to completion', async () => { + const deferred = createDeferred(); + const activeJobCompleted = jest.fn(); + + const promise1 = sut.enqueue('batch-1', async () => { + await deferred.promise; + activeJobCompleted(); + }); + + sut.enqueue('batch-2', async () => { return; }); + + sut.clearPending(); + + // Active job should still be running + expect(sut.isBusy()).toBe(true); + + deferred.resolve(); + await promise1; + + expect(activeJobCompleted).toHaveBeenCalledTimes(1); + }); + + it('when clearPending is called, then cleared jobs never execute', async () => { + const deferred = createDeferred(); + const clearedJobExecuted = jest.fn(); + + const promise1 = sut.enqueue('batch-1', () => deferred.promise); + + // These will be cleared + sut.enqueue('batch-2', async () => { + clearedJobExecuted(); + }); + sut.enqueue('batch-3', async () => { + clearedJobExecuted(); + }); + + sut.clearPending(); + + deferred.resolve(); + await promise1; + + // Give microtasks time to settle in case something would try to run + await new Promise((r) => setTimeout(r, 50)); + + expect(clearedJobExecuted).not.toHaveBeenCalled(); + }); + }); + + describe('When handling concurrent enqueue calls', () => { + it('when two jobs are enqueued simultaneously, then both complete and resolve independently', async () => { + const deferred1 = createDeferred(); + const deferred2 = createDeferred(); + + let result1Resolved = false; + let result2Resolved = false; + + const promise1 = sut.enqueue('batch-1', () => deferred1.promise).then(() => { + result1Resolved = true; + }); + + const promise2 = sut.enqueue('batch-2', () => deferred2.promise).then(() => { + result2Resolved = true; + }); + + // Complete first job + deferred1.resolve(); + await promise1; + + expect(result1Resolved).toBe(true); + expect(result2Resolved).toBe(false); + + // Complete second job + deferred2.resolve(); + await promise2; + + expect(result2Resolved).toBe(true); + }); + + it('when a failing job is followed by a succeeding job, then each promise settles correctly', async () => { + const error = new Error('first failed'); + + const promise1 = sut.enqueue('batch-1', async () => { + throw error; + }); + + const promise2 = sut.enqueue('batch-2', async () => { + // success + }); + + await expect(promise1).rejects.toBe(error); + await expect(promise2).resolves.toBeUndefined(); + }); + }); +}); diff --git a/src/services/drive/file/uploadQueue.service.ts b/src/services/drive/file/uploadQueue.service.ts new file mode 100644 index 000000000..1b300ef26 --- /dev/null +++ b/src/services/drive/file/uploadQueue.service.ts @@ -0,0 +1,92 @@ +import { logger } from '@internxt-mobile/services/common'; + +type BatchJob = () => Promise; + +interface QueueEntry { + job: BatchJob; + resolve: () => void; + reject: (error: unknown) => void; + batchId: string; +} + +export class UploadQueueService { + private queue: QueueEntry[] = []; + private isProcessing = false; + + /** + * Enqueue a batch upload job. Jobs execute serially — each batch + * waits for the previous one to fully complete before starting. + * This ensures duplicate checks see all previously uploaded files. + */ + enqueue(batchId: string, job: BatchJob): Promise { + logger.info( + `[UploadQueue] enqueue called - batchId: ${batchId}, isProcessing: ${this.isProcessing}, pendingCount: ${this.queue.length}`, + ); + return new Promise((resolve, reject) => { + this.queue.push({ job, resolve, reject, batchId }); + logger.info(`[UploadQueue] Entry added to queue, new queue length: ${this.queue.length}`); + this.processNext(); + }); + } + + private async processNext(): Promise { + logger.info( + `[UploadQueue] processNext called - isProcessing: ${this.isProcessing}, queueLength: ${this.queue.length}`, + ); + + if (this.isProcessing) { + logger.info('[UploadQueue] Already processing, skipping'); + return; + } + + const entry = this.queue.shift(); + if (!entry) { + logger.info('[UploadQueue] Queue empty, nothing to process'); + return; + } + + this.isProcessing = true; + logger.info( + `[UploadQueue] Starting batch: ${entry.batchId}, remaining in queue: ${this.queue.length}`, + ); + + try { + await entry.job(); + logger.info(`[UploadQueue] Batch ${entry.batchId} job completed successfully, calling resolve`); + entry.resolve(); + } catch (error) { + logger.error( + `[UploadQueue] Batch ${entry.batchId} job threw error:`, + JSON.stringify(error), + (error as Error)?.message, + (error as Error)?.stack, + ); + entry.reject(error); + } finally { + logger.info( + `[UploadQueue] Finally block for batch: ${entry.batchId}, setting isProcessing=false, queueLength: ${this.queue.length}`, + ); + this.isProcessing = false; + this.processNext(); + } + } + + getPendingCount(): number { + return this.queue.length; + } + + isBusy(): boolean { + return this.isProcessing; + } + + /** + * Clear the pending queue (does NOT abort the active batch). + * Used on logout or critical error recovery. + */ + clearPending(): void { + this.queue = []; + logger.info('[UploadQueue] Pending queue cleared'); + } +} + +export const uploadQueueService = new UploadQueueService(); diff --git a/src/store/slices/app/index.ts b/src/store/slices/app/index.ts index 96c76520d..025b687eb 100644 --- a/src/store/slices/app/index.ts +++ b/src/store/slices/app/index.ts @@ -5,6 +5,7 @@ import drive from '@internxt-mobile/services/drive'; import { BiometricAccessType } from '@internxt-mobile/types/app'; import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; import strings from 'assets/lang/strings'; +import * as Localization from 'expo-localization'; import languageService from 'src/services/LanguageService'; import notificationsService from 'src/services/NotificationsService'; import { Language, NotificationType } from 'src/types'; @@ -22,6 +23,7 @@ export interface AppState { screenLocked: boolean; lastScreenLock: number | null; initialScreenLocked: boolean; + language: Language; } const initialState: AppState = { @@ -32,13 +34,34 @@ const initialState: AppState = { screenLocked: false, lastScreenLock: null, initialScreenLocked: false, + language: Language.English, }; +const initializeLanguageThunk = createAsyncThunk( + 'app/initializeLanguage', + async () => { + const savedLanguage = await asyncStorageService.getItem('language' as any); + + if (savedLanguage) { + strings.setLanguage(savedLanguage); + return savedLanguage as Language; + } else { + const deviceLocale = Localization.getLocales()[0]?.languageCode; + const detectedLanguage = deviceLocale === 'es' ? Language.Spanish : Language.English; + strings.setLanguage(detectedLanguage); + await asyncStorageService.saveItem('language' as any, detectedLanguage); + return detectedLanguage; + } + }, +); + const initializeThunk = createAsyncThunk( 'app/initialize', async (_, { dispatch }) => { await drive.start(); + await dispatch(initializeLanguageThunk()).unwrap(); + dispatch(authThunks.initializeThunk()); dispatch(driveThunks.initializeThunk()); dispatch(paymentsThunks.initializeThunk()); @@ -46,10 +69,11 @@ const initializeThunk = createAsyncThunk( }, ); -const changeLanguageThunk = createAsyncThunk( +const changeLanguageThunk = createAsyncThunk( 'app/changeLanguage', async (language) => { - return languageService.setLanguage(language); + await languageService.setLanguage(language); + return language; }, ); @@ -125,9 +149,17 @@ export const appSlice = createSlice({ state.isInitializing = false; }); - builder.addCase(changeLanguageThunk.rejected, () => { - notificationsService.show({ type: NotificationType.Error, text1: strings.errors.changeLanguage }); + builder.addCase(initializeLanguageThunk.fulfilled, (state, action) => { + state.language = action.payload; }); + + builder + .addCase(changeLanguageThunk.fulfilled, (state, action) => { + state.language = action.payload; + }) + .addCase(changeLanguageThunk.rejected, () => { + notificationsService.show({ type: NotificationType.Error, text1: strings.errors.changeLanguage }); + }); }, }); @@ -135,6 +167,7 @@ export const appActions = appSlice.actions; export const appThunks = { initializeThunk, + initializeLanguageThunk, changeLanguageThunk, initializeUserPreferencesThunk, lockScreenIfNeededThunk, diff --git a/src/store/slices/drive/index.ts b/src/store/slices/drive/index.ts index b06699fbd..7acdb83a5 100644 --- a/src/store/slices/drive/index.ts +++ b/src/store/slices/drive/index.ts @@ -417,6 +417,10 @@ export const driveSlice = createSlice({ clearUploadedFiles(state) { state.uploadingFiles = []; }, + clearBatchFiles(state, action: PayloadAction) { + const batchFileIds = new Set(action.payload); + state.uploadingFiles = state.uploadingFiles.filter((file) => !batchFileIds.has(file.id)); + }, uploadFileFinished(state) { state.isLoading = false; state.isUploading = false; diff --git a/src/useCases/drive/getShareLink.ts b/src/useCases/drive/getShareLink.ts index 7ae1744dd..d5308932b 100644 --- a/src/useCases/drive/getShareLink.ts +++ b/src/useCases/drive/getShareLink.ts @@ -9,7 +9,6 @@ import { aes } from '@internxt/lib'; import strings from 'assets/lang/strings'; import { setStringAsync } from 'expo-clipboard'; import { randomBytes } from 'react-native-crypto'; -import { Network } from 'src/lib/network'; /** * Gets an already generated share link @@ -100,10 +99,9 @@ export const onSharedLinksUpdated = (callback: () => void) => { */ export const generateShareLink = async ({ itemId, - fileId, + displayCopyNotification, type, - plainPassword, }: { itemId: string; fileId?: string | null; @@ -115,14 +113,10 @@ export const generateShareLink = async ({ if (!credentials?.user) throw new Error('User credentials not found'); - const { bucket, mnemonic, email, userId } = credentials.user; - const network = new Network(email, userId, mnemonic); + const { mnemonic } = credentials.user; // Random code for the file const plainCode = randomBytes(32).toString('hex'); - // 1. Get the file token - const itemToken = await network.createFileToken(bucket, fileId as string, 'PULL'); - // 2. Create an encrypted code for the file const encryptedCode = aes.encrypt(plainCode, mnemonic); diff --git a/yarn.lock b/yarn.lock index 3cce1b28e..b4621d5a5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1532,10 +1532,10 @@ dependencies: buffer "^6.0.3" -"@internxt/sdk@1.11.25": - version "1.11.25" - resolved "https://npm.pkg.github.com/download/@internxt/sdk/1.11.25/b606f1db93716e6a9d2356fdab2fbf6024431e1f#b606f1db93716e6a9d2356fdab2fbf6024431e1f" - integrity sha512-mVJIDabOjN777ZaUTgaVWZLje3/0mimhJDCThunQ6smKMrei/2UPxBACa9w3k6AKtLE6eUwgKu8/pTz35T5r9Q== +"@internxt/sdk@1.12.3": + version "1.12.3" + resolved "https://npm.pkg.github.com/download/@internxt/sdk/1.12.3/a8e8b92fb74366ede86d6239307771c7c3c3100e#a8e8b92fb74366ede86d6239307771c7c3c3100e" + integrity sha512-rrt2tEUFGAvjD9yJ+kGSVvrSnZiUushqwWh7Bee+cDIFd3c1NCAtCmfhTBZjX/nvMfDqHWA1xeEYgP53qe8tpA== dependencies: axios "1.13.2" uuid "11.1.0" @@ -4558,6 +4558,11 @@ expo-navigation-bar@~5.0.10: debug "^4.3.2" react-native-is-edge-to-edge "^1.2.1" +expo-screen-orientation@^9.0.8: + version "9.0.8" + resolved "https://registry.yarnpkg.com/expo-screen-orientation/-/expo-screen-orientation-9.0.8.tgz#15b8f85bd4d183831943fc5a21e3020e17432867" + integrity sha512-qRoPi3E893o3vQHT4h1NKo51+7g2hjRSbDeg1fsSo/u2pOW5s6FCeoacLvD+xofOP33cH2MkE4ua54aWWO7Icw== + expo-secure-store@~15.0.8: version "15.0.8" resolved "https://registry.yarnpkg.com/expo-secure-store/-/expo-secure-store-15.0.8.tgz#678065599bb76061b5a85b15b9426bf7a11089ae"