diff --git a/android/app/build.gradle b/android/app/build.gradle index 1e849a36e7..917aa93959 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -93,7 +93,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionCode VERSIONCODE as Integer - versionName "4.50.0" + versionName "4.50.1" vectorDrawables.useSupportLibrary = true if (!isFoss) { manifestPlaceholders = [BugsnagAPIKey: BugsnagAPIKey as String] diff --git a/android/app/src/main/java/chat/rocket/reactnative/networking/SSLPinningModule.java b/android/app/src/main/java/chat/rocket/reactnative/networking/SSLPinningModule.java index 1e0ab23b7c..2e7f639fac 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/networking/SSLPinningModule.java +++ b/android/app/src/main/java/chat/rocket/reactnative/networking/SSLPinningModule.java @@ -39,6 +39,7 @@ import com.dylanvann.fastimage.FastImageOkHttpUrlLoader; import expo.modules.av.player.datasource.SharedCookiesDataSourceFactory; +import expo.modules.filesystem.FileSystemModule; public class SSLPinningModule extends ReactContextBaseJavaModule implements KeyChainAliasCallback { @@ -113,6 +114,8 @@ public void setCertificate(String data, Promise promise) { FastImageOkHttpUrlLoader.setOkHttpClient(getOkHttpClient()); // Expo AV network layer SharedCookiesDataSourceFactory.setOkHttpClient(getOkHttpClient()); + // Expo File System network layer + FileSystemModule.setOkHttpClient(getOkHttpClient()); promise.resolve(null); } diff --git a/app/containers/MessageComposer/MessageComposer.test.tsx b/app/containers/MessageComposer/MessageComposer.test.tsx index f1326120cd..5cdc418c58 100644 --- a/app/containers/MessageComposer/MessageComposer.test.tsx +++ b/app/containers/MessageComposer/MessageComposer.test.tsx @@ -100,8 +100,10 @@ describe('MessageComposer', () => { const onSendMessage = jest.fn(); render(); expect(screen.getByTestId('message-composer-send-audio')).toBeOnTheScreen(); + expect(screen.queryByTestId('message-composer-send')).not.toBeOnTheScreen(); await user.type(screen.getByTestId('message-composer-input'), 'test'); + expect(screen.getByTestId('message-composer-input')).not.toBe(''); expect(screen.queryByTestId('message-composer-send-audio')).not.toBeOnTheScreen(); expect(screen.getByTestId('message-composer-send')).toBeOnTheScreen(); diff --git a/app/containers/MessageComposer/components/ComposerInput.tsx b/app/containers/MessageComposer/components/ComposerInput.tsx index 029c650c48..096f799a84 100644 --- a/app/containers/MessageComposer/components/ComposerInput.tsx +++ b/app/containers/MessageComposer/components/ComposerInput.tsx @@ -152,7 +152,8 @@ export const ComposerInput = memo( })); const setInput: TSetInput = (text, selection) => { - textRef.current = text; + const message = text.trim(); + textRef.current = message; if (inputRef.current) { inputRef.current.setNativeProps({ text }); } @@ -163,7 +164,7 @@ export const ComposerInput = memo( selectionRef.current = selection; }, 50); } - setMicOrSend(text.length === 0 ? 'mic' : 'send'); + setMicOrSend(message.length === 0 ? 'mic' : 'send'); }; const focus = () => { diff --git a/app/containers/MessageComposer/constants.ts b/app/containers/MessageComposer/constants.ts index 1786de36f2..c35268c42e 100644 --- a/app/containers/MessageComposer/constants.ts +++ b/app/containers/MessageComposer/constants.ts @@ -6,13 +6,15 @@ export const IMAGE_PICKER_CONFIG = { cropping: true, avoidEmptySpaceAroundImage: false, freeStyleCropEnabled: true, - forceJpg: true + forceJpg: true, + includeExif: true }; export const LIBRARY_PICKER_CONFIG: Options = { multiple: true, compressVideoPreset: 'Passthrough', - mediaType: 'any' + mediaType: 'any', + includeExif: true }; export const VIDEO_PICKER_CONFIG: Options = { diff --git a/app/containers/UIKit/Image.tsx b/app/containers/UIKit/Image.tsx index 42ebc31bca..96c57a9315 100644 --- a/app/containers/UIKit/Image.tsx +++ b/app/containers/UIKit/Image.tsx @@ -3,7 +3,7 @@ import { StyleSheet, View } from 'react-native'; import FastImage from 'react-native-fast-image'; import { BlockContext } from '@rocket.chat/ui-kit'; -import ImageContainer from '../message/Image'; +import ImageContainer from '../message/Components/Attachments/Image'; import Navigation from '../../lib/navigation/appNavigation'; import { IThumb, IImage, IElement } from './interfaces'; import { IAttachment } from '../../definitions'; diff --git a/app/containers/message/Components/Attachments/AttachedActions.tsx b/app/containers/message/Components/Attachments/AttachedActions.tsx new file mode 100644 index 0000000000..f0185427b9 --- /dev/null +++ b/app/containers/message/Components/Attachments/AttachedActions.tsx @@ -0,0 +1,47 @@ +import React, { useContext } from 'react'; + +import Button from '../../../Button'; +import MessageContext from '../../Context'; +import { IAttachment, TGetCustomEmoji } from '../../../../definitions'; +import openLink from '../../../../lib/methods/helpers/openLink'; +import Markdown from '../../../markdown'; + +export type TElement = { + type: string; + msg?: string; + url?: string; + text: string; +}; + +const AttachedActions = ({ attachment, getCustomEmoji }: { attachment: IAttachment; getCustomEmoji: TGetCustomEmoji }) => { + const { onAnswerButtonPress } = useContext(MessageContext); + + if (!attachment.actions) { + return null; + } + + const attachedButtons = attachment.actions.map((element: TElement) => { + const onPress = () => { + if (element.msg) { + onAnswerButtonPress(element.msg); + } + + if (element.url) { + openLink(element.url); + } + }; + + if (element.type === 'button') { + return + ); + if (msg) { return ( - + {image} ); } - return ( - - ); + return image; }; ImageContainer.displayName = 'MessageImageContainer'; diff --git a/app/containers/message/Reply.tsx b/app/containers/message/Components/Attachments/Reply.tsx similarity index 88% rename from app/containers/message/Reply.tsx rename to app/containers/message/Components/Attachments/Reply.tsx index 22c2e28fb7..822a2c30e1 100644 --- a/app/containers/message/Reply.tsx +++ b/app/containers/message/Components/Attachments/Reply.tsx @@ -4,19 +4,19 @@ import React, { useContext, useState } from 'react'; import { StyleSheet, Text, View } from 'react-native'; import FastImage from 'react-native-fast-image'; -import { IAttachment, TGetCustomEmoji } from '../../definitions'; -import { themes } from '../../lib/constants'; -import { fileDownloadAndPreview } from '../../lib/methods/helpers'; -import { formatAttachmentUrl } from '../../lib/methods/helpers/formatAttachmentUrl'; -import openLink from '../../lib/methods/helpers/openLink'; -import { TSupportedThemes, useTheme } from '../../theme'; -import sharedStyles from '../../views/Styles'; -import RCActivityIndicator from '../ActivityIndicator'; -import Markdown from '../markdown'; +import { IAttachment, TGetCustomEmoji } from '../../../../definitions'; +import { themes } from '../../../../lib/constants'; +import { fileDownloadAndPreview } from '../../../../lib/methods/helpers'; +import { formatAttachmentUrl } from '../../../../lib/methods/helpers/formatAttachmentUrl'; +import openLink from '../../../../lib/methods/helpers/openLink'; +import { TSupportedThemes, useTheme } from '../../../../theme'; +import sharedStyles from '../../../../views/Styles'; +import RCActivityIndicator from '../../../ActivityIndicator'; +import Markdown from '../../../markdown'; import { Attachments } from './components'; -import MessageContext from './Context'; -import Touchable from './Touchable'; -import messageStyles from './styles'; +import MessageContext from '../../Context'; +import Touchable from '../../Touchable'; +import messageStyles from '../../styles'; const styles = StyleSheet.create({ button: { @@ -202,9 +202,9 @@ const Reply = React.memo( ({ attachment, timeFormat, index, getCustomEmoji, msg, showAttachment }: IMessageReply) => { const [loading, setLoading] = useState(false); const { theme } = useTheme(); - const { baseUrl, user } = useContext(MessageContext); + const { baseUrl, user, id, e2e, isEncrypted } = useContext(MessageContext); - if (!attachment) { + if (!attachment || (isEncrypted && !e2e)) { return null; } @@ -216,7 +216,7 @@ const Reply = React.memo( if (attachment.type === 'file' && attachment.title_link) { setLoading(true); url = formatAttachmentUrl(attachment.title_link, user.id, user.token, baseUrl); - await fileDownloadAndPreview(url, attachment); + await fileDownloadAndPreview(url, attachment, id); setLoading(false); return; } diff --git a/app/containers/message/Video.tsx b/app/containers/message/Components/Attachments/Video.tsx similarity index 54% rename from app/containers/message/Video.tsx rename to app/containers/message/Components/Attachments/Video.tsx index ff4d52eedd..42c432db83 100644 --- a/app/containers/message/Video.tsx +++ b/app/containers/message/Components/Attachments/Video.tsx @@ -1,30 +1,26 @@ -import React, { useContext, useEffect, useState } from 'react'; +import React, { useCallback, useContext, useEffect, useState } from 'react'; import { StyleProp, StyleSheet, Text, TextStyle, View } from 'react-native'; -import FastImage from 'react-native-fast-image'; - -import { IAttachment } from '../../definitions/IAttachment'; -import { TGetCustomEmoji } from '../../definitions/IEmoji'; -import I18n from '../../i18n'; -import { themes } from '../../lib/constants'; -import { fetchAutoDownloadEnabled } from '../../lib/methods/autoDownloadPreference'; -import { - cancelDownload, - downloadMediaFile, - getMediaCache, - isDownloadActive, - resumeMediaFile -} from '../../lib/methods/handleMediaDownload'; -import { fileDownload, isIOS } from '../../lib/methods/helpers'; -import EventEmitter from '../../lib/methods/helpers/events'; -import { formatAttachmentUrl } from '../../lib/methods/helpers/formatAttachmentUrl'; -import { useTheme } from '../../theme'; -import sharedStyles from '../../views/Styles'; -import { LISTENER } from '../Toast'; -import Markdown from '../markdown'; -import BlurComponent from './Components/OverlayComponent'; -import MessageContext from './Context'; -import Touchable from './Touchable'; -import { DEFAULT_MESSAGE_HEIGHT } from './utils'; + +import { IAttachment } from '../../../../definitions/IAttachment'; +import { TGetCustomEmoji } from '../../../../definitions/IEmoji'; +import I18n from '../../../../i18n'; +import { themes } from '../../../../lib/constants'; +import { fetchAutoDownloadEnabled } from '../../../../lib/methods/autoDownloadPreference'; +import { cancelDownload, downloadMediaFile, getMediaCache, isDownloadActive } from '../../../../lib/methods/handleMediaDownload'; +import { emitter, fileDownload, isIOS } from '../../../../lib/methods/helpers'; +import EventEmitter from '../../../../lib/methods/helpers/events'; +import { formatAttachmentUrl } from '../../../../lib/methods/helpers/formatAttachmentUrl'; +import { useTheme } from '../../../../theme'; +import sharedStyles from '../../../../views/Styles'; +import { LISTENER } from '../../../Toast'; +import Markdown from '../../../markdown'; +import BlurComponent from '../OverlayComponent'; +import MessageContext from '../../Context'; +import Touchable from '../../Touchable'; +import { DEFAULT_MESSAGE_HEIGHT } from '../../utils'; +import { TIconsName } from '../../../CustomIcon'; +import { useFile } from '../../hooks/useFile'; +import { IUserMessage } from '../../../../definitions'; const SUPPORTED_TYPES = ['video/quicktime', 'video/mp4', ...(isIOS ? [] : ['video/3gp', 'video/mkv'])]; const isTypeSupported = (type: string) => SUPPORTED_TYPES.indexOf(type) !== -1; @@ -46,11 +42,6 @@ const styles = StyleSheet.create({ text: { ...sharedStyles.textRegular, fontSize: 12 - }, - thumbnailImage: { - borderRadius: 4, - width: '100%', - height: '100%' } }); @@ -58,6 +49,7 @@ interface IMessageVideo { file: IAttachment; showAttachment?: (file: IAttachment) => void; getCustomEmoji: TGetCustomEmoji; + author?: IUserMessage; style?: StyleProp[]; isReply?: boolean; msg?: string; @@ -72,20 +64,33 @@ const CancelIndicator = () => { ); }; -// TODO: Wait backend send the thumbnailUrl as prop -const Thumbnail = ({ loading, thumbnailUrl, cached }: { loading: boolean; thumbnailUrl?: string; cached: boolean }) => ( - <> - {thumbnailUrl ? : null} - - {loading ? : null} - -); - -const Video = ({ file, showAttachment, getCustomEmoji, style, isReply, msg }: IMessageVideo): React.ReactElement | null => { - const [videoCached, setVideoCached] = useState(file); +const Thumbnail = ({ loading, cached, encrypted = false }: { loading: boolean; cached: boolean; encrypted: boolean }) => { + let icon: TIconsName = cached ? 'play-filled' : 'arrow-down-circle'; + if (encrypted && !loading && cached) { + icon = 'encrypted'; + } + + return ( + <> + + {loading ? : null} + + ); +}; + +const Video = ({ + file, + showAttachment, + getCustomEmoji, + author, + style, + isReply, + msg +}: IMessageVideo): React.ReactElement | null => { + const { id, baseUrl, user } = useContext(MessageContext); + const [videoCached, setVideoCached] = useFile(file, id); const [loading, setLoading] = useState(true); const [cached, setCached] = useState(false); - const { baseUrl, user } = useContext(MessageContext); const { theme } = useTheme(); const video = formatAttachmentUrl(file.video_url, user.id, user.token, baseUrl); @@ -101,9 +106,19 @@ const Video = ({ file, showAttachment, getCustomEmoji, style, isReply, msg }: IM return; } await handleAutoDownload(); + setLoading(false); } }; handleVideoSearchAndDownload(); + + return () => { + emitter.off(`downloadMedia${id}`, downloadMediaListener); + }; + }, []); + + const downloadMediaListener = useCallback((uri: string) => { + updateVideoCached(uri); + setLoading(false); }, []); if (!baseUrl) { @@ -111,8 +126,9 @@ const Video = ({ file, showAttachment, getCustomEmoji, style, isReply, msg }: IM } const handleAutoDownload = async () => { + const isCurrentUserAuthor = author?._id === user.id; const isAutoDownloadEnabled = fetchAutoDownloadEnabled('videoPreferenceDownload'); - if (isAutoDownloadEnabled && file.video_type && isTypeSupported(file.video_type)) { + if ((isAutoDownloadEnabled || isCurrentUserAuthor) && file.video_type && isTypeSupported(file.video_type)) { await handleDownload(); return; } @@ -120,11 +136,17 @@ const Video = ({ file, showAttachment, getCustomEmoji, style, isReply, msg }: IM }; const updateVideoCached = (videoUri: string) => { - setVideoCached(prev => ({ - ...prev, - video_url: videoUri - })); + setVideoCached({ video_url: videoUri }); setCached(true); + setLoading(false); + }; + + const setDecrypted = () => { + if (videoCached.e2e === 'pending') { + setVideoCached({ + e2e: 'done' + }); + } }; const handleGetMediaCache = async () => { @@ -133,51 +155,42 @@ const Video = ({ file, showAttachment, getCustomEmoji, style, isReply, msg }: IM mimeType: file.video_type, urlToCache: video }); - if (cachedVideoResult?.exists) { + const result = !!cachedVideoResult?.exists && videoCached.e2e !== 'pending'; + if (result) { updateVideoCached(cachedVideoResult.uri); - setLoading(false); } - return !!cachedVideoResult?.exists; + return result; }; - const handleResumeDownload = async () => { - try { - setLoading(true); - const videoUri = await resumeMediaFile({ - downloadUrl: video - }); - updateVideoCached(videoUri); - } catch (e) { - setCached(false); - } finally { - setLoading(false); - } + const handleResumeDownload = () => { + emitter.on(`downloadMedia${id}`, downloadMediaListener); }; const handleDownload = async () => { - setLoading(true); try { const videoUri = await downloadMediaFile({ + messageId: id, downloadUrl: video, type: 'video', - mimeType: file.video_type + mimeType: file.video_type, + encryption: file.encryption, + originalChecksum: file.hashes?.sha256 }); + setDecrypted(); updateVideoCached(videoUri); } catch { setCached(false); - } finally { - setLoading(false); } }; const onPress = async () => { - if (file.video_type && cached && isTypeSupported(file.video_type) && showAttachment) { + if (file.video_type && cached && isTypeSupported(file.video_type) && showAttachment && videoCached.video_url) { showAttachment(videoCached); return; } if (!loading && !cached && file.video_type && isTypeSupported(file.video_type)) { const isVideoCached = await handleGetMediaCache(); - if (isVideoCached && showAttachment) { + if (isVideoCached && showAttachment && videoCached.video_url) { showAttachment(videoCached); return; } @@ -185,6 +198,7 @@ const Video = ({ file, showAttachment, getCustomEmoji, style, isReply, msg }: IM handleResumeDownload(); return; } + setLoading(true); handleDownload(); return; } @@ -224,9 +238,8 @@ const Video = ({ file, showAttachment, getCustomEmoji, style, isReply, msg }: IM - + background={Touchable.Ripple(themes[theme].surfaceNeutral)}> + ); diff --git a/app/containers/message/components.ts b/app/containers/message/Components/Attachments/components.ts similarity index 100% rename from app/containers/message/components.ts rename to app/containers/message/Components/Attachments/components.ts diff --git a/app/containers/message/Components/Attachments/index.ts b/app/containers/message/Components/Attachments/index.ts new file mode 100644 index 0000000000..b103242946 --- /dev/null +++ b/app/containers/message/Components/Attachments/index.ts @@ -0,0 +1,3 @@ +import Attachments from './Attachments'; + +export default Attachments; diff --git a/app/containers/message/Message.tsx b/app/containers/message/Message.tsx index e4322b2665..93efa8237e 100644 --- a/app/containers/message/Message.tsx +++ b/app/containers/message/Message.tsx @@ -7,7 +7,7 @@ import User from './User'; import styles from './styles'; import RepliedThread from './RepliedThread'; import MessageAvatar from './MessageAvatar'; -import Attachments from './Attachments'; +import Attachments from './Components/Attachments'; import Urls from './Urls'; import Thread from './Thread'; import Blocks from './Blocks'; diff --git a/app/containers/message/hooks/useFile.tsx b/app/containers/message/hooks/useFile.tsx new file mode 100644 index 0000000000..2e5d34eb32 --- /dev/null +++ b/app/containers/message/hooks/useFile.tsx @@ -0,0 +1,26 @@ +import { useEffect, useState } from 'react'; + +import { IAttachment } from '../../../definitions'; +import { getMessageById } from '../../../lib/database/services/Message'; + +export const useFile = (file: IAttachment, messageId: string) => { + const [localFile, setLocalFile] = useState(file); + const [isMessagePersisted, setIsMessagePersisted] = useState(!!messageId); + useEffect(() => { + const checkMessage = async () => { + const message = await getMessageById(messageId); + if (!message) { + setIsMessagePersisted(false); + } + }; + checkMessage(); + }, [messageId]); + + const manageForwardedFile = (f: Partial) => { + if (isMessagePersisted) { + return; + } + setLocalFile(prev => ({ ...prev, ...f })); + }; + return [isMessagePersisted ? file : localFile, manageForwardedFile] as const; +}; diff --git a/app/containers/message/index.tsx b/app/containers/message/index.tsx index 0024abebc8..ce706a9bdc 100644 --- a/app/containers/message/index.tsx +++ b/app/containers/message/index.tsx @@ -429,9 +429,9 @@ class MessageContainer extends React.Component + translateLanguage: canTranslateMessage ? autoTranslateLanguage : undefined, + isEncrypted: this.isEncrypted + }}> {/* @ts-ignore*/} ; + content?: IMessageE2EEContent; } export interface IShareAttachment { @@ -69,4 +84,9 @@ export interface IShareAttachment { canUpload: boolean; error?: any; uri: string; + width?: number; + height?: number; + exif?: { + Orientation: string; + }; } diff --git a/app/definitions/IMessage.ts b/app/definitions/IMessage.ts index 996ec0c4f9..5271140322 100644 --- a/app/definitions/IMessage.ts +++ b/app/definitions/IMessage.ts @@ -80,6 +80,11 @@ interface IMessageFile { type: string; } +export type IMessageE2EEContent = { + algorithm: 'rc.v1.aes-sha2'; + ciphertext: string; // Encrypted subset JSON of IMessage +}; + export interface IMessageFromServer { _id: string; rid: string; @@ -107,6 +112,7 @@ export interface IMessageFromServer { username: string; }; score?: number; + content?: IMessageE2EEContent; } export interface ILoadMoreMessage { diff --git a/app/definitions/IUpload.ts b/app/definitions/IUpload.ts index a2935575b3..9bd76ab7c7 100644 --- a/app/definitions/IUpload.ts +++ b/app/definitions/IUpload.ts @@ -14,6 +14,16 @@ export interface IUpload { error?: boolean; subscription?: { id: string }; msg?: string; + width?: number; + height?: number; } -export type TUploadModel = IUpload & Model; +export type TUploadModel = IUpload & + Model & { + asPlain: () => IUpload; + }; + +export type TSendFileMessageFileInfo = Pick< + IUpload, + 'rid' | 'path' | 'name' | 'tmid' | 'description' | 'size' | 'type' | 'msg' | 'width' | 'height' +>; diff --git a/app/definitions/rest/v1/rooms.ts b/app/definitions/rest/v1/rooms.ts index ebcb29cd41..0343a298a4 100644 --- a/app/definitions/rest/v1/rooms.ts +++ b/app/definitions/rest/v1/rooms.ts @@ -45,3 +45,7 @@ export type RoomsEndpoints = { POST: (params: { roomId: string; notifications: IRoomNotifications }) => {}; }; }; + +export type TRoomsMediaResponse = { + file: { _id: string; url: string }; +}; diff --git a/app/i18n/locales/en.json b/app/i18n/locales/en.json index 09f4e7d619..46b2cde2f0 100644 --- a/app/i18n/locales/en.json +++ b/app/i18n/locales/en.json @@ -254,6 +254,7 @@ "Enable_writing_in_room": "Enable writing in room", "Enabled_E2E_Encryption_for_this_room": "enabled E2E encryption for this room", "Encrypted": "Encrypted", + "Encrypted_file": "Encrypted file", "Encrypted_message": "Encrypted message", "encrypted_room_description": "Enter your end-to-end encryption password to access.", "encrypted_room_title": "{{room_name}} is encrypted", @@ -265,6 +266,7 @@ "Enter_the_code": "Enter the code we just emailed you.", "Enter_workspace_URL": "Enter workspace URL", "Error_Download_file": "Error while downloading file", + "Error_play_video": "There was an error while playing this video", "Error_uploading": "Error uploading", "error-action-not-allowed": "{{action}} is not allowed", "error-avatar-invalid-url": "Invalid avatar URL: {{url}}", diff --git a/app/i18n/locales/pt-BR.json b/app/i18n/locales/pt-BR.json index aabe6935c8..7af4a558ed 100644 --- a/app/i18n/locales/pt-BR.json +++ b/app/i18n/locales/pt-BR.json @@ -249,6 +249,7 @@ "Enable_writing_in_room": "Permitir escrita na sala", "Enabled_E2E_Encryption_for_this_room": "habilitou criptografia para essa sala", "Encrypted": "Criptografado", + "Encrypted_file": "Arquivo criptografado", "Encrypted_message": "Mensagem criptografada", "encrypted_room_description": "Insira sua senha de criptografia de ponta a ponta para acessar.", "encrypted_room_title": "{{room_name}} está criptografado", @@ -260,6 +261,7 @@ "Enter_the_code": "Insira o código que acabamos de enviar por e-mail.", "Enter_workspace_URL": "Digite a URL da sua workspace", "Error_Download_file": "Erro ao baixar o arquivo", + "Error_play_video": "Houve um erro ao reproduzir esse vídeo", "Error_uploading": "Erro subindo", "error-action-not-allowed": "{{action}} não é permitido", "error-avatar-invalid-url": "URL inválida de avatar: {{url}}", diff --git a/app/lib/database/model/Message.js b/app/lib/database/model/Message.js index 5e452b43ca..336f9c3935 100644 --- a/app/lib/database/model/Message.js +++ b/app/lib/database/model/Message.js @@ -84,6 +84,8 @@ export default class Message extends Model { @json('md', sanitizer) md; + @json('content', sanitizer) content; + @field('comment') comment; asPlain() { @@ -125,6 +127,7 @@ export default class Message extends Model { e2e: this.e2e, tshow: this.tshow, md: this.md, + content: this.content, comment: this.comment }; } diff --git a/app/lib/database/model/Thread.js b/app/lib/database/model/Thread.js index 947493b6b7..a04e589bbd 100644 --- a/app/lib/database/model/Thread.js +++ b/app/lib/database/model/Thread.js @@ -74,6 +74,8 @@ export default class Thread extends Model { @json('translations', sanitizer) translations; + @json('content', sanitizer) content; + @field('e2e') e2e; @field('draft_message') draftMessage; @@ -112,7 +114,8 @@ export default class Thread extends Model { autoTranslate: this.autoTranslate, translations: this.translations, e2e: this.e2e, - draftMessage: this.draftMessage + draftMessage: this.draftMessage, + content: this.content }; } } diff --git a/app/lib/database/model/ThreadMessage.js b/app/lib/database/model/ThreadMessage.js index 0d2ad16bf8..8bb364b7ed 100644 --- a/app/lib/database/model/ThreadMessage.js +++ b/app/lib/database/model/ThreadMessage.js @@ -78,6 +78,8 @@ export default class ThreadMessage extends Model { @field('e2e') e2e; + @json('content', sanitizer) content; + asPlain() { return { id: this.id, @@ -112,7 +114,8 @@ export default class ThreadMessage extends Model { autoTranslate: this.autoTranslate, translations: this.translations, draftMessage: this.draftMessage, - e2e: this.e2e + e2e: this.e2e, + content: this.content }; } } diff --git a/app/lib/database/model/Upload.js b/app/lib/database/model/Upload.js index a96e2c9954..bfb91655ea 100644 --- a/app/lib/database/model/Upload.js +++ b/app/lib/database/model/Upload.js @@ -29,4 +29,18 @@ export default class Upload extends Model { @field('progress') progress; @field('error') error; + + asPlain() { + return { + id: this.id, + rid: this.subscription.id, + path: this.path, + name: this.name, + tmid: this.tmid, + description: this.description, + size: this.size, + type: this.type, + store: this.store + }; + } } diff --git a/app/lib/database/model/migrations.js b/app/lib/database/model/migrations.js index 37b0b5123c..a40ceb0a5b 100644 --- a/app/lib/database/model/migrations.js +++ b/app/lib/database/model/migrations.js @@ -293,6 +293,23 @@ export default schemaMigrations({ columns: [{ name: 'disable_notifications', type: 'boolean', isOptional: true }] }) ] + }, + { + toVersion: 25, + steps: [ + addColumns({ + table: 'messages', + columns: [{ name: 'content', type: 'string', isOptional: true }] + }), + addColumns({ + table: 'threads', + columns: [{ name: 'content', type: 'string', isOptional: true }] + }), + addColumns({ + table: 'thread_messages', + columns: [{ name: 'content', type: 'string', isOptional: true }] + }) + ] } ] }); diff --git a/app/lib/database/schema/app.js b/app/lib/database/schema/app.js index b0a99752bc..73220f11de 100644 --- a/app/lib/database/schema/app.js +++ b/app/lib/database/schema/app.js @@ -1,7 +1,7 @@ import { appSchema, tableSchema } from '@nozbe/watermelondb'; export default appSchema({ - version: 24, + version: 25, tables: [ tableSchema({ name: 'subscriptions', @@ -125,6 +125,7 @@ export default appSchema({ { name: 'e2e', type: 'string', isOptional: true }, { name: 'tshow', type: 'boolean', isOptional: true }, { name: 'md', type: 'string', isOptional: true }, + { name: 'content', type: 'string', isOptional: true }, { name: 'comment', type: 'string', isOptional: true } ] }), @@ -163,6 +164,7 @@ export default appSchema({ { name: 'auto_translate', type: 'boolean', isOptional: true }, { name: 'translations', type: 'string', isOptional: true }, { name: 'e2e', type: 'string', isOptional: true }, + { name: 'content', type: 'string', isOptional: true }, { name: 'draft_message', type: 'string', isOptional: true } ] }), @@ -200,7 +202,8 @@ export default appSchema({ { name: 'unread', type: 'boolean', isOptional: true }, { name: 'auto_translate', type: 'boolean', isOptional: true }, { name: 'translations', type: 'string', isOptional: true }, - { name: 'e2e', type: 'string', isOptional: true } + { name: 'e2e', type: 'string', isOptional: true }, + { name: 'content', type: 'string', isOptional: true } ] }), tableSchema({ diff --git a/app/lib/database/services/Upload.ts b/app/lib/database/services/Upload.ts new file mode 100644 index 0000000000..4da1ece9f8 --- /dev/null +++ b/app/lib/database/services/Upload.ts @@ -0,0 +1,16 @@ +import database from '..'; +import { TAppDatabase } from '../interfaces'; +import { UPLOADS_TABLE } from '../model'; + +const getCollection = (db: TAppDatabase) => db.get(UPLOADS_TABLE); + +export const getUploadByPath = async (path: string) => { + const db = database.active; + const uploadCollection = getCollection(db); + try { + const result = await uploadCollection.find(path); + return result; + } catch (error) { + return null; + } +}; diff --git a/app/lib/encryption/constants.ts b/app/lib/encryption/constants.ts index 15da8e6efa..88901431c8 100644 --- a/app/lib/encryption/constants.ts +++ b/app/lib/encryption/constants.ts @@ -1 +1,2 @@ export const LEARN_MORE_E2EE_URL = 'https://go.rocket.chat/i/e2ee-guide'; +export const MAX_CONCURRENT_QUEUE = 5; diff --git a/app/lib/encryption/definitions.ts b/app/lib/encryption/definitions.ts new file mode 100644 index 0000000000..ba50fc8c7f --- /dev/null +++ b/app/lib/encryption/definitions.ts @@ -0,0 +1,29 @@ +import { TAttachmentEncryption, TSendFileMessageFileInfo } from '../../definitions'; + +export type TGetContentResult = { + algorithm: 'rc.v1.aes-sha2'; + ciphertext: string; +}; + +export type TGetContent = (_id: string, fileUrl: string) => Promise; + +export type TEncryptFileResult = Promise<{ + file: TSendFileMessageFileInfo; + getContent?: TGetContent; + fileContent?: TGetContentResult; +}>; + +export type TEncryptFile = (rid: string, file: TSendFileMessageFileInfo) => TEncryptFileResult; + +export type TDecryptFile = ( + messageId: string, + path: string, + encryption: TAttachmentEncryption, + originalChecksum: string +) => Promise; + +export interface IDecryptionFileQueue { + params: Parameters; + resolve: (value: string | null | PromiseLike) => void; + reject: (reason?: any) => void; +} diff --git a/app/lib/encryption/encryption.ts b/app/lib/encryption/encryption.ts index 7cdf4b9a0c..128233bd1c 100644 --- a/app/lib/encryption/encryption.ts +++ b/app/lib/encryption/encryption.ts @@ -2,16 +2,28 @@ import EJSON from 'ejson'; import SimpleCrypto from 'react-native-simple-crypto'; import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord'; import { Q, Model } from '@nozbe/watermelondb'; +import { deleteAsync } from 'expo-file-system'; import UserPreferences from '../methods/userPreferences'; +import { getMessageById } from '../database/services/Message'; +import { getSubscriptionByRoomId } from '../database/services/Subscription'; import database from '../database'; import protectedFunction from '../methods/helpers/protectedFunction'; import Deferred from './helpers/deferred'; import log from '../methods/helpers/log'; import { store } from '../store/auxStore'; -import { joinVectorData, randomPassword, splitVectorData, toString, utf8ToBuffer } from './utils'; +import { decryptAESCTR, joinVectorData, randomPassword, splitVectorData, toString, utf8ToBuffer } from './utils'; +import { + IMessage, + ISubscription, + TSendFileMessageFileInfo, + TMessageModel, + TSubscriptionModel, + TThreadMessageModel, + TThreadModel, + IServerAttachment +} from '../../definitions'; import EncryptionRoom from './room'; -import { IMessage, ISubscription, TMessageModel, TSubscriptionModel, TThreadMessageModel, TThreadModel } from '../../definitions'; import { E2E_BANNER_TYPE, E2E_MESSAGE_TYPE, @@ -21,6 +33,8 @@ import { E2E_STATUS } from '../constants'; import { Services } from '../services'; +import { IDecryptionFileQueue, TDecryptFile, TEncryptFile } from './definitions'; +import { MAX_CONCURRENT_QUEUE } from './constants'; class Encryption { ready: boolean; @@ -33,10 +47,16 @@ class Encryption { provideKeyToUser: Function; handshake: Function; decrypt: Function; + decryptFileContent: Function; encrypt: Function; + encryptText: Function; + encryptFile: TEncryptFile; + encryptUpload: Function; importRoomKey: Function; }; }; + decryptionFileQueue: IDecryptionFileQueue[]; + decryptionFileQueueActiveCount: number; constructor() { this.userId = ''; @@ -51,6 +71,8 @@ class Encryption { .catch(() => { this.ready = false; }); + this.decryptionFileQueue = []; + this.decryptionFileQueueActiveCount = 0; } // Initialize Encryption client @@ -68,9 +90,9 @@ class Encryption { }; get establishing() { - const { banner } = store.getState().encryption; + const { banner, enabled } = store.getState().encryption; // If the password was not inserted yet - if (!banner || banner === E2E_BANNER_TYPE.REQUEST_PASSWORD) { + if (!enabled || banner === E2E_BANNER_TYPE.REQUEST_PASSWORD) { // We can't decrypt/encrypt, so, reject this try return Promise.reject(); } @@ -275,7 +297,7 @@ class Encryption { ]; toDecrypt = (await Promise.all( toDecrypt.map(async message => { - const { t, msg, tmsg } = message; + const { t, msg, tmsg, attachments, content } = message; let newMessage: TMessageModel = {} as TMessageModel; if (message.subscription) { const { id: rid } = message.subscription; @@ -284,7 +306,9 @@ class Encryption { t, rid, msg: msg as string, - tmsg + tmsg, + attachments, + content }); } @@ -316,17 +340,9 @@ class Encryption { try { // Find all rooms that can have a lastMessage encrypted // If we select only encrypted rooms we can miss some room that changed their encrypted status - const subsEncrypted = await subCollection.query(Q.where('e2e_key_id', Q.notEq(null))).fetch(); - // We can't do this on database level since lastMessage is not a database object - const subsToDecrypt = subsEncrypted.filter( - (sub: ISubscription) => - // Encrypted message - sub?.lastMessage?.t === E2E_MESSAGE_TYPE && - // Message pending decrypt - sub?.lastMessage?.e2e === E2E_STATUS.PENDING - ); + const subsEncrypted = await subCollection.query(Q.where('e2e_key_id', Q.notEq(null)), Q.where('encrypted', true)).fetch(); await Promise.all( - subsToDecrypt.map(async (sub: TSubscriptionModel) => { + subsEncrypted.map(async (sub: TSubscriptionModel) => { const { rid, lastMessage } = sub; const newSub = await this.decryptSubscription({ rid, lastMessage }); try { @@ -342,7 +358,7 @@ class Encryption { ); await db.write(async () => { - await db.batch(...subsToDecrypt); + await db.batch(...subsEncrypted); }); } catch (e) { log(e); @@ -436,6 +452,11 @@ class Encryption { }; }; + encryptText = async (rid: string, text: string) => { + const roomE2E = await this.getRoomInstance(rid); + return roomE2E.encryptText(text); + }; + // Encrypt a message encryptMessage = async (message: IMessage) => { const { rid } = message; @@ -470,7 +491,7 @@ class Encryption { }; // Decrypt a message - decryptMessage = async (message: Pick) => { + decryptMessage = async (message: Pick) => { const { t, e2e } = message; // Prevent create a new instance if this room was encrypted sometime ago @@ -495,12 +516,105 @@ class Encryption { return roomE2E.decrypt(message); }; + decryptFileContent = async (file: IServerAttachment) => { + const roomE2E = await this.getRoomInstance(file.rid); + + if (!roomE2E) { + return file; + } + + return roomE2E.decryptFileContent(file); + }; + + encryptFile = async (rid: string, file: TSendFileMessageFileInfo) => { + const subscription = await getSubscriptionByRoomId(rid); + if (!subscription) { + throw new Error('Subscription not found'); + } + + if (!subscription.encrypted) { + // Send a non encrypted message + return { file }; + } + + // If the client is not ready + if (!this.ready) { + // Wait for ready status + await this.establishing; + } + + const roomE2E = await this.getRoomInstance(rid); + return roomE2E.encryptFile(rid, file); + }; + + decryptFile: TDecryptFile = async (messageId, path, encryption, originalChecksum) => { + const messageRecord = await getMessageById(messageId); + const decryptedFile = await decryptAESCTR(path, encryption.key.k, encryption.iv); + if (decryptedFile) { + const checksum = await SimpleCrypto.utils.calculateFileChecksum(decryptedFile); + if (checksum !== originalChecksum) { + await deleteAsync(decryptedFile); + return null; + } + + if (messageRecord) { + const db = database.active; + await db.write(async () => { + await messageRecord.update(m => { + m.attachments = m.attachments?.map(att => ({ + ...att, + title_link: decryptedFile, + e2e: 'done' + })); + }); + }); + } + } + return decryptedFile; + }; + + addFileToDecryptFileQueue: TDecryptFile = (messageId, path, encryption, originalChecksum) => + new Promise((resolve, reject) => { + this.decryptionFileQueue.push({ + params: [messageId, path, encryption, originalChecksum], + resolve, + reject + }); + this.processFileQueue(); + }); + + async processFileQueue() { + if (this.decryptionFileQueueActiveCount >= MAX_CONCURRENT_QUEUE || this.decryptionFileQueue.length === 0) { + return; + } + const queueItem = this.decryptionFileQueue.shift(); + // FIXME: TS not getting decryptionFileQueue is not empty. TS 5.5 fix? + if (!queueItem) { + return; + } + const { params, resolve, reject } = queueItem; + this.decryptionFileQueueActiveCount += 1; + + try { + const result = await this.decryptFile(...params); + resolve(result); + } catch (error) { + reject(error); + } finally { + this.decryptionFileQueueActiveCount -= 1; + this.processFileQueue(); + } + } + // Decrypt multiple messages decryptMessages = (messages: Partial[]) => Promise.all(messages.map((m: Partial) => this.decryptMessage(m as IMessage))); // Decrypt multiple subscriptions decryptSubscriptions = (subscriptions: ISubscription[]) => Promise.all(subscriptions.map(s => this.decryptSubscription(s))); + + // Decrypt multiple files + decryptFiles = (files: IServerAttachment[]) => Promise.all(files.map(f => this.decryptFileContent(f))); } const encryption = new Encryption(); diff --git a/app/lib/encryption/room.ts b/app/lib/encryption/room.ts index 2088e70a20..10cd50fda0 100644 --- a/app/lib/encryption/room.ts +++ b/app/lib/encryption/room.ts @@ -3,9 +3,10 @@ import { Base64 } from 'js-base64'; import SimpleCrypto from 'react-native-simple-crypto'; import ByteBuffer from 'bytebuffer'; import parse from 'url-parse'; +import { sha256 } from 'js-sha256'; import getSingleMessage from '../methods/getSingleMessage'; -import { IMessage, IUser } from '../../definitions'; +import { IAttachment, IMessage, IUpload, TSendFileMessageFileInfo, IUser, IServerAttachment } from '../../definitions'; import Deferred from './helpers/deferred'; import { debounce } from '../methods/helpers'; import database from '../database'; @@ -15,6 +16,10 @@ import { bufferToB64, bufferToB64URI, bufferToUtf8, + encryptAESCTR, + exportAESCTR, + generateAESCTRKey, + getFileExtension, joinVectorData, splitVectorData, toString, @@ -28,6 +33,7 @@ import { mapMessageFromAPI } from './helpers/mapMessageFromApi'; import { mapMessageFromDB } from './helpers/mapMessageFromDB'; import { createQuoteAttachment } from './helpers/createQuoteAttachment'; import { getMessageById } from '../database/services/Message'; +import { TEncryptFileResult, TGetContent } from './definitions'; export default class EncryptionRoom { ready: boolean; @@ -238,7 +244,15 @@ export default class EncryptionRoom { ...message, t: E2E_MESSAGE_TYPE, e2e: E2E_STATUS.PENDING, - msg + msg, + content: { + algorithm: 'rc.v1.aes-sha2' as const, + ciphertext: await this.encryptText( + EJSON.stringify({ + msg: message.msg + }) + ) + } }; } catch { // Do nothing @@ -247,8 +261,151 @@ export default class EncryptionRoom { return message; }; + // Encrypt upload + encryptUpload = async (message: IUpload) => { + if (!this.ready) { + return message; + } + + try { + let description = ''; + + if (message.description) { + description = await this.encryptText(EJSON.stringify({ text: message.description })); + } + + return { + ...message, + t: E2E_MESSAGE_TYPE, + e2e: E2E_STATUS.PENDING, + description + }; + } catch { + // Do nothing + } + + return message; + }; + + encryptFile = async (rid: string, file: TSendFileMessageFileInfo): TEncryptFileResult => { + const { path } = file; + const vector = await SimpleCrypto.utils.randomBytes(16); + const key = await generateAESCTRKey(); + const exportedKey = await exportAESCTR(key); + const iv = bufferToB64(vector); + const checksum = await SimpleCrypto.utils.calculateFileChecksum(path); + const encryptedFile = await encryptAESCTR(path, exportedKey.k, iv); + + const getContent: TGetContent = async (_id, fileUrl) => { + const attachments: IAttachment[] = []; + let att: IAttachment = { + title: file.name, + type: 'file', + description: file.description, + title_link: fileUrl, + title_link_download: true, + encryption: { + key: exportedKey, + iv: bufferToB64(vector) + }, + hashes: { + sha256: checksum + } + }; + if (file.type && /^image\/.+/.test(file.type)) { + att = { + ...att, + image_url: fileUrl, + image_type: file.type, + image_size: file.size, + ...(file.width && + file.height && { + image_dimensions: { + width: file.width, + height: file.height + } + }) + }; + } else if (file.type && /^audio\/.+/.test(file.type)) { + att = { + ...att, + audio_url: fileUrl, + audio_type: file.type, + audio_size: file.size + }; + } else if (file.type && /^video\/.+/.test(file.type)) { + att = { + ...att, + video_url: fileUrl, + video_type: file.type, + video_size: file.size + }; + } else { + att = { + ...att, + size: file.size, + format: getFileExtension(file.path) + }; + } + attachments.push(att); + + const files = [ + { + _id, + name: file.name, + type: file.type, + size: file.size + } + ]; + + const data = EJSON.stringify({ + attachments, + files, + file: files[0] + }); + + return { + algorithm: 'rc.v1.aes-sha2', + ciphertext: await Encryption.encryptText(rid, data) + }; + }; + + const fileContentData = { + type: file.type, + typeGroup: file.type?.split('/')[0], + name: file.name, + encryption: { + key: exportedKey, + iv + }, + hashes: { + sha256: checksum + } + }; + + const fileContent = { + algorithm: 'rc.v1.aes-sha2' as const, + ciphertext: await Encryption.encryptText(rid, EJSON.stringify(fileContentData)) + }; + + return { + file: { + ...file, + type: 'file', + name: sha256(file.name ?? 'File message'), + path: encryptedFile + }, + getContent, + fileContent + }; + }; + // Decrypt text decryptText = async (msg: string | ArrayBuffer) => { + if (!msg) { + return null; + } + msg = b64ToBuffer(msg.slice(12) as string); const [vector, cipherText] = splitVectorData(msg); @@ -259,6 +416,25 @@ export default class EncryptionRoom { return m.text; }; + decryptFileContent = async (data: IServerAttachment) => { + if (data.content?.algorithm === 'rc.v1.aes-sha2') { + const content = await this.decryptContent(data.content.ciphertext); + Object.assign(data, content); + } + return data; + }; + + decryptContent = async (contentBase64: string) => { + if (!contentBase64) { + return null; + } + + const contentBuffer = b64ToBuffer(contentBase64.slice(12) as string); + const [vector, cipherText] = splitVectorData(contentBuffer); + const decrypted = await SimpleCrypto.AES.decrypt(cipherText, this.roomKey, vector); + return EJSON.parse(bufferToUtf8(decrypted)); + }; + // Decrypt messages decrypt = async (message: IMessage) => { if (!this.ready) { @@ -270,19 +446,31 @@ export default class EncryptionRoom { // If message type is e2e and it's encrypted still if (t === E2E_MESSAGE_TYPE && e2e !== E2E_STATUS.DONE) { - let { msg, tmsg } = message; + const { msg, tmsg } = message; // Decrypt msg - msg = await this.decryptText(msg as string); + if (msg) { + message.msg = await this.decryptText(msg); + } // Decrypt tmsg if (tmsg) { - tmsg = await this.decryptText(tmsg); + message.tmsg = await this.decryptText(tmsg); + } + + if (message.content?.algorithm === 'rc.v1.aes-sha2') { + const content = await this.decryptContent(message.content.ciphertext); + message = { + ...message, + ...content, + attachments: content.attachments?.map((att: IAttachment) => ({ + ...att, + e2e: 'pending' + })) + }; } const decryptedMessage: IMessage = { ...message, - tmsg, - msg, e2e: 'done' }; diff --git a/app/lib/encryption/utils.ts b/app/lib/encryption/utils.ts index 3df3ce48a3..a36e62c4ca 100644 --- a/app/lib/encryption/utils.ts +++ b/app/lib/encryption/utils.ts @@ -1,9 +1,10 @@ import ByteBuffer from 'bytebuffer'; import SimpleCrypto from 'react-native-simple-crypto'; -import { random } from '../methods/helpers'; +import { compareServerVersion, random } from '../methods/helpers'; import { fromByteArray, toByteArray } from './helpers/base64-js'; import { TSubscriptionModel } from '../../definitions'; +import { store } from '../store/auxStore'; const BASE64URI = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; @@ -60,6 +61,39 @@ export const toString = (thing: string | ByteBuffer | Buffer | ArrayBuffer | Uin }; export const randomPassword = (): string => `${random(3)}-${random(3)}-${random(3)}`.toLowerCase(); +export const generateAESCTRKey = () => SimpleCrypto.utils.randomBytes(32); + +interface IExportedKey { + kty: string; + alg: string; + k: string; + ext: boolean; + key_ops: string[]; +} + +export const exportAESCTR = (key: ArrayBuffer): IExportedKey => { + // Web Crypto format of a Secret Key + const exportedKey = { + // Type of Secret Key + kty: 'oct', + // Algorithm + alg: 'A256CTR', + // Base64URI encoded array of bytes + k: bufferToB64URI(key), + // Specific Web Crypto properties + ext: true, + key_ops: ['encrypt', 'decrypt'] + }; + + return exportedKey; +}; + +export const encryptAESCTR = (path: string, key: string, vector: string): Promise => + SimpleCrypto.AES.encryptFile(path, key, vector); + +export const decryptAESCTR = (path: string, key: string, vector: string): Promise => + SimpleCrypto.AES.decryptFile(path, key, vector); + // Missing room encryption key export const isMissingRoomE2EEKey = ({ encryptionEnabled, @@ -69,7 +103,13 @@ export const isMissingRoomE2EEKey = ({ encryptionEnabled: boolean; roomEncrypted: TSubscriptionModel['encrypted']; E2EKey: TSubscriptionModel['E2EKey']; -}): boolean => (encryptionEnabled && roomEncrypted && !E2EKey) ?? false; +}): boolean => { + const serverVersion = store.getState().server.version; + if (compareServerVersion(serverVersion, 'lowerThan', '6.10.0')) { + return false; + } + return (encryptionEnabled && roomEncrypted && !E2EKey) ?? false; +}; // Encrypted room, but user session is not encrypted export const isE2EEDisabledEncryptedRoom = ({ @@ -78,7 +118,13 @@ export const isE2EEDisabledEncryptedRoom = ({ }: { encryptionEnabled: boolean; roomEncrypted: TSubscriptionModel['encrypted']; -}): boolean => (!encryptionEnabled && roomEncrypted) ?? false; +}): boolean => { + const serverVersion = store.getState().server.version; + if (compareServerVersion(serverVersion, 'lowerThan', '6.10.0')) { + return false; + } + return (!encryptionEnabled && roomEncrypted) ?? false; +}; export const hasE2EEWarning = ({ encryptionEnabled, @@ -97,3 +143,18 @@ export const hasE2EEWarning = ({ } return false; }; + +// https://github.com/RocketChat/Rocket.Chat/blob/7a57f3452fd26a603948b70af8f728953afee53f/apps/meteor/lib/utils/getFileExtension.ts#L1 +export const getFileExtension = (fileName?: string): string => { + if (!fileName) { + return 'file'; + } + + const arr = fileName.split('.'); + + if (arr.length < 2 || (arr[0] === '' && arr.length === 2)) { + return 'file'; + } + + return arr.pop()?.toLocaleUpperCase() || 'file'; +}; diff --git a/app/lib/methods/handleMediaDownload.ts b/app/lib/methods/handleMediaDownload.ts index 28abab2d6b..7a31f7a1d9 100644 --- a/app/lib/methods/handleMediaDownload.ts +++ b/app/lib/methods/handleMediaDownload.ts @@ -2,25 +2,25 @@ import * as FileSystem from 'expo-file-system'; import * as mime from 'react-native-mime-types'; import { isEmpty } from 'lodash'; +import { TAttachmentEncryption } from '../../definitions'; import { sanitizeLikeString } from '../database/utils'; import { store } from '../store/auxStore'; import log from './helpers/log'; +import { emitter } from './helpers'; +import { Encryption } from '../encryption'; export type MediaTypes = 'audio' | 'image' | 'video'; - export type TDownloadState = 'to-download' | 'loading' | 'downloaded'; - const defaultType = { audio: 'mp3', image: 'jpg', video: 'mp4' }; - export const LOCAL_DOCUMENT_DIRECTORY = FileSystem.documentDirectory; const serverUrlParsedAsPath = (serverURL: string) => `${sanitizeLikeString(serverURL)}/`; -const sanitizeFileName = (value: string) => { +export const sanitizeFileName = (value: string) => { const extension = value.substring(value.lastIndexOf('.') + 1); const toSanitize = value.substring(0, value.lastIndexOf('.')); return `${sanitizeLikeString(toSanitize)}.${extension}`; @@ -195,13 +195,19 @@ export async function cancelDownload(messageUrl: string): Promise { } export function downloadMediaFile({ + messageId, type, mimeType, - downloadUrl + downloadUrl, + encryption, + originalChecksum }: { + messageId: string; type: MediaTypes; mimeType?: string; downloadUrl: string; + encryption?: TAttachmentEncryption; + originalChecksum?: string; }): Promise { return new Promise(async (resolve, reject) => { let downloadKey = ''; @@ -213,29 +219,19 @@ export function downloadMediaFile({ downloadKey = mediaDownloadKey(downloadUrl); downloadQueue[downloadKey] = FileSystem.createDownloadResumable(downloadUrl, path); const result = await downloadQueue[downloadKey].downloadAsync(); - if (result?.uri) { - return resolve(result.uri); + + if (!result) { + return reject(); } - return reject(); - } catch { - return reject(); - } finally { - delete downloadQueue[downloadKey]; - } - }); -} -export function resumeMediaFile({ downloadUrl }: { downloadUrl: string }): Promise { - return new Promise(async (resolve, reject) => { - let downloadKey = ''; - try { - downloadKey = mediaDownloadKey(downloadUrl); - const result = await downloadQueue[downloadKey].resumeAsync(); - if (result?.uri) { - return resolve(result.uri); + if (encryption && originalChecksum) { + await Encryption.addFileToDecryptFileQueue(messageId, result.uri, encryption, originalChecksum); } - return reject(); - } catch { + + emitter.emit(`downloadMedia${messageId}`, result.uri); + return resolve(result.uri); + } catch (e) { + console.error(e); return reject(); } finally { delete downloadQueue[downloadKey]; diff --git a/app/lib/methods/helpers/emitter.ts b/app/lib/methods/helpers/emitter.ts index 3cc7f6c11c..a79368ac4b 100644 --- a/app/lib/methods/helpers/emitter.ts +++ b/app/lib/methods/helpers/emitter.ts @@ -2,7 +2,11 @@ import mitt from 'mitt'; import { TMarkdownStyle } from '../../../containers/MessageComposer/interfaces'; -export type TEmitterEvents = { +type TDynamicMediaDownloadEvents = { + [key: `downloadMedia${string}`]: string; +}; + +export type TEmitterEvents = TDynamicMediaDownloadEvents & { toolbarMention: undefined; addMarkdown: { style: TMarkdownStyle; diff --git a/app/lib/methods/helpers/fileDownload.ts b/app/lib/methods/helpers/fileDownload.ts index af255452db..5573864e1b 100644 --- a/app/lib/methods/helpers/fileDownload.ts +++ b/app/lib/methods/helpers/fileDownload.ts @@ -5,25 +5,39 @@ import { LISTENER } from '../../../containers/Toast'; import { IAttachment } from '../../../definitions'; import i18n from '../../../i18n'; import EventEmitter from './events'; +import { Encryption } from '../../encryption'; +import { sanitizeFileName } from '../handleMediaDownload'; export const getLocalFilePathFromFile = (localPath: string, attachment: IAttachment): string => `${localPath}${attachment.title}`; export const fileDownload = async (url: string, attachment?: IAttachment, fileName?: string): Promise => { let path = `${FileSystem.documentDirectory}`; if (fileName) { - path = `${path}${fileName}`; + path = `${path}${sanitizeFileName(fileName)}`; } - if (attachment) { - path = `${path}${attachment.title}`; + if (attachment?.title) { + path = `${path}${sanitizeFileName(attachment.title)}`; } const file = await FileSystem.downloadAsync(url, path); return file.uri; }; -export const fileDownloadAndPreview = async (url: string, attachment: IAttachment): Promise => { +export const fileDownloadAndPreview = async (url: string, attachment: IAttachment, messageId: string): Promise => { try { - const file = await fileDownload(url, attachment); - FileViewer.open(file, { + let file = url; + // If url starts with file://, we assume it's a local file and we don't download/decrypt it + if (!file.startsWith('file://')) { + file = await fileDownload(file, attachment); + + if (attachment.encryption) { + if (!attachment.hashes?.sha256) { + throw new Error('Missing checksum'); + } + await Encryption.addFileToDecryptFileQueue(messageId, file, attachment.encryption, attachment.hashes?.sha256); + } + } + + await FileViewer.open(file, { showOpenWithDialog: true, showAppsSuggestions: true }); diff --git a/app/lib/methods/helpers/fileUpload.ts b/app/lib/methods/helpers/fileUpload.ts deleted file mode 100644 index d711d095bc..0000000000 --- a/app/lib/methods/helpers/fileUpload.ts +++ /dev/null @@ -1,66 +0,0 @@ -export interface IFileUpload { - name: string; - uri?: string; - type?: string; - filename?: string; - data?: any; -} - -export class Upload { - public xhr: XMLHttpRequest; - public formData: FormData; - - constructor() { - this.xhr = new XMLHttpRequest(); - this.formData = new FormData(); - } - - public setupRequest(url: string, headers: { [key: string]: string }): void { - this.xhr.open('POST', url); - Object.keys(headers).forEach(key => { - this.xhr.setRequestHeader(key, headers[key]); - }); - } - - public appendFile(item: IFileUpload): void { - if (item.uri) { - this.formData.append(item.name, { - uri: item.uri, - type: item.type, - name: item.filename - } as any); - } else { - this.formData.append(item.name, item.data); - } - } - - public then(callback: (param: { respInfo: XMLHttpRequest }) => void): void { - this.xhr.onload = () => callback({ respInfo: this.xhr }); - this.xhr.send(this.formData); - } - - public catch(callback: ((this: XMLHttpRequest, ev: ProgressEvent) => any) | null): void { - this.xhr.onerror = callback; - } - - public uploadProgress(callback: (param: number, arg1: number) => any): void { - this.xhr.upload.onprogress = ({ total, loaded }) => callback(loaded, total); - } - - public cancel(): Promise { - this.xhr.abort(); - return Promise.resolve(); - } -} - -class FileUpload { - public uploadFile(url: string, headers: { [x: string]: string }, data: IFileUpload[]) { - const upload = new Upload(); - upload.setupRequest(url, headers); - data.forEach(item => upload.appendFile(item)); - return upload; - } -} - -const fileUpload = new FileUpload(); -export default fileUpload; diff --git a/app/lib/methods/helpers/fileUpload/Upload.android.ts b/app/lib/methods/helpers/fileUpload/Upload.android.ts new file mode 100644 index 0000000000..e26182bec4 --- /dev/null +++ b/app/lib/methods/helpers/fileUpload/Upload.android.ts @@ -0,0 +1,92 @@ +import * as FileSystem from 'expo-file-system'; + +import { TRoomsMediaResponse } from '../../../../definitions/rest/v1/rooms'; +import { IFormData } from './definitions'; + +export class Upload { + private uploadUrl: string; + private file: { + uri: string; + type: string | undefined; + name: string | undefined; + } | null; + private headers: { [key: string]: string }; + private formData: any; + private uploadTask: FileSystem.UploadTask | null; + private isCancelled: boolean; + private progressCallback?: (loaded: number, total: number) => void; + + constructor() { + this.uploadUrl = ''; + this.file = null; + this.headers = {}; + this.formData = {}; + this.uploadTask = null; + this.isCancelled = false; + } + + public setupRequest( + url: string, + headers: { [key: string]: string }, + progressCallback?: (loaded: number, total: number) => void + ): void { + this.uploadUrl = url; + this.headers = headers; + this.progressCallback = progressCallback; + } + + public appendFile(item: IFormData): void { + if (item.uri) { + this.file = { uri: item.uri, type: item.type, name: item.filename }; + } else { + this.formData[item.name] = item.data; + } + } + + public send(): Promise { + return new Promise(async (resolve, reject) => { + try { + if (!this.file) { + return reject(); + } + this.uploadTask = FileSystem.createUploadTask( + this.uploadUrl, + this.file.uri, + { + headers: this.headers, + httpMethod: 'POST', + uploadType: FileSystem.FileSystemUploadType.MULTIPART, + fieldName: 'file', + mimeType: this.file.type, + parameters: this.formData + }, + data => { + if (data.totalBytesSent && data.totalBytesExpectedToSend && this.progressCallback) { + this.progressCallback(data.totalBytesSent, data.totalBytesExpectedToSend); + } + } + ); + + const response = await this.uploadTask.uploadAsync(); + if (response && response.status >= 200 && response.status < 400) { + resolve(JSON.parse(response.body)); + } else { + reject(new Error(`Error: ${response?.status}`)); + } + } catch (error) { + if (this.isCancelled) { + reject(new Error('Upload cancelled')); + } else { + reject(error); + } + } + }); + } + + public cancel(): void { + this.isCancelled = true; + if (this.uploadTask) { + this.uploadTask.cancelAsync(); + } + } +} diff --git a/app/lib/methods/helpers/fileUpload/Upload.ts b/app/lib/methods/helpers/fileUpload/Upload.ts new file mode 100644 index 0000000000..911c7b12d9 --- /dev/null +++ b/app/lib/methods/helpers/fileUpload/Upload.ts @@ -0,0 +1,70 @@ +import { TRoomsMediaResponse } from '../../../../definitions/rest/v1/rooms'; +import { IFormData } from './definitions'; + +export class Upload { + private xhr: XMLHttpRequest; + private formData: FormData; + private isCancelled: boolean; + + constructor() { + this.xhr = new XMLHttpRequest(); + this.formData = new FormData(); + this.isCancelled = false; + } + + public setupRequest( + url: string, + headers: { [key: string]: string }, + progressCallback?: (loaded: number, total: number) => void + ): void { + this.xhr.open('POST', url); + Object.keys(headers).forEach(key => { + this.xhr.setRequestHeader(key, headers[key]); + }); + + if (progressCallback) { + this.xhr.upload.onprogress = ({ loaded, total }) => progressCallback(loaded, total); + } + } + + public appendFile(item: IFormData): void { + if (item.uri) { + this.formData.append(item.name, { + uri: item.uri, + type: item.type, + name: item.filename + } as any); + } else { + this.formData.append(item.name, item.data); + } + } + + public send(): Promise { + return new Promise((resolve, reject) => { + this.xhr.onload = () => { + if (this.xhr.status >= 200 && this.xhr.status < 400) { + resolve(JSON.parse(this.xhr.responseText)); + } else { + reject(new Error(`Error: ${this.xhr.statusText}`)); + } + }; + + this.xhr.onerror = () => { + reject(new Error('Network Error')); + }; + + this.xhr.onabort = () => { + if (this.isCancelled) { + reject(new Error('Upload Cancelled')); + } + }; + + this.xhr.send(this.formData); + }); + } + + public cancel(): void { + this.isCancelled = true; + this.xhr.abort(); + } +} diff --git a/app/lib/methods/helpers/fileUpload/definitions.ts b/app/lib/methods/helpers/fileUpload/definitions.ts new file mode 100644 index 0000000000..85554e7717 --- /dev/null +++ b/app/lib/methods/helpers/fileUpload/definitions.ts @@ -0,0 +1,14 @@ +import { TRoomsMediaResponse } from '../../../../definitions/rest/v1/rooms'; + +export interface IFormData { + name: string; + uri?: string; + type?: string; + filename?: string; + data?: any; +} + +export interface IFileUpload { + send(): Promise; + cancel(): void; +} diff --git a/app/lib/methods/helpers/fileUpload/index.ts b/app/lib/methods/helpers/fileUpload/index.ts new file mode 100644 index 0000000000..8387b40660 --- /dev/null +++ b/app/lib/methods/helpers/fileUpload/index.ts @@ -0,0 +1,28 @@ +import { TRoomsMediaResponse } from '../../../../definitions/rest/v1/rooms'; +import { Upload } from './Upload'; +import { IFormData } from './definitions'; + +class FileUpload { + private upload: Upload; + + constructor( + url: string, + headers: { [key: string]: string }, + data: IFormData[], + progressCallback?: (loaded: number, total: number) => void + ) { + this.upload = new Upload(); + this.upload.setupRequest(url, headers, progressCallback); + data.forEach(item => this.upload.appendFile(item)); + } + + public send(): Promise { + return this.upload.send(); + } + + public cancel(): void { + this.upload.cancel(); + } +} + +export default FileUpload; diff --git a/app/lib/methods/index.ts b/app/lib/methods/index.ts index 4ac4100839..e084f27344 100644 --- a/app/lib/methods/index.ts +++ b/app/lib/methods/index.ts @@ -26,7 +26,7 @@ export * from './logout'; export * from './readMessages'; export * from './roomTypeToApiType'; export * from './search'; -export * from './sendFileMessage'; +export { sendFileMessage } from './sendFileMessage'; export * from './sendMessage'; export * from './setUser'; export * from './triggerActions'; diff --git a/app/lib/methods/sendFileMessage.ts b/app/lib/methods/sendFileMessage.ts deleted file mode 100644 index 43a2aa6cf3..0000000000 --- a/app/lib/methods/sendFileMessage.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord'; -import { settings as RocketChatSettings } from '@rocket.chat/sdk'; -import isEmpty from 'lodash/isEmpty'; -import { Alert } from 'react-native'; - -import { IUpload, IUser, TUploadModel } from '../../definitions'; -import i18n from '../../i18n'; -import database from '../database'; -import type { IFileUpload, Upload } from './helpers/fileUpload'; -import FileUpload from './helpers/fileUpload'; -import log from './helpers/log'; - -const uploadQueue: { [index: string]: Upload } = {}; - -const getUploadPath = (path: string, rid: string) => `${path}-${rid}`; - -export function isUploadActive(path: string, rid: string): boolean { - return !!uploadQueue[getUploadPath(path, rid)]; -} - -export async function cancelUpload(item: TUploadModel, rid: string): Promise { - const uploadPath = getUploadPath(item.path, rid); - if (!isEmpty(uploadQueue[uploadPath])) { - try { - await uploadQueue[uploadPath].cancel(); - } catch { - // Do nothing - } - delete uploadQueue[uploadPath]; - } - if (item.id) { - try { - const db = database.active; - await db.write(async () => { - await item.destroyPermanently(); - }); - } catch (e) { - log(e); - } - } -} - -export function sendFileMessage( - rid: string, - fileInfo: IUpload, - tmid: string | undefined, - server: string, - user: Partial>, - isForceTryAgain?: boolean -): Promise { - return new Promise(async (resolve, reject) => { - try { - const { id, token } = user; - - const uploadUrl = `${server}/api/v1/rooms.upload/${rid}`; - - fileInfo.rid = rid; - - const db = database.active; - const uploadsCollection = db.get('uploads'); - const uploadPath = getUploadPath(fileInfo.path, rid); - let uploadRecord: TUploadModel; - try { - uploadRecord = await uploadsCollection.find(uploadPath); - if (uploadRecord.id && !isForceTryAgain) { - return Alert.alert(i18n.t('FileUpload_Error'), i18n.t('Upload_in_progress')); - } - } catch (error) { - try { - await db.write(async () => { - uploadRecord = await uploadsCollection.create(u => { - u._raw = sanitizedRaw({ id: uploadPath }, uploadsCollection.schema); - Object.assign(u, fileInfo); - if (tmid) { - u.tmid = tmid; - } - if (u.subscription) { - u.subscription.id = rid; - } - }); - }); - } catch (e) { - return log(e); - } - } - - const formData: IFileUpload[] = []; - formData.push({ - name: 'file', - type: fileInfo.type, - filename: fileInfo.name || 'fileMessage', - uri: fileInfo.path - }); - - if (fileInfo.description) { - formData.push({ - name: 'description', - data: fileInfo.description - }); - } - - if (fileInfo.msg) { - formData.push({ - name: 'msg', - data: fileInfo.msg - }); - } - - if (tmid) { - formData.push({ - name: 'tmid', - data: tmid - }); - } - - const headers = { - ...RocketChatSettings.customHeaders, - 'Content-Type': 'multipart/form-data', - 'X-Auth-Token': token, - 'X-User-Id': id - }; - - uploadQueue[uploadPath] = FileUpload.uploadFile(uploadUrl, headers, formData); - - uploadQueue[uploadPath].uploadProgress(async (loaded: number, total: number) => { - try { - await db.write(async () => { - await uploadRecord.update(u => { - u.progress = Math.floor((loaded / total) * 100); - }); - }); - } catch (e) { - log(e); - } - }); - - uploadQueue[uploadPath].then(async response => { - if (response.respInfo.status >= 200 && response.respInfo.status < 400) { - try { - await db.write(async () => { - await uploadRecord.destroyPermanently(); - }); - resolve(); - } catch (e) { - log(e); - } - } else { - try { - await db.write(async () => { - await uploadRecord.update(u => { - u.error = true; - }); - }); - } catch (e) { - log(e); - } - try { - reject(response); - } catch (e) { - reject(e); - } - } - }); - - uploadQueue[uploadPath].catch(async error => { - try { - await db.write(async () => { - await uploadRecord.update(u => { - u.error = true; - }); - }); - } catch (e) { - log(e); - } - reject(error); - }); - } catch (e) { - log(e); - } - }); -} diff --git a/app/lib/methods/sendFileMessage/index.ts b/app/lib/methods/sendFileMessage/index.ts new file mode 100644 index 0000000000..255b3f7e76 --- /dev/null +++ b/app/lib/methods/sendFileMessage/index.ts @@ -0,0 +1,21 @@ +import { IUpload, TSendFileMessageFileInfo, IUser } from '../../../definitions'; +import { store } from '../../store/auxStore'; +import { compareServerVersion } from '../helpers'; +import { sendFileMessage as sendFileMessageV1 } from './sendFileMessage'; +import { sendFileMessageV2 } from './sendFileMessageV2'; + +export const sendFileMessage = ( + rid: string, + fileInfo: TSendFileMessageFileInfo, + tmid: string | undefined, + server: string, + user: Partial>, + isForceTryAgain?: boolean +): Promise => { + const { version: serverVersion } = store.getState().server; + if (compareServerVersion(serverVersion, 'lowerThan', '6.10.0')) { + return sendFileMessageV1(rid, fileInfo as IUpload, tmid, server, user, isForceTryAgain); + } + + return sendFileMessageV2(rid, fileInfo, tmid, server, user, isForceTryAgain); +}; diff --git a/app/lib/methods/sendFileMessage/sendFileMessage.ts b/app/lib/methods/sendFileMessage/sendFileMessage.ts new file mode 100644 index 0000000000..b9a7ffd9a2 --- /dev/null +++ b/app/lib/methods/sendFileMessage/sendFileMessage.ts @@ -0,0 +1,116 @@ +import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord'; +import { settings as RocketChatSettings } from '@rocket.chat/sdk'; +import { Alert } from 'react-native'; + +import { IUser, TSendFileMessageFileInfo, TUploadModel } from '../../../definitions'; +import i18n from '../../../i18n'; +import database from '../../database'; +import FileUpload from '../helpers/fileUpload'; +import log from '../helpers/log'; +import { copyFileToCacheDirectoryIfNeeded, getUploadPath, persistUploadError, uploadQueue } from './utils'; +import { IFormData } from '../helpers/fileUpload/definitions'; + +export async function sendFileMessage( + rid: string, + fileInfo: TSendFileMessageFileInfo, + tmid: string | undefined, + server: string, + user: Partial>, + isForceTryAgain?: boolean +): Promise { + let uploadPath: string | null = ''; + try { + const { id, token } = user; + const uploadUrl = `${server}/api/v1/rooms.upload/${rid}`; + fileInfo.rid = rid; + + const db = database.active; + const uploadsCollection = db.get('uploads'); + uploadPath = getUploadPath(fileInfo.path, rid); + let uploadRecord: TUploadModel; + try { + uploadRecord = await uploadsCollection.find(uploadPath); + if (uploadRecord.id && !isForceTryAgain) { + return Alert.alert(i18n.t('FileUpload_Error'), i18n.t('Upload_in_progress')); + } + } catch (error) { + try { + await db.write(async () => { + uploadRecord = await uploadsCollection.create(u => { + u._raw = sanitizedRaw({ id: uploadPath }, uploadsCollection.schema); + Object.assign(u, fileInfo); + if (tmid) { + u.tmid = tmid; + } + if (u.subscription) { + u.subscription.id = rid; + } + }); + }); + } catch (e) { + return log(e); + } + } + + fileInfo.path = await copyFileToCacheDirectoryIfNeeded(fileInfo.path, fileInfo.name); + + const formData: IFormData[] = []; + formData.push({ + name: 'file', + type: fileInfo.type, + filename: fileInfo.name || 'fileMessage', + uri: fileInfo.path + }); + + if (fileInfo.description) { + formData.push({ + name: 'description', + data: fileInfo.description + }); + } + + if (fileInfo.msg) { + formData.push({ + name: 'msg', + data: fileInfo.msg + }); + } + + if (tmid) { + formData.push({ + name: 'tmid', + data: tmid + }); + } + + const headers = { + ...RocketChatSettings.customHeaders, + 'Content-Type': 'multipart/form-data', + 'X-Auth-Token': token, + 'X-User-Id': id + }; + + uploadQueue[uploadPath] = new FileUpload(uploadUrl, headers, formData, async (loaded, total) => { + try { + await db.write(async () => { + await uploadRecord?.update(u => { + u.progress = Math.floor((loaded / total) * 100); + }); + }); + } catch (e) { + console.error(e); + } + }); + await uploadQueue[uploadPath].send(); + await db.write(async () => { + await uploadRecord?.destroyPermanently(); + }); + } catch (e) { + if (uploadPath && !uploadQueue[uploadPath]) { + console.log('Upload cancelled'); + } else { + await persistUploadError(fileInfo.path, rid); + throw e; + } + } +} diff --git a/app/lib/methods/sendFileMessage/sendFileMessageV2.ts b/app/lib/methods/sendFileMessage/sendFileMessageV2.ts new file mode 100644 index 0000000000..1f207461cf --- /dev/null +++ b/app/lib/methods/sendFileMessage/sendFileMessageV2.ts @@ -0,0 +1,94 @@ +import { settings as RocketChatSettings } from '@rocket.chat/sdk'; + +import { TSendFileMessageFileInfo, IUser, TUploadModel } from '../../../definitions'; +import database from '../../database'; +import { Encryption } from '../../encryption'; +import { copyFileToCacheDirectoryIfNeeded, createUploadRecord, persistUploadError, uploadQueue } from './utils'; +import FileUpload from '../helpers/fileUpload'; +import { IFormData } from '../helpers/fileUpload/definitions'; + +export async function sendFileMessageV2( + rid: string, + fileInfo: TSendFileMessageFileInfo, + tmid: string | undefined, + server: string, + user: Partial>, + isForceTryAgain?: boolean +): Promise { + let uploadPath: string | null = ''; + let uploadRecord: TUploadModel | null; + try { + const { id, token } = user; + const headers = { + ...RocketChatSettings.customHeaders, + 'Content-Type': 'multipart/form-data', + 'X-Auth-Token': token, + 'X-User-Id': id + }; + const db = database.active; + + [uploadPath, uploadRecord] = await createUploadRecord({ rid, fileInfo, tmid, isForceTryAgain }); + if (!uploadPath || !uploadRecord) { + throw new Error("Couldn't create upload record"); + } + const { file, getContent, fileContent } = await Encryption.encryptFile(rid, fileInfo); + file.path = await copyFileToCacheDirectoryIfNeeded(file.path, file.name); + + const formData: IFormData[] = []; + formData.push({ + name: 'file', + type: file.type, + filename: file.name, + uri: file.path + }); + if (fileContent) { + formData.push({ + name: 'content', + data: JSON.stringify(fileContent) + }); + } + + uploadQueue[uploadPath] = new FileUpload(`${server}/api/v1/rooms.media/${rid}`, headers, formData, async (loaded, total) => { + try { + await db.write(async () => { + await uploadRecord?.update(u => { + u.progress = Math.floor((loaded / total) * 100); + }); + }); + } catch (e) { + console.error(e); + } + }); + const response = await uploadQueue[uploadPath].send(); + + let content; + if (getContent) { + content = await getContent(response.file._id, response.file.url); + } + await fetch(`${server}/api/v1/rooms.mediaConfirm/${rid}/${response.file._id}`, { + method: 'POST', + headers: { + ...headers, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + msg: file.msg || undefined, + tmid: tmid || undefined, + description: file.description || undefined, + t: content ? 'e2e' : undefined, + content + }) + }); + await db.write(async () => { + await uploadRecord?.destroyPermanently(); + }); + } catch (e: any) { + console.error(e); + if (uploadPath && !uploadQueue[uploadPath]) { + console.log('Upload cancelled'); + } else { + await persistUploadError(fileInfo.path, rid); + throw e; + } + } +} diff --git a/app/lib/methods/sendFileMessage/utils.ts b/app/lib/methods/sendFileMessage/utils.ts new file mode 100644 index 0000000000..3d73df4b1d --- /dev/null +++ b/app/lib/methods/sendFileMessage/utils.ts @@ -0,0 +1,112 @@ +import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord'; +import isEmpty from 'lodash/isEmpty'; +import { Alert } from 'react-native'; +import * as FileSystem from 'expo-file-system'; + +import { getUploadByPath } from '../../database/services/Upload'; +import { IUpload, TUploadModel } from '../../../definitions'; +import i18n from '../../../i18n'; +import database from '../../database'; +import log from '../helpers/log'; +import { IFileUpload } from '../helpers/fileUpload/definitions'; + +export const uploadQueue: { [index: string]: IFileUpload } = {}; + +export const getUploadPath = (path: string, rid: string) => `${path}-${rid}`; + +export function isUploadActive(path: string, rid: string): boolean { + return !!uploadQueue[getUploadPath(path, rid)]; +} + +export async function cancelUpload(item: TUploadModel, rid: string): Promise { + const uploadPath = getUploadPath(item.path, rid); + if (!isEmpty(uploadQueue[uploadPath])) { + try { + await uploadQueue[uploadPath].cancel(); + } catch { + // Do nothing + } + delete uploadQueue[uploadPath]; + } + if (item.id) { + try { + const db = database.active; + await db.write(async () => { + await item.destroyPermanently(); + }); + } catch (e) { + log(e); + } + } +} + +export const persistUploadError = async (path: string, rid: string) => { + try { + const db = database.active; + const uploadRecord = await getUploadByPath(getUploadPath(path, rid)); + if (!uploadRecord) { + return; + } + await db.write(async () => { + await uploadRecord.update(u => { + u.error = true; + }); + }); + } catch { + // Do nothing + } +}; + +export const createUploadRecord = async ({ + rid, + fileInfo, + tmid, + isForceTryAgain +}: { + rid: string; + fileInfo: IUpload; + tmid: string | undefined; + isForceTryAgain?: boolean; +}) => { + const db = database.active; + const uploadsCollection = db.get('uploads'); + const uploadPath = getUploadPath(fileInfo.path, rid); + let uploadRecord: TUploadModel | null = null; + try { + uploadRecord = await uploadsCollection.find(uploadPath); + if (uploadRecord.id && !isForceTryAgain) { + Alert.alert(i18n.t('FileUpload_Error'), i18n.t('Upload_in_progress')); + return [null, null]; + } + } catch (error) { + try { + await db.write(async () => { + uploadRecord = await uploadsCollection.create(u => { + u._raw = sanitizedRaw({ id: uploadPath }, uploadsCollection.schema); + Object.assign(u, fileInfo); + if (tmid) { + u.tmid = tmid; + } + if (u.subscription) { + u.subscription.id = rid; + } + }); + }); + } catch (e) { + throw e; + } + } + return [uploadPath, uploadRecord] as const; +}; + +export const copyFileToCacheDirectoryIfNeeded = async (path: string, name?: string) => { + if (!path.startsWith('file://') && name) { + if (!FileSystem.cacheDirectory) { + throw new Error('No cache dir'); + } + const newPath = `${FileSystem.cacheDirectory}/${name}`; + await FileSystem.copyAsync({ from: path, to: newPath }); + return newPath; + } + return path; +}; diff --git a/app/lib/methods/subscriptions/rooms.ts b/app/lib/methods/subscriptions/rooms.ts index cc79ccb2ed..105842eb2f 100644 --- a/app/lib/methods/subscriptions/rooms.ts +++ b/app/lib/methods/subscriptions/rooms.ts @@ -399,14 +399,16 @@ export default function subscribeRooms() { // If it's from a encrypted room if (message?.t === E2E_MESSAGE_TYPE) { - // Decrypt this message content - const { msg } = await Encryption.decryptMessage({ ...message, rid }); - // If it's a direct the content is the message decrypted - if (room.t === 'd') { - notification.text = msg; - // If it's a private group we should add the sender name - } else { - notification.text = `${getSenderName(sender)}: ${msg}`; + if (message.msg) { + // Decrypt this message content + const { msg } = await Encryption.decryptMessage({ ...message, rid }); + // If it's a direct the content is the message decrypted + if (room.t === 'd') { + notification.text = msg; + // If it's a private group we should add the sender name + } else { + notification.text = `${getSenderName(sender)}: ${msg}`; + } } } } catch (e) { diff --git a/app/sagas/login.js b/app/sagas/login.js index 3aa28293ce..0d2b26b344 100644 --- a/app/sagas/login.js +++ b/app/sagas/login.js @@ -183,6 +183,7 @@ const handleLoginSuccess = function* handleLoginSuccess({ user }) { getUserPresence(user.id); const server = yield select(getServer); + yield put(encryptionInit()); yield put(roomsRequest()); yield fork(fetchPermissionsFork); yield fork(fetchCustomEmojisFork); @@ -192,7 +193,6 @@ const handleLoginSuccess = function* handleLoginSuccess({ user }) { yield fork(fetchUsersPresenceFork); yield fork(fetchEnterpriseModulesFork, { user }); yield fork(subscribeSettingsFork); - yield put(encryptionInit()); yield fork(fetchUsersRoles); setLanguage(user?.language); diff --git a/app/views/AttachmentView.tsx b/app/views/AttachmentView.tsx index ffec75a119..85379093a9 100644 --- a/app/views/AttachmentView.tsx +++ b/app/views/AttachmentView.tsx @@ -18,7 +18,7 @@ import { IAttachment } from '../definitions'; import I18n from '../i18n'; import { useAppSelector } from '../lib/hooks'; import { useAppNavigation, useAppRoute } from '../lib/hooks/navigation'; -import { formatAttachmentUrl, isAndroid, fileDownload } from '../lib/methods/helpers'; +import { formatAttachmentUrl, isAndroid, fileDownload, showErrorAlert } from '../lib/methods/helpers'; import EventEmitter from '../lib/methods/helpers/events'; import { getUserSelector } from '../selectors/login'; import { TNavigation } from '../stacks/stackType'; @@ -69,7 +69,7 @@ const RenderContent = ({ ); } if (attachment.video_url) { - const url = formatAttachmentUrl(attachment.video_url, user.id, user.token, baseUrl); + const url = formatAttachmentUrl(attachment.title_link || attachment.video_url, user.id, user.token, baseUrl); const uri = encodeURI(url); return (