diff --git a/web/apps/photos/src/components/PhotoViewer/ImageEditorOverlay/index.tsx b/web/apps/photos/src/components/PhotoViewer/ImageEditorOverlay/index.tsx index e8f2fee7c..8e60875ff 100644 --- a/web/apps/photos/src/components/PhotoViewer/ImageEditorOverlay/index.tsx +++ b/web/apps/photos/src/components/PhotoViewer/ImageEditorOverlay/index.tsx @@ -42,8 +42,8 @@ import { t } from "i18next"; import mime from "mime-types"; import { AppContext } from "pages/_app"; import { getLocalCollections } from "services/collectionService"; +import { detectFileTypeInfo } from "services/detect-type"; import downloadManager from "services/download"; -import { detectFileTypeInfo } from "services/typeDetectionService"; import uploadManager from "services/upload/uploadManager"; import { EnteFile } from "types/file"; import { FileWithCollection } from "types/upload"; diff --git a/web/apps/photos/src/components/PhotoViewer/index.tsx b/web/apps/photos/src/components/PhotoViewer/index.tsx index 8eabecf0d..8e6debf68 100644 --- a/web/apps/photos/src/components/PhotoViewer/index.tsx +++ b/web/apps/photos/src/components/PhotoViewer/index.tsx @@ -43,10 +43,10 @@ import { t } from "i18next"; import isElectron from "is-electron"; import { AppContext } from "pages/_app"; import { GalleryContext } from "pages/gallery"; +import { detectFileTypeInfo } from "services/detect-type"; import downloadManager, { LoadedLivePhotoSourceURL } from "services/download"; import { getParsedExifData } from "services/exif"; import { trashFiles } from "services/fileService"; -import { detectFileTypeInfo } from "services/typeDetectionService"; import { SetFilesDownloadProgressAttributesCreator } from "types/gallery"; import { isClipboardItemPresent } from "utils/common"; import { pauseVideo, playVideo } from "utils/photoFrame"; diff --git a/web/apps/photos/src/services/detect-type.ts b/web/apps/photos/src/services/detect-type.ts new file mode 100644 index 000000000..bbabf0024 --- /dev/null +++ b/web/apps/photos/src/services/detect-type.ts @@ -0,0 +1,101 @@ +import { + FILE_TYPE, + KnownFileTypeInfos, + KnownNonMediaFileExtensions, + type FileTypeInfo, +} from "@/media/file-type"; +import { lowercaseExtension } from "@/next/file"; +import { CustomError } from "@ente/shared/error"; +import FileType from "file-type"; +import { getUint8ArrayView } from "./readerService"; + +/** + * Read the file's initial contents or use the file's name to detect its type. + * + * This function first reads an initial chunk of the file and tries to detect + * the file's {@link FileTypeInfo} from it. If that doesn't work, it then falls + * back to using the file's name to detect it. + * + * If neither of these two approaches work, it throws an exception. + * + * If we were able to detect the file type, but it is explicitly not a media + * (image or video) format that we support, this function throws an error with + * the message `CustomError.UNSUPPORTED_FILE_FORMAT`. + * + * @param file A {@link File} object + * + * @returns The detected {@link FileTypeInfo}. + */ +export const detectFileTypeInfo = async (file: File): Promise => + detectFileTypeInfoFromChunk(() => readInitialChunkOfFile(file), file.name); + +/** + * The lower layer implementation of the type detector. + * + * Usually, when the code already has a {@link File} object at hand, it is + * easier to use the higher level {@link detectFileTypeInfo} function. + * + * However, this lower level function is also exposed for use in cases like + * during upload where we might not have a File object and would like to provide + * the initial chunk of the file's contents in a different way. + * + * @param readInitialChunk A function to call to read the initial chunk of the + * file's data. There is no strict requirement for the size of the chunk this + * function should return, generally the first few KBs should be good. + * + * @param fileNameOrPath The full path or just the file name of the file whose + * type we're trying to determine. This is used by the fallback layer that tries + * to detect the type info from the file's extension. + */ +export const detectFileTypeInfoFromChunk = async ( + readInitialChunk: () => Promise, + fileNameOrPath: string, +): Promise => { + try { + const typeResult = await detectFileTypeFromBuffer( + await readInitialChunk(), + ); + + const mimeType = typeResult.mime; + + let fileType: FILE_TYPE; + if (mimeType.startsWith("image/")) { + fileType = FILE_TYPE.IMAGE; + } else if (mimeType.startsWith("video/")) { + fileType = FILE_TYPE.VIDEO; + } else { + throw new Error(CustomError.UNSUPPORTED_FILE_FORMAT); + } + + return { + fileType, + // See https://github.com/sindresorhus/file-type/blob/main/core.d.ts + // for the full list of ext values. + extension: typeResult.ext, + mimeType, + }; + } catch (e) { + const extension = lowercaseExtension(fileNameOrPath); + const known = KnownFileTypeInfos.find((f) => f.extension == extension); + if (known) return known; + + if (KnownNonMediaFileExtensions.includes(extension)) + throw Error(CustomError.UNSUPPORTED_FILE_FORMAT); + + throw e; + } +}; + +const readInitialChunkOfFile = async (file: File) => { + const chunkSizeForTypeDetection = 4100; + const chunk = file.slice(0, chunkSizeForTypeDetection); + return await getUint8ArrayView(chunk); +}; + +const detectFileTypeFromBuffer = async (buffer: Uint8Array) => { + const result = await FileType.fromBuffer(buffer); + if (!result?.ext || !result?.mime) { + throw Error(`Could not deduce file type from buffer`); + } + return result; +}; diff --git a/web/apps/photos/src/services/ffmpeg.ts b/web/apps/photos/src/services/ffmpeg.ts index b1436f17b..994cede2c 100644 --- a/web/apps/photos/src/services/ffmpeg.ts +++ b/web/apps/photos/src/services/ffmpeg.ts @@ -51,7 +51,7 @@ const generateVideoThumbnail = async ( * for the new files that the user is adding. * * @param dataOrPath The input video's data or the path to the video on the - * user's local filesystem. See: [Note: The fileOrPath parameter to upload]. + * user's local filesystem. See: [Note: Reading a fileOrPath]. * * @returns JPEG data of the generated thumbnail. * diff --git a/web/apps/photos/src/services/fix-exif.ts b/web/apps/photos/src/services/fix-exif.ts index 8c38aacde..f47e4c5ed 100644 --- a/web/apps/photos/src/services/fix-exif.ts +++ b/web/apps/photos/src/services/fix-exif.ts @@ -2,7 +2,7 @@ import { FILE_TYPE } from "@/media/file-type"; import log from "@/next/log"; import { validateAndGetCreationUnixTimeInMicroSeconds } from "@ente/shared/time"; import type { FixOption } from "components/FixCreationTime"; -import { detectFileTypeInfo } from "services/typeDetectionService"; +import { detectFileTypeInfo } from "services/detect-type"; import { EnteFile } from "types/file"; import { changeFileCreationTime, diff --git a/web/apps/photos/src/services/typeDetectionService.ts b/web/apps/photos/src/services/typeDetectionService.ts deleted file mode 100644 index b4eaeede8..000000000 --- a/web/apps/photos/src/services/typeDetectionService.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { - FILE_TYPE, - KnownFileTypeInfos, - KnownNonMediaFileExtensions, - type FileTypeInfo, -} from "@/media/file-type"; -import { lowercaseExtension } from "@/next/file"; -import { ElectronFile } from "@/next/types/file"; -import { CustomError } from "@ente/shared/error"; -import FileType, { type FileTypeResult } from "file-type"; -import { getUint8ArrayView } from "./readerService"; - -/** - * Read the file's initial contents or use the file's name to detect its type. - * - * This function first reads an initial chunk of the file and tries to detect - * the file's {@link FileTypeInfo} from it. If that doesn't work, it then falls - * back to using the file's name to detect it. - * - * If neither of these two approaches work, it throws an exception. - * - * If we were able to detect the file type, but it is explicitly not a media - * (image or video) format that we support, this function throws an error with - * the message `CustomError.UNSUPPORTED_FILE_FORMAT`. - * - * @param fileOrPath A {@link File} object, or the path to the file on the - * user's local filesystem. It is only valid to provide a path if we're running - * in the context of our desktop app. - * - * @returns The detected {@link FileTypeInfo}. - */ -export const detectFileTypeInfo = async ( - fileOrPath: File | ElectronFile, -): Promise => { - try { - let fileType: FILE_TYPE; - let typeResult: FileTypeResult; - - if (fileOrPath instanceof File) { - typeResult = await extractFileType(fileOrPath); - } else { - typeResult = await extractElectronFileType(fileOrPath); - } - - const mimTypeParts: string[] = typeResult.mime?.split("/"); - - if (mimTypeParts?.length !== 2) { - throw Error(CustomError.INVALID_MIME_TYPE(typeResult.mime)); - } - switch (mimTypeParts[0]) { - case "image": - fileType = FILE_TYPE.IMAGE; - break; - case "video": - fileType = FILE_TYPE.VIDEO; - break; - default: - throw new Error(CustomError.UNSUPPORTED_FILE_FORMAT); - } - return { - fileType, - extension: typeResult.ext, - mimeType: typeResult.mime, - }; - } catch (e) { - const extension = lowercaseExtension(fileOrPath.name); - const known = KnownFileTypeInfos.find((f) => f.extension == extension); - if (known) return known; - - if (KnownNonMediaFileExtensions.includes(extension)) - throw Error(CustomError.UNSUPPORTED_FILE_FORMAT); - - throw e; - } -}; - -async function extractFileType(file: File) { - const chunkSizeForTypeDetection = 4100; - const fileBlobChunk = file.slice(0, chunkSizeForTypeDetection); - const fileDataChunk = await getUint8ArrayView(fileBlobChunk); - return getFileTypeFromBuffer(fileDataChunk); -} - -async function extractElectronFileType(file: ElectronFile) { - const stream = await file.stream(); - const reader = stream.getReader(); - const { value: fileDataChunk } = await reader.read(); - await reader.cancel(); - return getFileTypeFromBuffer(fileDataChunk); -} - -async function getFileTypeFromBuffer(buffer: Uint8Array) { - const result = await FileType.fromBuffer(buffer); - if (!result?.ext || !result?.mime) { - throw Error(`Could not deduce file type from buffer`); - } - return result; -} diff --git a/web/apps/photos/src/services/upload/uploadService.ts b/web/apps/photos/src/services/upload/uploadService.ts index 1e786caff..aa6b50bc3 100644 --- a/web/apps/photos/src/services/upload/uploadService.ts +++ b/web/apps/photos/src/services/upload/uploadService.ts @@ -47,8 +47,8 @@ import { import { readStream } from "utils/native-stream"; import { hasFileHash } from "utils/upload"; import * as convert from "xml-js"; +import { detectFileTypeInfo } from "../detect-type"; import { getFileStream } from "../readerService"; -import { detectFileTypeInfo } from "../typeDetectionService"; import { extractAssetMetadata } from "./metadata"; import publicUploadHttpClient from "./publicUploadHttpClient"; import type { ParsedMetadataJSON } from "./takeout"; @@ -175,14 +175,12 @@ export const uploader = async ( const { collection, localID, ...uploadAsset2 } = fileWithCollection; /* TODO(MR): ElectronFile changes */ const uploadAsset = uploadAsset2 as UploadAsset; - let fileTypeInfo: FileTypeInfo; - let fileSize: number; try { /* * We read the file three times: * 1. To determine its MIME type (only needs first few KBs). * 2. To calculate its hash. - * 3. To compute its thumbnail and then encrypt it. + * 3. To encrypt it. * * When we already have a File object the multiple reads are fine. When * we're in the context of our desktop app and have a path, it might be @@ -192,13 +190,15 @@ export const uploader = async ( * manner (tee will not work for strictly sequential reads of large * streams). */ - const maxFileSize = 4 * 1024 * 1024 * 1024; // 4 GB - fileSize = getAssetSize(uploadAsset); - if (fileSize >= maxFileSize) { + const { fileTypeInfo, fileSize } = + await readFileTypeInfoAndSize(uploadAsset); + + const maxFileSize = 4 * 1024 * 1024 * 1024; /* 4 GB */ + if (fileSize >= maxFileSize) return { fileUploadResult: UPLOAD_RESULT.TOO_LARGE }; - } - fileTypeInfo = await getAssetFileType(uploadAsset); + + abortIfCancelled(); const { metadata, publicMagicMetadata } = await extractAssetMetadata( worker, @@ -313,9 +313,6 @@ export const uploader = async ( export const getFileName = (file: File | ElectronFile | string) => typeof file == "string" ? basename(file) : file.name; -function getFileSize(file: File | ElectronFile) { - return file.size; -} export const getAssetName = ({ isLivePhoto, file, @@ -330,41 +327,10 @@ export const assetName = ({ }: UploadAsset2) => isLivePhoto ? getFileName(livePhotoAssets.image) : getFileName(file); -const getAssetSize = ({ isLivePhoto, file, livePhotoAssets }: UploadAsset) => { - return isLivePhoto ? getLivePhotoSize(livePhotoAssets) : getFileSize(file); -}; - -const getLivePhotoSize = (livePhotoAssets: LivePhotoAssets) => { - return livePhotoAssets.image.size + livePhotoAssets.video.size; -}; - -const getAssetFileType = ({ - isLivePhoto, - file, - livePhotoAssets, -}: UploadAsset) => { - return isLivePhoto - ? getLivePhotoFileType(livePhotoAssets) - : detectFileTypeInfo(file); -}; - -const getLivePhotoFileType = async ( - livePhotoAssets: LivePhotoAssets, -): Promise => { - const imageFileTypeInfo = await detectFileTypeInfo(livePhotoAssets.image); - const videoFileTypeInfo = await detectFileTypeInfo(livePhotoAssets.video); - return { - fileType: FILE_TYPE.LIVE_PHOTO, - extension: `${imageFileTypeInfo.extension}+${videoFileTypeInfo.extension}`, - imageType: imageFileTypeInfo.extension, - videoType: videoFileTypeInfo.extension, - }; -}; - /** * Read the given file or path into an in-memory representation. * - * [Note: The fileOrPath parameter to upload] + * See: [Note: Reading a fileOrPath] * * The file can be either a web * [File](https://developer.mozilla.org/en-US/docs/Web/API/File) or the absolute @@ -437,6 +403,75 @@ const readFileOrPath = async ( return { dataOrStream, fileSize }; }; +/** + * Read the beginning of the file or use its filename to determine its MIME + * type. Use that to construct and return a {@link FileTypeInfo}. + * + * While we're at it, also return the size of the file. + * + * @param fileOrPath See: [Note: Reading a fileOrPath] + */ +const readFileTypeInfoAndSize = async ( + fileOrPath: File | string, +): Promise<{ fileTypeInfo: FileTypeInfo; fileSize: number }> => { + const { dataOrStream, fileSize } = await readFileOrPath(fileOrPath); + + function getFileSize(file: File | ElectronFile) { + return file.size; + } + + async function extractElectronFileType(file: ElectronFile) { + const stream = await file.stream(); + const reader = stream.getReader(); + const { value: fileDataChunk } = await reader.read(); + await reader.cancel(); + return getFileTypeFromBuffer(fileDataChunk); + } + + fileSize = getAssetSize(uploadAsset); + fileTypeInfo = await getAssetFileType(uploadAsset); + const getAssetSize = ({ + isLivePhoto, + file, + livePhotoAssets, + }: UploadAsset) => { + return isLivePhoto + ? getLivePhotoSize(livePhotoAssets) + : getFileSize(file); + }; + + const getLivePhotoSize = (livePhotoAssets: LivePhotoAssets) => { + return livePhotoAssets.image.size + livePhotoAssets.video.size; + }; + + const getAssetFileType = ({ + isLivePhoto, + file, + livePhotoAssets, + }: UploadAsset) => { + return isLivePhoto + ? getLivePhotoFileType(livePhotoAssets) + : detectFileTypeInfo(file); + }; + + const getLivePhotoFileType = async ( + livePhotoAssets: LivePhotoAssets, + ): Promise => { + const imageFileTypeInfo = await detectFileTypeInfo( + livePhotoAssets.image, + ); + const videoFileTypeInfo = await detectFileTypeInfo( + livePhotoAssets.video, + ); + return { + fileType: FILE_TYPE.LIVE_PHOTO, + extension: `${imageFileTypeInfo.extension}+${videoFileTypeInfo.extension}`, + imageType: imageFileTypeInfo.extension, + videoType: videoFileTypeInfo.extension, + }; + }; +}; + const readAsset = async ( fileTypeInfo: FileTypeInfo, { isLivePhoto, file, livePhotoAssets }: UploadAsset2, diff --git a/web/apps/photos/src/utils/file/index.ts b/web/apps/photos/src/utils/file/index.ts index c8bb8431c..5d7762abf 100644 --- a/web/apps/photos/src/utils/file/index.ts +++ b/web/apps/photos/src/utils/file/index.ts @@ -11,6 +11,7 @@ import { downloadUsingAnchor, withTimeout } from "@ente/shared/utils"; import { t } from "i18next"; import isElectron from "is-electron"; import { moveToHiddenCollection } from "services/collectionService"; +import { detectFileTypeInfo } from "services/detect-type"; import DownloadManager from "services/download"; import { updateFileCreationDateInEXIF } from "services/exif"; import { @@ -20,7 +21,6 @@ import { updateFilePublicMagicMetadata, } from "services/fileService"; import { heicToJPEG } from "services/heic-convert"; -import { detectFileTypeInfo } from "services/typeDetectionService"; import { EncryptedEnteFile, EnteFile, diff --git a/web/packages/media/file-type.ts b/web/packages/media/file-type.ts index 586c75f06..b180918cd 100644 --- a/web/packages/media/file-type.ts +++ b/web/packages/media/file-type.ts @@ -11,9 +11,6 @@ export interface FileTypeInfo { * A lowercased, standardized extension for files of the current type. * * TODO(MR): This in not valid for LIVE_PHOTO. - * - * See https://github.com/sindresorhus/file-type/blob/main/core.d.ts for the - * full list of values this property can have. */ extension: string; mimeType?: string; diff --git a/web/packages/next/file.ts b/web/packages/next/file.ts index aec755558..56d27b79b 100644 --- a/web/packages/next/file.ts +++ b/web/packages/next/file.ts @@ -26,17 +26,22 @@ export const nameAndExtension = (fileName: string): FileNameComponents => { }; /** - * If the file has an extension, return a lowercased version of it. + * If the file name or path has an extension, return a lowercased version of it. * * This is handy when comparing the extension to a known set without worrying * about case sensitivity. * * See {@link nameAndExtension} for its more generic sibling. */ -export const lowercaseExtension = (fileName: string): string | undefined => { - const [, ext] = nameAndExtension(fileName); +export const lowercaseExtension = ( + fileNameOrPath: string, +): string | undefined => { + // We rely on the implementation of nameAndExtension using lastIndexOf to + // allow us to also work on paths. + const [, ext] = nameAndExtension(fileNameOrPath); return ext?.toLowerCase(); }; + /** * Construct a file name from its components (name and extension). *