// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import Model from '@nozbe/watermelondb/Model'; import {applicationName} from 'expo-application'; import {Directory, File, Paths, type FileInfo as ExpoFileInfo} from 'expo-file-system'; import mimeDB from 'mime-db'; import {Alert, Linking, Platform} from 'react-native'; import Permissions, {PERMISSIONS} from 'react-native-permissions'; import {Files, ServerErrors} from '@constants'; import {getFullErrorMessage} from '@utils/errors'; import {generateId} from '@utils/general'; import keyMirror from '@utils/key_mirror'; import {logDebug, logError} from '@utils/log'; import {getIOSAppGroupDetails} from '@utils/mattermost_managed'; import {urlSafeBase64Encode} from '@utils/security'; import type {PastedFile} from '@mattermost/react-native-paste-input'; import type {DocumentPickerResponse} from '@react-native-documents/picker'; import type FileModel from '@typings/database/models/servers/file'; import type {IntlShape} from 'react-intl'; import type {Asset} from 'react-native-image-picker'; const EXTRACT_TYPE_REGEXP = /^\s*([^;\s]*)(?:;|\s|$)/; const CONTENT_DISPOSITION_REGEXP = /inline;filename=".*\.([a-z]+)";/i; const DEFAULT_SERVER_MAX_FILE_SIZE = 50 * 1024 * 1024;// 50 Mb export const FileFilters = keyMirror({ ALL: null, DOCUMENTS: null, SPREADSHEETS: null, PRESENTATIONS: null, CODE: null, IMAGES: null, AUDIO: null, VIDEOS: null, }); export type FileFilter = keyof typeof FileFilters export const GENERAL_SUPPORTED_DOCS_FORMAT = [ 'application/json', 'application/msword', 'application/pdf', 'application/rtf', 'application/vnd.ms-excel', 'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.openxmlformats-officedocument.presentationml.presentation', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/x-x509-ca-cert', 'application/xml', 'text/csv', 'text/plain', ]; const SUPPORTED_DOCS_FORMAT = Platform.select({ android: GENERAL_SUPPORTED_DOCS_FORMAT, ios: [ ...GENERAL_SUPPORTED_DOCS_FORMAT, 'application/vnd.apple.pages', 'application/vnd.apple.numbers', 'application/vnd.apple.keynote', ], }); const SUPPORTED_IMAGE_FORMAT = ['png', 'jpg', 'jpeg', 'bmp', 'tiff', 'svg', 'xcf', 'gif', 'webp']; const SUPPORTED_VIDEO_FORMAT = Platform.select({ ios: ['video/mp4', 'video/x-m4v', 'video/quicktime'], android: ['video/3gpp', 'video/x-matroska', 'video/mp4', 'video/webm', 'video/quicktime'], }); const SUPPORTED_AUDIO_FORMAT = Platform.select({ ios: ['audio/mp4', 'audio/mpeg', 'audio/wav', 'audio/x-aiff'], android: ['audio/mp4', 'audio/mpeg', 'audio/ogg', 'audio/wav', 'audio/webm', 'audio/3gpp'], }); const types: Record = {}; const extensions: Record = {}; export function filterFileExtensions(filter?: FileFilter): string { let searchTerms: string[] = []; switch (filter) { case FileFilters.ALL: return ''; case FileFilters.DOCUMENTS: searchTerms = Files.DOCUMENT_TYPES; break; case FileFilters.SPREADSHEETS: searchTerms = Files.SPREADSHEET_TYPES; break; case FileFilters.PRESENTATIONS: searchTerms = Files.PRESENTATION_TYPES; break; case FileFilters.CODE: searchTerms = Files.CODE_TYPES; break; case FileFilters.IMAGES: searchTerms = Files.IMAGE_TYPES; break; case FileFilters.AUDIO: searchTerms = Files.AUDIO_TYPES; break; case FileFilters.VIDEOS: searchTerms = Files.VIDEO_TYPES; break; default: return ''; } return 'ext:' + searchTerms.join(' ext:'); } /** * Populate the extensions and types maps. * @private */ function populateMaps() { // source preference (least -> most) const preference = ['nginx', 'apache', undefined, 'iana']; Object.keys(mimeDB).forEach((type) => { const mime = mimeDB[type]; const exts = mime.extensions; if (!exts || !exts.length) { return; } extensions[type] = exts; for (let i = 0; i < exts.length; i++) { const extension = exts[i]; if (types[extension]) { const from = preference.indexOf(mimeDB[types[extension]].source); const to = preference.indexOf(mime.source); if (types[extension] !== 'application/octet-stream' && (from > to || (from === to && types[extension].substr(0, 12) === 'application/'))) { continue; } } types[extension] = type; } }); } export function deleteFileCache(serverUrl: string) { const serverDir = urlSafeBase64Encode(serverUrl); return deleteFileCacheByDir(serverDir); } export function deleteFileCacheByDir(dir: string) { if (Platform.OS === 'ios') { const appGroupCacheDir = `${getIOSAppGroupDetails().appGroupSharedDirectory}/Library/Caches/${dir}`; deleteFilesInDir(appGroupCacheDir); } const cacheDir = `${Paths.cache.uri}/${dir}`; deleteFilesInDir(cacheDir); return true; } /** * Per-file eviction, for when the server confirms a denial: the FileModel rows are destroyed, but the * downloaded bytes would stay readable on disk. Each file is looked for both where its row says it * was saved and where downloads land by default, because opening a document never records its path. * Failures are swallowed: a file that cannot be removed must not stop the denial being recorded. */ export function deleteDownloadedFiles(serverUrl: string | undefined, files: FileModel[]) { for (const f of files) { const paths = new Set(); if (f.localPath) { try { paths.add(decodeURIComponent(f.localPath)); } catch { paths.add(f.localPath); } } if (serverUrl) { paths.add(getLocalFilePathFromFile(serverUrl, f)); } for (const path of paths) { try { const file = new File(pathWithPrefix('file://', path)); if (file.exists) { file.delete(); } } catch (error) { logDebug('deleteDownloadedFiles: could not remove a cached file', getFullErrorMessage(error)); } } } } function deleteFilesInDir(directory: string) { if (directory) { const dir = new Directory(directory); if (dir.exists) { dir.delete(); } } } export function lookupMimeType(filename: string) { if (!Object.keys(extensions).length) { populateMaps(); } const ext = filename.split('.').pop()?.toLowerCase(); return types[ext!] || 'application/octet-stream'; } export function getExtensionFromMime(type: string) { if (!Object.keys(extensions).length) { populateMaps(); } if (!type || typeof type !== 'string') { return undefined; } const match = EXTRACT_TYPE_REGEXP.exec(type); // get extensions const exts = match && extensions[match[1].toLowerCase()]; if (!exts || !exts.length) { return undefined; } return exts[0]; } export function getExtensionFromContentDisposition(contentDisposition: string) { const match = CONTENT_DISPOSITION_REGEXP.exec(contentDisposition); let extension = match && match[1]; if (extension) { if (!Object.keys(types).length) { populateMaps(); } extension = extension.toLowerCase(); if (types[extension]) { return extension; } return null; } return null; } export const getAllowedServerMaxFileSize = (config: ClientConfig) => { return config && config.MaxFileSize ? parseInt(config.MaxFileSize, 10) : DEFAULT_SERVER_MAX_FILE_SIZE; }; export const isGif = (file?: FileInfo | FileModel) => { if (!file) { return false; } let mime = 'mime_type' in file ? file.mime_type : file.mimeType; if (mime && mime.includes(';')) { mime = mime.split(';')[0]; } else if (!mime && file?.name) { mime = lookupMimeType(file.name); } return mime === 'image/gif'; }; export function extractExtension(filename: string) { const ext = filename.split('.').pop() || ''; return ext.startsWith('.') ? ext.slice(1).toLowerCase() : ext.toLowerCase(); } export const isImage = (file?: FileInfo | FileModel) => { if (!file) { return false; } if (isGif(file)) { return true; } // A draft persisted before its file metadata resolved has neither, despite the types. if (!file.extension && !file.name) { return false; } const fileExt = extractExtension(file.extension || file.name); if (!SUPPORTED_IMAGE_FORMAT.includes(fileExt)) { return false; } let mimeType = 'mime_type' in file ? file.mime_type : file.mimeType; if (!mimeType) { mimeType = lookupMimeType(fileExt); } return Boolean(mimeType?.startsWith('image/')); }; export const isDocument = (file?: FileInfo | FileModel) => { if (!file) { return false; } let mime = 'mime_type' in file ? file.mime_type : file.mimeType; if (mime && mime.includes(';')) { mime = mime.split(';')[0]; } else if (!mime && file?.name) { mime = lookupMimeType(file.name); } return SUPPORTED_DOCS_FORMAT!.includes(mime); }; export const isPdf = (file?: FileInfo | FileModel) => { if (!file) { return false; } let mime = 'mime_type' in file ? file.mime_type : file.mimeType; if (mime && mime.includes(';')) { mime = mime.split(';')[0]; } else if (!mime && file?.name) { mime = lookupMimeType(file.name); } return mime === 'application/pdf'; }; export const isVideo = (file?: FileInfo | FileModel) => { if (!file) { return false; } let mime = 'mime_type' in file ? file.mime_type : file.mimeType; if (mime && mime.includes(';')) { mime = mime.split(';')[0]; } else if (!mime && file?.name) { mime = lookupMimeType(file.name); } return SUPPORTED_VIDEO_FORMAT!.includes(mime); }; export const isAudio = (file?: FileInfo | FileModel) => { if (!file) { return false; } let mime = 'mime_type' in file ? file.mime_type : file.mimeType; if (mime && mime.includes(';')) { mime = mime.split(';')[0]; } else if (!mime && file?.name) { mime = lookupMimeType(file.name); } return SUPPORTED_AUDIO_FORMAT!.includes(mime); }; export function getFormattedFileSize(bytes: number): string { const fileSizes: Array<[string, number]> = [ ['TB', 1024 * 1024 * 1024 * 1024], ['GB', 1024 * 1024 * 1024], ['MB', 1024 * 1024], ['KB', 1024], ]; const size = fileSizes.find((unitAndMinBytes) => { const minBytes = unitAndMinBytes[1]; return bytes > minBytes; }); if (size) { return `${Math.floor(bytes / (size[1]))} ${size[0]}`; } return `${bytes} B`; } export function getFileType(file: FileInfo | ExtractedFileInfo): string { if (!file || !file.extension) { return 'other'; } const fileExt = file.extension.toLowerCase(); const fileTypes = [ 'image', 'code', 'pdf', 'video', 'audio', 'spreadsheet', 'text', 'word', 'presentation', 'patch', 'zip', ]; return fileTypes.find((fileType) => { const constForFileTypeExtList = `${fileType}_types`.toUpperCase(); const fileTypeExts = Files[constForFileTypeExtList]; return fileTypeExts.indexOf(fileExt) > -1; }) || 'other'; } export function getLocalFilePathFromFile(serverUrl: string, file: FileInfo | FileModel) { const fileIdPath = file.id?.replace(/[^0-9a-z]/g, ''); if (serverUrl) { const server = urlSafeBase64Encode(serverUrl); const hasValidFilename = file?.name && !file.name.includes('/'); const hasValidExtension = file?.extension && !file.extension.includes('/'); if (hasValidFilename) { let extension: string | undefined = file.extension; let filename = file.name; if (!hasValidExtension) { const mimeType = (file instanceof Model) ? file.mimeType : file.mime_type; extension = getExtensionFromMime(mimeType); } if (extension && filename.includes(`.${extension}`)) { filename = filename.replace(`.${extension}`, ''); } else { const fileParts = file.name.split('.'); if (fileParts.length > 1) { extension = fileParts.pop(); filename = fileParts.join('.'); } } return `${Paths.cache.uri}${server}/Files/${filename}-${fileIdPath}.${extension}`; } else if (file?.id && hasValidExtension) { return `${Paths.cache.uri}${server}/Files/${fileIdPath}.${file.extension}`; } else if (file?.id) { return `${Paths.cache.uri}${server}/Files/${fileIdPath}`; } } throw new Error('File path could not be set'); } export async function extractFileInfo(files: Array) { const out: ExtractedFileInfo[] = []; await Promise.all(files.map(async (file) => { if (!file || !file.uri) { logError('extractFileInfo no file or url'); return; } const outFile = { progress: 0, localPath: file.uri, clientId: generateId(), loading: true, } as unknown as ExtractedFileInfo; if ('fileSize' in file) { outFile.size = file.fileSize || 0; outFile.name = file.fileName || ''; } else { const localPath = file.uri || ''; try { const fileInfo = new File(localPath).info(); if (fileInfo.exists) { outFile.size = fileInfo.size || 0; outFile.name = localPath.substring(localPath.lastIndexOf('/') + 1); } } catch (e) { logError('extractFileInfo', e); return; } } if (file.type) { outFile.mime_type = file.type; } else { outFile.mime_type = lookupMimeType(outFile.name); } out.push(outFile); })); return out; } export function fileSizeWarning(intl: IntlShape, maxFileSize: number) { return intl.formatMessage({ id: 'file_upload.fileAbove', defaultMessage: 'Files must be less than {max}', }, { max: getFormattedFileSize(maxFileSize), }); } export function fileMaxWarning(intl: IntlShape, maxFileCount: number) { return intl.formatMessage({ id: 'mobile.file_upload.max_warning', defaultMessage: 'Uploads limited to {count} files maximum.', }, { count: maxFileCount, }); } export function uploadDisabledWarning(intl: IntlShape) { return intl.formatMessage({ id: 'mobile.file_upload.disabled2', defaultMessage: 'File uploads from mobile are disabled.', }); } export function uploadDisabledByPolicyWarning(intl: IntlShape) { return intl.formatMessage({ id: 'mobile.file_upload.disabled_by_policy', defaultMessage: 'File uploads are restricted in this channel', }); } export function getUploadErrorMessage(intl: IntlShape, errorMessage: string, errorName?: string) { // The client may not know about a denial yet (the decision is still loading or went stale), so the // server's own refusal is shown in the same words the disabled button would have used. if (errorName === ServerErrors.UPLOAD_DENIED_BY_POLICY_ERROR) { return uploadDisabledByPolicyWarning(intl); } // iOS: Alamofire wraps all network errors with this prefix const isIosNetworkError = errorMessage.startsWith('URLSessionTask failed with error:'); // Android: Network exceptions from java.net package const isAndroidNetworkError = errorName?.startsWith('java.net.') ?? false; if (isIosNetworkError || isAndroidNetworkError) { return intl.formatMessage({ id: 'mobile.file_upload.network_unavailable', defaultMessage: "File couldn't be uploaded. Check your connection and try again.", }); } return errorMessage; } export const fileExists = (path: string) => { try { return new File(path).exists; } catch { return false; } }; export const hasWriteStoragePermission = async (intl: IntlShape) => { if (Platform.OS === 'android' && Platform.Version < 33) { const storagePermission = PERMISSIONS.ANDROID.WRITE_EXTERNAL_STORAGE; let permissionRequest; const hasPermissionToStorage = await Permissions.check(storagePermission); switch (hasPermissionToStorage) { case Permissions.RESULTS.DENIED: permissionRequest = await Permissions.request(storagePermission); return permissionRequest === Permissions.RESULTS.GRANTED; case Permissions.RESULTS.BLOCKED: { const title = intl.formatMessage( { id: 'mobile.storage_permission_denied_title', defaultMessage: '{applicationName} would like to access your files', }, {applicationName}, ); const text = intl.formatMessage( { id: 'mobile.write_storage_permission_denied_description', defaultMessage: 'Save files to your device. Open Settings to grant {applicationName} write access to files on this device.', }, {applicationName}, ); Alert.alert(title, text, [ { text: intl.formatMessage({ id: 'mobile.permission_denied_dismiss', defaultMessage: "Don't Allow", }), }, { text: intl.formatMessage({ id: 'mobile.permission_denied_retry', defaultMessage: 'Settings', }), onPress: () => Linking.openSettings(), }, ]); return false; } default: return true; } } return true; }; export const getAllFilesInCachesDirectory = (serverUrl: string) => { try { const infos: ExpoFileInfo[] = []; const paths = [`${Paths.cache.uri}/${urlSafeBase64Encode(serverUrl)}`]; if (Platform.OS === 'ios') { paths.push(`${getIOSAppGroupDetails().appGroupSharedDirectory}/Library/Caches/${urlSafeBase64Encode(serverUrl)}`); } for (const p of paths) { const info = new Directory(p).info(); if (info.exists) { infos.push(info); } } const totalSize = infos.reduce((acc, info) => acc + (info.size ?? 0), 0); return { files: infos, totalSize, }; } catch (error) { return {error}; } }; export const pathWithPrefix = (prefix: string, path: string) => { const p = path.startsWith(prefix) ? '' : prefix; return `${p}${path}`; }; export const deleteFile = (path: string) => { try { new File(pathWithPrefix('file://', path)).delete(); } catch { /* file may not exist */ } }; export const filesLocalPathValidation = (files: FileModel[], authorId: string) => { const filesInfo: FileInfo[] = []; for (const f of files) { const info = f.toFileInfo(authorId); if (info.localPath) { const exists = fileExists(info.localPath); if (!exists) { info.localPath = ''; } } filesInfo.push(info); } return filesInfo; }; /** * Convert FileModel array to FileInfo array * Note: Does NOT validate local paths - validation is done by Files component */ export function fileModelsToFileInfo(files: FileModel[], userId: string): FileInfo[] { return files.map((f) => f.toFileInfo(userId)); }