import { getEndpoint } from 'utils/common/apiUtil'; import localForage from 'utils/storage/localForage'; import { getToken } from 'utils/common/key'; import { DataStream, EncryptionResult, MetadataObject, } from './upload/uploadService'; import { Collection } from './collectionService'; import HTTPService from './HTTPService'; import { logError } from 'utils/sentry'; import { appendPhotoSwipeProps, decryptFile, mergeMetadata, sortFiles, } from 'utils/file'; import CryptoWorker from 'utils/crypto'; const ENDPOINT = getEndpoint(); const FILES = 'files'; export const MIN_EDITED_CREATION_TIME = '1800-01-01T00:00:00.000Z'; export interface fileAttribute { encryptedData?: DataStream | Uint8Array; objectKey?: string; decryptionHeader: string; } export enum FILE_TYPE { IMAGE, VIDEO, LIVE_PHOTO, OTHERS, } /* Build error occurred ReferenceError: Cannot access 'FILE_TYPE' before initialization when it was placed in readFileService */ // list of format that were missed by type-detection for some files. export const FORMAT_MISSED_BY_FILE_TYPE_LIB = [ { fileType: FILE_TYPE.IMAGE, exactType: 'jpeg' }, { fileType: FILE_TYPE.IMAGE, exactType: 'jpg' }, { fileType: FILE_TYPE.VIDEO, exactType: 'webm' }, ]; export enum VISIBILITY_STATE { VISIBLE, ARCHIVED, } export interface MagicMetadataCore { version: number; count: number; header: string; data: Record; } export interface EncryptedMagicMetadataCore extends Omit { data: string; } export interface MagicMetadataProps { visibility?: VISIBILITY_STATE; } export interface MagicMetadata extends Omit { data: MagicMetadataProps; } export interface PublicMagicMetadataProps { editedTime?: number; } export interface PublicMagicMetadata extends Omit { data: PublicMagicMetadataProps; } export interface File { id: number; collectionID: number; ownerID: number; file: fileAttribute; thumbnail: fileAttribute; metadata: MetadataObject; magicMetadata: MagicMetadata; pubMagicMetadata: PublicMagicMetadata; encryptedKey: string; keyDecryptionNonce: string; key: string; src: string; msrc: string; html: string; w: number; h: number; isDeleted: boolean; isTrashed?: boolean; deleteBy?: number; dataIndex: number; updationTime: number; } interface UpdateMagicMetadataRequest { metadataList: UpdateMagicMetadata[]; } interface UpdateMagicMetadata { id: number; magicMetadata: EncryptedMagicMetadataCore; } export const NEW_MAGIC_METADATA: MagicMetadataCore = { version: 0, data: {}, header: null, count: 0, }; interface TrashRequest { items: TrashRequestItems[]; } interface TrashRequestItems { fileID: number; collectionID: number; } export const getLocalFiles = async () => { const files: Array = (await localForage.getItem(FILES)) || []; return files; }; export const syncFiles = async ( collections: Collection[], setFiles: (files: File[]) => void ) => { const localFiles = await getLocalFiles(); let files = await removeDeletedCollectionFiles(collections, localFiles); if (files.length !== localFiles.length) { await localForage.setItem('files', files); setFiles(sortFiles(mergeMetadata(files))); } for (const collection of collections) { if (!getToken()) { continue; } const lastSyncTime = (await localForage.getItem(`${collection.id}-time`)) ?? 0; if (collection.updationTime === lastSyncTime) { continue; } const fetchedFiles = (await getFiles(collection, lastSyncTime, files, setFiles)) ?? []; files.push(...fetchedFiles); const latestVersionFiles = new Map(); files.forEach((file) => { const uid = `${file.collectionID}-${file.id}`; if ( !latestVersionFiles.has(uid) || latestVersionFiles.get(uid).updationTime < file.updationTime ) { latestVersionFiles.set(uid, file); } }); files = []; // eslint-disable-next-line @typescript-eslint/no-unused-vars for (const [_, file] of latestVersionFiles) { if (file.isDeleted) { continue; } files.push(file); } await localForage.setItem('files', files); await localForage.setItem( `${collection.id}-time`, collection.updationTime ); files = sortFiles(mergeMetadata(appendPhotoSwipeProps(files))); setFiles(files); } return mergeMetadata(appendPhotoSwipeProps(files)); }; export const getFiles = async ( collection: Collection, sinceTime: number, files: File[], setFiles: (files: File[]) => void ): Promise => { try { const decryptedFiles: File[] = []; let time = sinceTime || (await localForage.getItem(`${collection.id}-time`)) || 0; let resp; do { const token = getToken(); if (!token) { break; } resp = await HTTPService.get( `${ENDPOINT}/collections/v2/diff`, { collectionID: collection.id, sinceTime: time, }, { 'X-Auth-Token': token, } ); decryptedFiles.push( ...(await Promise.all( resp.data.diff.map(async (file: File) => { if (!file.isDeleted) { file = await decryptFile(file, collection); } return file; }) as Promise[] )) ); if (resp.data.diff.length) { time = resp.data.diff.slice(-1)[0].updationTime; } setFiles( sortFiles( mergeMetadata( [...(files || []), ...decryptedFiles].filter( (item) => !item.isDeleted ) ) ) ); } while (resp.data.hasMore); return decryptedFiles; } catch (e) { logError(e, 'Get files failed'); } }; const removeDeletedCollectionFiles = async ( collections: Collection[], files: File[] ) => { const syncedCollectionIds = new Set(); for (const collection of collections) { syncedCollectionIds.add(collection.id); } files = files.filter((file) => syncedCollectionIds.has(file.collectionID)); return files; }; export const trashFiles = async (filesToTrash: File[]) => { try { const token = getToken(); if (!token) { return; } const trashRequest: TrashRequest = { items: filesToTrash.map((file) => ({ fileID: file.id, collectionID: file.collectionID, })), }; await HTTPService.post(`${ENDPOINT}/files/trash`, trashRequest, null, { 'X-Auth-Token': token, }); } catch (e) { logError(e, 'trash file failed'); throw e; } }; export const deleteFromTrash = async (filesToDelete: number[]) => { try { const token = getToken(); if (!token) { return; } await HTTPService.post( `${ENDPOINT}/trash/delete`, { fileIDs: filesToDelete }, null, { 'X-Auth-Token': token, } ); } catch (e) { logError(e, 'delete from trash failed'); throw e; } }; export const updateMagicMetadata = async (files: File[]) => { const token = getToken(); if (!token) { return; } const reqBody: UpdateMagicMetadataRequest = { metadataList: [] }; const worker = await new CryptoWorker(); for (const file of files) { const { file: encryptedMagicMetadata }: EncryptionResult = await worker.encryptMetadata(file.magicMetadata.data, file.key); reqBody.metadataList.push({ id: file.id, magicMetadata: { version: file.magicMetadata.version, count: file.magicMetadata.count, data: encryptedMagicMetadata.encryptedData as unknown as string, header: encryptedMagicMetadata.decryptionHeader, }, }); } await HTTPService.put(`${ENDPOINT}/files/magic-metadata`, reqBody, null, { 'X-Auth-Token': token, }); return files.map( (file): File => ({ ...file, magicMetadata: { ...file.magicMetadata, version: file.magicMetadata.version + 1, }, }) ); }; export const updatePublicMagicMetadata = async (files: File[]) => { const token = getToken(); if (!token) { return; } const reqBody: UpdateMagicMetadataRequest = { metadataList: [] }; const worker = await new CryptoWorker(); for (const file of files) { const { file: encryptedPubMagicMetadata }: EncryptionResult = await worker.encryptMetadata(file.pubMagicMetadata.data, file.key); reqBody.metadataList.push({ id: file.id, magicMetadata: { version: file.pubMagicMetadata.version, count: file.pubMagicMetadata.count, data: encryptedPubMagicMetadata.encryptedData as unknown as string, header: encryptedPubMagicMetadata.decryptionHeader, }, }); } await HTTPService.put( `${ENDPOINT}/files/public-magic-metadata`, reqBody, null, { 'X-Auth-Token': token, } ); return files.map( (file): File => ({ ...file, pubMagicMetadata: { ...file.pubMagicMetadata, version: file.pubMagicMetadata.version + 1, }, }) ); };