UNPKG

react-native-cloud-storage

Version:

☁️ Save to & read from iCloud and Google Drive using React Native

489 lines (430 loc) 17 kB
import { NativeCloudStorageErrorCode, NativeStorage, type NativeStorageFileStat, type NativeStorageScope, } from '../../types/native'; import CloudStorageError from '../../utils/cloud-storage-error'; import { MimeTypes, type GoogleDriveFile, type GoogleDriveFileSpace } from './types'; import GoogleDriveApiClient, { GoogleDriveHttpError } from './client'; import { type CloudStorageProviderOptions, type DeepRequired } from '../../types/main'; /** * A JavaScript-based implementation of the Google Drive API that implements the cloud storage interface. */ export default class GoogleDrive implements NativeStorage { private drive: GoogleDriveApiClient; private options: DeepRequired<CloudStorageProviderOptions['googledrive']>; constructor(options: DeepRequired<CloudStorageProviderOptions['googledrive']>) { this.options = options; this.drive = new GoogleDriveApiClient(options); return new Proxy(this, { // before calling any function, check if the access token is set get(target: GoogleDrive, property: keyof GoogleDrive) { const allowedFunctions = ['isCloudAvailable']; if (typeof target[property] === 'function' && !allowedFunctions.includes(property.toString())) { const { accessToken } = options; if (!accessToken?.length) { throw new CloudStorageError( `Google Drive access token is not set, cannot call function ${property.toString()}`, NativeCloudStorageErrorCode.ACCESS_TOKEN_MISSING ); } } return target[property]; }, }); } public isCloudAvailable: () => Promise<boolean> = async () => { const { accessToken } = this.options; return !!accessToken?.length; }; private getRootDirectory(scope: NativeStorageScope): GoogleDriveFileSpace { switch (scope) { case 'documents': { return 'drive'; } case 'documents_legacy': { return 'drive'; } case 'app_data': { return 'appDataFolder'; } } } private isRootPath(path: string): boolean { return path === '' || path === '/'; } private resolvePathToDirectories(path: string): { directories: string[]; filename: string; } { if (path.startsWith('/')) path = path.slice(1); if (path.endsWith('/')) path = path.slice(0, -1); const directories = path.split('/'); const actualFilename = directories.pop() ?? ''; return { directories, filename: actualFilename }; } private escapeDriveQueryValue(value: string): string { return value.replaceAll('\\', String.raw`\\`).replaceAll("'", String.raw`\'`); } private async getQueryRootParentId(scope: NativeStorageScope): Promise<string> { if (scope === 'app_data') { return this.getRootDirectory(scope); } return this.getRootDirectoryId(scope); } private async findDirectoryByNameAndParent( name: string, parentId: string, scope: NativeStorageScope ): Promise<GoogleDriveFile[]> { const escapedName = this.escapeDriveQueryValue(name); const escapedParentId = this.escapeDriveQueryValue(parentId); const query = `name = '${escapedName}' and '${escapedParentId}' in parents and mimeType = '${MimeTypes.FOLDER}' and trashed = false`; return this.drive.listFiles(this.getRootDirectory(scope), query); } private async findFilesByNameAndParent( name: string, parentId: string, scope: NativeStorageScope ): Promise<GoogleDriveFile[]> { const escapedName = this.escapeDriveQueryValue(name); const escapedParentId = this.escapeDriveQueryValue(parentId); const query = `name = '${escapedName}' and '${escapedParentId}' in parents and trashed = false`; return this.drive.listFiles(this.getRootDirectory(scope), query); } private async findFilesByParent(parentId: string, scope: NativeStorageScope): Promise<GoogleDriveFile[]> { const escapedParentId = this.escapeDriveQueryValue(parentId); const query = `'${escapedParentId}' in parents and trashed = false`; return this.drive.listFiles(this.getRootDirectory(scope), query); } private async findParentDirectoryId(directoryTree: string[], scope: NativeStorageScope): Promise<string | null> { let parentDirectoryId = await this.getQueryRootParentId(scope); for (const directoryName of directoryTree) { const directories = await this.findDirectoryByNameAndParent(directoryName, parentDirectoryId, scope); if (directories.length === 0) { return null; } parentDirectoryId = directories[0]!.id; } return parentDirectoryId; } /** * Gets the Google Drive ID of the root directory for the given scope. * @param scope The scope to get the root directory for. * @returns A promise that resolves to the ID of the root directory. */ private async getRootDirectoryId(scope: NativeStorageScope): Promise<string> { if (scope !== 'app_data') { return 'root'; } const files = await this.drive.listFiles(this.getRootDirectory(scope)); for (const file of files) { const parentId = file.parents?.[0]; if (parentId && !files.some((candidate) => candidate.id === parentId)) { return parentId; } } return this.getRootDirectory(scope); } private checkIfMultipleFilesWithSameName(path: string, files: GoogleDriveFile[]) { const { strictFilenames } = this.options; if (files.length <= 1) return; if (strictFilenames) { throw new CloudStorageError( `Multiple files with the same name found at path ${path}: ${files.map((f) => f.id).join(', ')}`, NativeCloudStorageErrorCode.MULTIPLE_FILES_SAME_NAME ); } } private async getFileId( path: string, scope: NativeStorageScope, throwIf: 'directory' | 'file' | false = false ): Promise<string> { try { if (this.isRootPath(path)) { if (throwIf === 'directory') { throw new CloudStorageError(`Path ${path} is a directory`, NativeCloudStorageErrorCode.PATH_IS_DIRECTORY); } const rootDirectoryId = await this.getRootDirectoryId(scope); if (scope !== 'app_data') { await this.drive.getFile(rootDirectoryId); } return rootDirectoryId; } const { directories, filename } = this.resolvePathToDirectories(path); const parentDirectoryId = await this.findParentDirectoryId(directories, scope); if (parentDirectoryId === null) { throw new CloudStorageError(`File not found`, NativeCloudStorageErrorCode.FILE_NOT_FOUND); } const files = await this.findFilesByNameAndParent(filename, parentDirectoryId, scope); this.checkIfMultipleFilesWithSameName(path, files); const file = files[0]; if (!file) throw new CloudStorageError(`File not found`, NativeCloudStorageErrorCode.FILE_NOT_FOUND); if (file.mimeType === MimeTypes.FOLDER && throwIf === 'directory') { throw new CloudStorageError(`Path ${path} is a directory`, NativeCloudStorageErrorCode.PATH_IS_DIRECTORY); } else if (file.mimeType !== MimeTypes.FOLDER && throwIf === 'file') { throw new CloudStorageError(`Path ${path} is a file`, NativeCloudStorageErrorCode.FILE_NOT_FOUND); } return file.id; } catch (error: unknown) { if (error instanceof GoogleDriveHttpError && error.json?.error?.status === 'UNAUTHENTICATED') { throw new CloudStorageError( `Could not authenticate with Google Drive`, NativeCloudStorageErrorCode.AUTHENTICATION_FAILED, error.json ); } else { if (error instanceof CloudStorageError) throw error; throw new CloudStorageError( `Could not get file id for path ${path}`, NativeCloudStorageErrorCode.UNKNOWN, error ); } } } async fileExists(path: string, scope: NativeStorageScope): Promise<boolean> { try { await this.getFileId(path, scope); return true; } catch (error: unknown) { if (error instanceof CloudStorageError && error.code === NativeCloudStorageErrorCode.FILE_NOT_FOUND) return false; else throw error; } } async appendToFile(path: string, data: string, scope: NativeStorageScope): Promise<void> { let fileId: string | undefined; let previousContent = ''; try { fileId = await this.getFileId(path, scope, 'directory'); previousContent = await this.drive.getFileText(fileId); } catch (error: unknown) { if (error instanceof CloudStorageError && error.code === NativeCloudStorageErrorCode.FILE_NOT_FOUND) { /* do nothing, simply create the file */ } else { throw error; } } if (fileId) { await this.drive.updateFile(fileId, { body: previousContent + data, mimeType: MimeTypes.TEXT, }); } else { const { directories, filename } = this.resolvePathToDirectories(path); const parentDirectoryId = await this.findParentDirectoryId(directories, scope); if (parentDirectoryId === null) { throw new CloudStorageError(`Directory not found`, NativeCloudStorageErrorCode.DIRECTORY_NOT_FOUND); } await this.drive.createFile( { name: filename, parents: [parentDirectoryId], }, { body: data, mimeType: MimeTypes.TEXT, } ); } } async createFile(path: string, data: string, scope: NativeStorageScope, overwrite: boolean): Promise<void> { let fileId: string | undefined; if (overwrite) { try { fileId = await this.getFileId(path, scope, 'directory'); } catch (error: unknown) { if (error instanceof CloudStorageError && error.code === NativeCloudStorageErrorCode.FILE_NOT_FOUND) { /* do nothing, simply create the file */ } else { throw error; } } } else { try { await this.getFileId(path, scope, 'directory'); throw new CloudStorageError(`File ${path} already exists`, NativeCloudStorageErrorCode.FILE_ALREADY_EXISTS); } catch (error: unknown) { if (error instanceof CloudStorageError && error.code === NativeCloudStorageErrorCode.FILE_NOT_FOUND) { /* do nothing, simply create the file */ } else { throw error; } } } if (fileId) { await this.drive.updateFile(fileId, { body: data, mimeType: MimeTypes.TEXT, }); } else { const { directories, filename } = this.resolvePathToDirectories(path); const parentDirectoryId = await this.findParentDirectoryId(directories, scope); if (parentDirectoryId === null) { throw new CloudStorageError(`Directory not found`, NativeCloudStorageErrorCode.DIRECTORY_NOT_FOUND); } await this.drive.createFile( { name: filename, parents: [parentDirectoryId], }, { body: data, mimeType: MimeTypes.TEXT, } ); } } async listFiles(path: string, scope: NativeStorageScope): Promise<string[]> { const parentDirectoryId = this.isRootPath(path) ? await this.getQueryRootParentId(scope) : await this.getFileId(path, scope); const files = await this.findFilesByParent(parentDirectoryId, scope); return [...new Set(files.map((f) => f.name))]; } async createDirectory(path: string, scope: NativeStorageScope): Promise<void> { if (this.isRootPath(path)) { throw new CloudStorageError(`Directory ${path} already exists`, NativeCloudStorageErrorCode.FILE_ALREADY_EXISTS); } try { await this.getFileId(path, scope); throw new CloudStorageError(`File ${path} already exists`, NativeCloudStorageErrorCode.FILE_ALREADY_EXISTS); } catch (error: unknown) { if (error instanceof CloudStorageError && error.code === NativeCloudStorageErrorCode.FILE_NOT_FOUND) { /* do nothing, simply create the directory */ } else if (error instanceof CloudStorageError && error.code === NativeCloudStorageErrorCode.PATH_IS_DIRECTORY) { throw new CloudStorageError( `Directory ${path} already exists`, NativeCloudStorageErrorCode.FILE_ALREADY_EXISTS ); } else { throw error; } } const { directories, filename } = this.resolvePathToDirectories(path); const parentDirectoryId = await this.findParentDirectoryId(directories, scope); if (parentDirectoryId === null) { throw new CloudStorageError(`Directory not found`, NativeCloudStorageErrorCode.DIRECTORY_NOT_FOUND); } await this.drive.createDirectory({ name: filename, parents: [parentDirectoryId], }); } async readFile(path: string, scope: NativeStorageScope): Promise<string> { const fileId = await this.getFileId(path, scope, 'directory'); const content = await this.drive.getFileText(fileId); return content; } async triggerSync(_path: string, _scope: NativeStorageScope): Promise<void> { // Triggering file synchronization in Google Drive is not necessary / possible, as they need to be downloaded on every read operation via the API anyway return; } async deleteFile(path: string, scope: NativeStorageScope): Promise<void> { // if trying to pass a directory, throw an error const fileId = await this.getFileId(path, scope, 'directory'); await this.drive.deleteFile(fileId); } async deleteDirectory(path: string, recursive: boolean, scope: NativeStorageScope): Promise<void> { // if trying to pass a file, throw an error const fileId = await this.getFileId(path, scope, 'file'); if (!recursive) { // check if the directory is empty const filesInDirectory = await this.findFilesByParent(fileId, scope); if (filesInDirectory.length > 0) { throw new CloudStorageError( `Directory ${path} is not empty`, NativeCloudStorageErrorCode.DELETE_ERROR, filesInDirectory ); } } await this.drive.deleteFile(fileId); } async statFile(path: string, scope: NativeStorageScope): Promise<NativeStorageFileStat> { const fileId = await this.getFileId(path, scope, false); const file = await this.drive.getFile(fileId!); return { size: file.size ?? 0, birthtimeMs: new Date(file.createdTime!).getTime(), mtimeMs: new Date(file.modifiedTime!).getTime(), isDirectory: file.mimeType === MimeTypes.FOLDER, isFile: file.mimeType !== MimeTypes.FOLDER, }; } async downloadFile(remotePath: string, localPath: string, scope: NativeStorageScope): Promise<void> { const fileId = await this.getFileId(remotePath, scope, 'directory'); try { await this.drive.downloadFile(fileId, localPath); } catch (error: unknown) { if (error instanceof CloudStorageError) throw error; throw new CloudStorageError( `Could not download file ${remotePath} to ${localPath}`, NativeCloudStorageErrorCode.UNKNOWN, error ); } } async uploadFile( remotePath: string, localPath: string, mimeType: string, scope: NativeStorageScope, overwrite: boolean ): Promise<void> { let fileId: string | undefined; if (overwrite) { try { fileId = await this.getFileId(remotePath, scope, 'directory'); } catch (error: unknown) { if (error instanceof CloudStorageError && error.code === NativeCloudStorageErrorCode.FILE_NOT_FOUND) { /* File doesn't exist -> we'll create it below */ } else { throw error; } } } else { try { await this.getFileId(remotePath, scope, 'directory'); throw new CloudStorageError( `File ${remotePath} already exists`, NativeCloudStorageErrorCode.FILE_ALREADY_EXISTS ); } catch (error: unknown) { if (error instanceof CloudStorageError && error.code === NativeCloudStorageErrorCode.FILE_NOT_FOUND) { /* not found -> ok, we'll create */ } else if (error instanceof CloudStorageError) { throw error; } else { throw error; } } } if (fileId) { // Overwrite existing file await this.drive.updateFile(fileId, { mimeType, localPath, }); } else { // Need to create a new file first const { directories, filename } = this.resolvePathToDirectories(remotePath); const parentDirectoryId = await this.findParentDirectoryId(directories, scope); if (parentDirectoryId === null) { throw new CloudStorageError(`Directory not found`, NativeCloudStorageErrorCode.DIRECTORY_NOT_FOUND); } await this.drive.createFile( { name: filename, parents: [parentDirectoryId], }, { mimeType, localPath, } ); } } }