diff --git a/README.md b/README.md index d16e9c083..1855f8500 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,39 @@ Please search for any [existing issues](https://github.com/oxen-io/session-deskt Build instructions can be found in [BUILDING.md](BUILDING.md). + +## Verifing signatures + + +Get Kee's key and import it: +``` +wget https://raw.githubusercontent.com/oxen-io/oxen-core/master/utils/gpg_keys/KeeJef.asc +gpg --import KeeJef.asc +``` + +Get the signed hash for this release, the SESSION_VERSION needs to be updated for the release you want to verify +``` +export SESSION_VERSION=1.6.1 +wget https://github.com/oxen-io/session-desktop/releases/download/v$SESSION_VERSION/signatures.asc +``` + +Verify the signature of the hashes of the files + +``` +gpg --verify signatures.asc 2>&1 |grep "Good signature from" +``` + +The command above should print "`Good signature from "Kee Jefferys...`" +If it does, the hashes are valid but we still have to make the sure the signed hashes matches the downloaded files. + +Make sure the two commands below returns the same hash. +If they do, files are valid +``` +sha256sum session-desktop-linux-amd64-$SESSION_VERSION.deb +grep .deb signatures.asc +``` + + ## Debian repository Please visit https://deb.oxen.io/<br/> diff --git a/background.html b/background.html index 7d02dfdb6..58cf594e8 100644 --- a/background.html +++ b/background.html @@ -10,7 +10,7 @@ <meta name="viewport" content="width=device-width, initial-scale=1"> <meta http-equiv="Content-Security-Policy" content="default-src 'none'; child-src 'self'; - connect-src 'self' https: wss:; + connect-src 'self' https: wss: blob:; font-src 'self'; form-action 'self'; frame-src 'none'; @@ -167,4 +167,4 @@ <script type='text/javascript' src='js/background.js'></script> </body> -</html> \ No newline at end of file +</html> diff --git a/js/background.js b/js/background.js index 04a23597d..00c0bb2b4 100644 --- a/js/background.js +++ b/js/background.js @@ -425,7 +425,7 @@ avatarPath, onOk: async (newName, avatar) => { let newAvatarPath = ''; - let url = null; + let fileUrl = null; let profileKey = null; if (avatar) { const data = await readFile({ file: avatar }); @@ -463,22 +463,18 @@ profileKey ); - const avatarPointer = await libsession.Utils.AttachmentUtils.uploadAvatarV1({ - ...dataResized, - data: encryptedData, - size: encryptedData.byteLength, - }); + const avatarPointer = await window.Fsv2.uploadFileToFsV2(encryptedData); - ({ url } = avatarPointer); + ({ fileUrl } = avatarPointer); storage.put('profileKey', profileKey); - conversation.set('avatarPointer', url); + conversation.set('avatarPointer', fileUrl); const upgraded = await Signal.Migrations.processNewAttachment({ isRaw: true, data: data.data, - url, + url: fileUrl, }); newAvatarPath = upgraded.path; // Replace our temporary image with the attachment pointer from the server: @@ -513,14 +509,6 @@ // so we could disable this here // or least it enable for the quickest response window.lokiPublicChatAPI.setProfileName(newName); - - if (avatar) { - window - .getConversationController() - .getConversations() - .filter(convo => convo.isPublic()) - .forEach(convo => convo.trigger('ourAvatarChanged', { url, profileKey })); - } }, }); } diff --git a/libtextsecure/crypto.d.ts b/libtextsecure/crypto.d.ts index 441d59ee5..54b1d1b65 100644 --- a/libtextsecure/crypto.d.ts +++ b/libtextsecure/crypto.d.ts @@ -13,4 +13,5 @@ export interface LibTextsecureCryptoInterface { theirDigest: ArrayBuffer ): Promise<ArrayBuffer>; decryptProfile(data: ArrayBuffer, key: ArrayBuffer): Promise<ArrayBuffer>; + encryptProfile(data: ArrayBuffer, key: ArrayBuffer): Promise<ArrayBuffer>; } diff --git a/package.json b/package.json index 3faef1cf1..eb19f61bc 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "session-desktop", "productName": "Session", "description": "Private messaging from your desktop", - "version": "1.6.2", + "version": "1.6.4", "license": "GPL-3.0", "author": { "name": "Loki Project", diff --git a/preload.js b/preload.js index 31dd86729..bf1ce9ebb 100644 --- a/preload.js +++ b/preload.js @@ -398,6 +398,7 @@ window.addEventListener('contextmenu', e => { }); window.NewReceiver = require('./ts/receiver/receiver'); +window.Fsv2 = require('./ts/fileserver/FileServerApiV2'); window.DataMessageReceiver = require('./ts/receiver/dataMessage'); window.NewSnodeAPI = require('./ts/session/snode_api/SNodeAPI'); window.SnodePool = require('./ts/session/snode_api/snodePool'); diff --git a/ts/components/Avatar.tsx b/ts/components/Avatar.tsx index f2c385488..a39e0d47e 100644 --- a/ts/components/Avatar.tsx +++ b/ts/components/Avatar.tsx @@ -91,7 +91,11 @@ export const Avatar = (props: Props) => { // contentType is not important const { urlToLoad } = useEncryptedFileFetch(avatarPath || '', ''); const handleImageError = () => { - window?.log?.warn('Avatar: Image failed to load; failing over to placeholder', urlToLoad); + window.log.warn( + 'Avatar: Image failed to load; failing over to placeholder', + urlToLoad, + avatarPath + ); setImageBroken(true); }; diff --git a/ts/components/ConversationListItem.tsx b/ts/components/ConversationListItem.tsx index a2404c912..76f1ce05f 100644 --- a/ts/components/ConversationListItem.tsx +++ b/ts/components/ConversationListItem.tsx @@ -229,7 +229,7 @@ class ConversationListItem extends React.PureComponent<Props> { const displayName = isMe ? i18n('noteToSelf') : profileName; let shouldShowPubkey = false; - if (!name || name.length === 0) { + if ((!name || name.length === 0) && (!displayName || displayName.length === 0)) { shouldShowPubkey = true; } diff --git a/ts/components/session/ActionsPanel.tsx b/ts/components/session/ActionsPanel.tsx index c24bdf11f..497dde9df 100644 --- a/ts/components/session/ActionsPanel.tsx +++ b/ts/components/session/ActionsPanel.tsx @@ -7,11 +7,13 @@ import { ConversationController } from '../../session/conversations'; import { UserUtils } from '../../session/utils'; import { syncConfigurationIfNeeded } from '../../session/utils/syncUtils'; import { DAYS, MINUTES } from '../../session/utils/Number'; + import { + createOrUpdateItem, generateAttachmentKeyIfEmpty, getItemById, hasSyncedInitialConfigurationItem, - removeItemById, + lastAvatarUploadTimestamp, } from '../../data/data'; import { OnionPaths } from '../../session/onions'; import { getMessageQueue } from '../../session/sending'; @@ -29,11 +31,18 @@ import { useInterval } from '../../hooks/useInterval'; import { clearSearch } from '../../state/ducks/search'; import { showLeftPaneSection } from '../../state/ducks/section'; -import { cleanUpOldDecryptedMedias } from '../../session/crypto/DecryptedAttachmentsManager'; +import { + cleanUpOldDecryptedMedias, + getDecryptedMediaUrl, +} from '../../session/crypto/DecryptedAttachmentsManager'; import { OpenGroupManagerV2 } from '../../opengroup/opengroupV2/OpenGroupManagerV2'; import { loadDefaultRooms } from '../../opengroup/opengroupV2/ApiUtil'; import { forceRefreshRandomSnodePool } from '../../session/snode_api/snodePool'; import { SwarmPolling } from '../../session/snode_api/swarmPolling'; +import { IMAGE_JPEG } from '../../types/MIME'; +import { FSv2 } from '../../fileserver'; +import { stringToArrayBuffer } from '../../session/utils/String'; +import { debounce } from 'underscore'; // tslint:disable-next-line: no-import-side-effect no-submodule-imports export enum SectionType { @@ -45,6 +54,20 @@ export enum SectionType { Moon, } +const showUnstableAttachmentsDialogIfNeeded = async () => { + const alreadyShown = (await getItemById('showUnstableAttachmentsDialog'))?.value; + + if (!alreadyShown) { + window.confirmationDialog({ + title: 'File server update', + message: + "We're upgrading the way files are stored. File transfer may be unstable for the next 24-48 hours.", + }); + + await createOrUpdateItem({ id: 'showUnstableAttachmentsDialog', value: true }); + } +}; + const Section = (props: { type: SectionType; avatarPath?: string }) => { const ourNumber = useSelector(getOurNumber); const unreadMessageCount = useSelector(getUnreadMessageCount); @@ -149,6 +172,81 @@ const triggerSyncIfNeeded = async () => { } }; +const triggerAvatarReUploadIfNeeded = async () => { + const lastTimeStampAvatarUpload = (await getItemById(lastAvatarUploadTimestamp))?.value || 0; + + if (Date.now() - lastTimeStampAvatarUpload > DAYS * 14) { + window.log.info('Reuploading avatar...'); + // reupload the avatar + const ourConvo = ConversationController.getInstance().get(UserUtils.getOurPubKeyStrFromCache()); + if (!ourConvo) { + window.log.warn('ourConvo not found... This is not a valid case'); + return; + } + const profileKey = window.textsecure.storage.get('profileKey'); + if (!profileKey) { + window.log.warn('our profileKey not found... This is not a valid case'); + return; + } + + const currentAttachmentPath = ourConvo.getAvatarPath(); + + if (!currentAttachmentPath) { + window.log.warn('No attachment currently set for our convo.. Nothing to do.'); + return; + } + + const decryptedAvatarUrl = await getDecryptedMediaUrl(currentAttachmentPath, IMAGE_JPEG); + + if (!decryptedAvatarUrl) { + window.log.warn('Could not decrypt avatar stored locally..'); + return; + } + const response = await fetch(decryptedAvatarUrl); + const blob = await response.blob(); + const decryptedAvatarData = await blob.arrayBuffer(); + + if (!decryptedAvatarData?.byteLength) { + window.log.warn('Could not read blob of avatar locally..'); + return; + } + + const encryptedData = await window.textsecure.crypto.encryptProfile( + decryptedAvatarData, + profileKey + ); + + const avatarPointer = await FSv2.uploadFileToFsV2(encryptedData); + let fileUrl; + if (!avatarPointer) { + window.log.warn('failed to reupload avatar to fsv2'); + return; + } + ({ fileUrl } = avatarPointer); + + ourConvo.set('avatarPointer', fileUrl); + + // this encrypts and save the new avatar and returns a new attachment path + const upgraded = await window.Signal.Migrations.processNewAttachment({ + isRaw: true, + data: decryptedAvatarData, + url: fileUrl, + }); + const newAvatarPath = upgraded.path; + // Replace our temporary image with the attachment pointer from the server: + ourConvo.set('avatar', null); + const existingHash = ourConvo.get('avatarHash'); + const displayName = ourConvo.get('profileName'); + // this commits already + await ourConvo.setLokiProfile({ avatar: newAvatarPath, displayName, avatarHash: existingHash }); + const newTimestampReupload = Date.now(); + await createOrUpdateItem({ id: lastAvatarUploadTimestamp, value: newTimestampReupload }); + window.log.info( + `Reuploading avatar finished at ${newTimestampReupload}, newAttachmentPointer ${fileUrl}` + ); + } +}; + /** * This function is called only once: on app startup with a logged in user */ @@ -158,6 +256,7 @@ const doAppStartUp = (dispatch: Dispatch<any>) => { void OnionPaths.buildNewOnionPathsOneAtATime(); } + void showUnstableAttachmentsDialogIfNeeded(); // init the messageQueue. In the constructor, we add all not send messages // this call does nothing except calling the constructor, which will continue sending message in the pipeline void getMessageQueue().processAllPending(); @@ -178,6 +277,8 @@ const doAppStartUp = (dispatch: Dispatch<any>) => { void loadDefaultRooms(); + debounce(triggerAvatarReUploadIfNeeded, 200); + // TODO: Investigate the case where we reconnect const ourKey = UserUtils.getOurPubKeyStrFromCache(); SwarmPolling.getInstance().addPubkey(ourKey); @@ -228,6 +329,11 @@ export const ActionsPanel = () => { void forceRefreshRandomSnodePool(); }, DAYS * 1); + useInterval(() => { + // this won't be run every days, but if the app stays open for more than 10 days + void triggerAvatarReUploadIfNeeded(); + }, DAYS * 1); + return ( <div className="module-left-pane__sections-container"> <Section type={SectionType.Profile} avatarPath={ourPrimaryConversation.avatarPath} /> diff --git a/ts/data/data.ts b/ts/data/data.ts index d11731b36..275926369 100644 --- a/ts/data/data.ts +++ b/ts/data/data.ts @@ -60,6 +60,7 @@ export type ServerToken = { }; export const hasSyncedInitialConfigurationItem = 'hasSyncedInitialConfigurationItem'; +export const lastAvatarUploadTimestamp = 'lastAvatarUploadTimestamp'; const channelsToMake = { shutdown, diff --git a/ts/fileserver/FileServerApiV2.ts b/ts/fileserver/FileServerApiV2.ts index 642eba13a..1a161590b 100644 --- a/ts/fileserver/FileServerApiV2.ts +++ b/ts/fileserver/FileServerApiV2.ts @@ -4,9 +4,14 @@ import { parseStatusCodeFromOnionRequest } from '../opengroup/opengroupV2/OpenGr import { fromArrayBufferToBase64, fromBase64ToArrayBuffer } from '../session/utils/String'; // tslint:disable-next-line: no-http-string -export const fileServerV2URL = 'http://88.99.175.227'; -export const fileServerV2PubKey = +export const oldFileServerV2URL = 'http://88.99.175.227'; +export const oldFileServerV2PubKey = '7cb31905b55cd5580c686911debf672577b3fb0bff81df4ce2d5c4cb3a7aaa69'; +// tslint:disable-next-line: no-http-string +export const fileServerV2URL = 'http://filev2.getsession.org'; + +export const fileServerV2PubKey = + 'da21e1d886c6fbaea313f75298bd64aab03a97ce985b46bb2dad9f2089c8ee59'; export type FileServerV2Request = { method: 'GET' | 'POST' | 'DELETE' | 'PUT'; @@ -14,6 +19,7 @@ export type FileServerV2Request = { // queryParams are used for post or get, but not the same way queryParams?: Record<string, any>; headers?: Record<string, string>; + isOldV2server?: boolean; // to remove in a few days }; const FILES_ENDPOINT = 'files'; @@ -67,7 +73,8 @@ export const uploadFileToFsV2 = async ( * @returns the data as an Uint8Array or null */ export const downloadFileFromFSv2 = async ( - fileIdOrCompleteUrl: string + fileIdOrCompleteUrl: string, + isOldV2server: boolean ): Promise<ArrayBuffer | null> => { let fileId = fileIdOrCompleteUrl; if (!fileIdOrCompleteUrl) { @@ -75,13 +82,19 @@ export const downloadFileFromFSv2 = async ( return null; } - const completeUrlPrefix = `${fileServerV2URL}/${FILES_ENDPOINT}/`; - if (fileIdOrCompleteUrl.startsWith(completeUrlPrefix)) { - fileId = fileId.substr(completeUrlPrefix.length); + const oldCompleteUrlPrefix = `${oldFileServerV2URL}/${FILES_ENDPOINT}/`; + const newCompleteUrlPrefix = `${fileServerV2URL}/${FILES_ENDPOINT}/`; + + if (fileIdOrCompleteUrl.startsWith(newCompleteUrlPrefix)) { + fileId = fileId.substr(newCompleteUrlPrefix.length); + } else if (fileIdOrCompleteUrl.startsWith(oldCompleteUrlPrefix)) { + fileId = fileId.substr(oldCompleteUrlPrefix.length); } + const request: FileServerV2Request = { method: 'GET', endpoint: `${FILES_ENDPOINT}/${fileId}`, + isOldV2server, }; const result = await sendApiV2Request(request); @@ -119,7 +132,11 @@ export const buildUrl = (request: FileServerV2Request | OpenGroupV2Request): URL if (isOpenGroupV2Request(request)) { rawURL = `${request.server}/${request.endpoint}`; } else { - rawURL = `${fileServerV2URL}/${request.endpoint}`; + if (request.isOldV2server) { + rawURL = `${oldFileServerV2URL}/${request.endpoint}`; + } else { + rawURL = `${fileServerV2URL}/${request.endpoint}`; + } } if (request.method === 'GET') { diff --git a/ts/models/conversation.ts b/ts/models/conversation.ts index c90402ca6..f98259e7d 100644 --- a/ts/models/conversation.ts +++ b/ts/models/conversation.ts @@ -179,7 +179,6 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> { //start right away the function is called, and wait 1sec before calling it again this.markRead = _.debounce(this.markReadBouncy, 1000, { leading: true }); // Listening for out-of-band data updates - this.on('ourAvatarChanged', avatar => this.updateAvatarOnPublicChat(avatar)); this.typingRefreshTimer = null; this.typingPauseTimer = null; @@ -783,23 +782,6 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> { return null; } - public async updateAvatarOnPublicChat({ url, profileKey }: any) { - if (!this.isPublic()) { - return; - } - // Always share avatars on PublicChat - - if (profileKey && typeof profileKey !== 'string') { - // eslint-disable-next-line no-param-reassign - // tslint:disable-next-line: no-parameter-reassignment - profileKey = fromArrayBufferToBase64(profileKey); - } - const serverAPI = await window.lokiPublicChatAPI.findOrCreateServer(this.get('server')); - if (!serverAPI) { - return; - } - await serverAPI.setAvatar(url, profileKey); - } public async bouncyUpdateLastMessage() { if (!this.id) { return; @@ -1227,11 +1209,11 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> { // Not sure if we care about updating the database } - public async setProfileAvatar(avatar: any, avatarHash?: string) { + public async setProfileAvatar(avatar: null | { path: string }, avatarHash?: string) { const profileAvatar = this.get('avatar'); const existingHash = this.get('avatarHash'); let shouldCommit = false; - if (profileAvatar !== avatar) { + if (!_.isEqual(profileAvatar, avatar)) { this.set({ avatar }); shouldCommit = true; } diff --git a/ts/opengroup/opengroupV2/OpenGroupAPIV2.ts b/ts/opengroup/opengroupV2/OpenGroupAPIV2.ts index c39c40cc2..6303ae261 100644 --- a/ts/opengroup/opengroupV2/OpenGroupAPIV2.ts +++ b/ts/opengroup/opengroupV2/OpenGroupAPIV2.ts @@ -58,7 +58,7 @@ const getDestinationPubKey = async ( } } else { // this is a fileServer call - return FSv2.fileServerV2PubKey; + return request.isOldV2server ? FSv2.oldFileServerV2PubKey : FSv2.fileServerV2PubKey; } }; diff --git a/ts/receiver/attachments.ts b/ts/receiver/attachments.ts index 57d845009..1e690e734 100644 --- a/ts/receiver/attachments.ts +++ b/ts/receiver/attachments.ts @@ -23,18 +23,19 @@ export async function downloadAttachment(attachment: any) { serverUrl ); // is it an attachment hosted on the file server v2 ? + const defaultFsOldV2 = _.startsWith(serverUrl, FSv2.oldFileServerV2URL); const defaultFsV2 = _.startsWith(serverUrl, FSv2.fileServerV2URL); let res: ArrayBuffer | null = null; - if (defaultFsV2) { + if (defaultFsV2 || defaultFsOldV2) { let attachmentId = attachment.id; if (!attachmentId) { // try to get the fileId from the end of the URL attachmentId = attachment.url; } window?.log?.info('Download v2 file server attachment'); - res = await FSv2.downloadFileFromFSv2(attachmentId); + res = await FSv2.downloadFileFromFSv2(attachmentId, defaultFsOldV2); } else { window.log.warn( 'downloadAttachment attachment is neither opengroup attachment nor fsv2... Dropping it' diff --git a/ts/test/types/Attachment_test.ts b/ts/test/types/Attachment_test.ts index 55bf2dd56..a30fc03bc 100644 --- a/ts/test/types/Attachment_test.ts +++ b/ts/test/types/Attachment_test.ts @@ -47,7 +47,7 @@ describe('Attachment', () => { contentType: MIME.VIDEO_QUICKTIME, }; const actual = Attachment.getSuggestedFilename({ attachment }); - const expected = 'session-attachment.mov'; + const expected = 'funny-cat.mov'; assert.strictEqual(actual, expected); }); it('should generate a filename without timestamp but with an index', () => { @@ -60,7 +60,7 @@ describe('Attachment', () => { attachment, index: 3, }); - const expected = 'session-attachment_003.mov'; + const expected = 'funny-cat.mov'; assert.strictEqual(actual, expected); }); it('should generate a filename with an extension if contentType is not setup', () => { @@ -73,7 +73,7 @@ describe('Attachment', () => { attachment, index: 3, }); - const expected = 'session-attachment_003.ini'; + const expected = 'funny-cat.ini'; assert.strictEqual(actual, expected); }); @@ -87,7 +87,7 @@ describe('Attachment', () => { attachment, index: 3, }); - const expected = 'session-attachment_003.txt'; + const expected = 'funny-cat.txt'; assert.strictEqual(actual, expected); }); it('should generate a filename with an extension if contentType is json', () => { @@ -100,7 +100,7 @@ describe('Attachment', () => { attachment, index: 3, }); - const expected = 'session-attachment_003.json'; + const expected = 'funny-cat.json'; assert.strictEqual(actual, expected); }); }); @@ -116,14 +116,14 @@ describe('Attachment', () => { attachment, timestamp, }); - const expected = 'session-attachment-2000-01-01-000000.mov'; + const expected = 'funny-cat.mov'; assert.strictEqual(actual, expected); }); }); context('for attachment with index', () => { - it('should generate a filename based on timestamp', () => { + it('should generate a filename based on timestamp if filename is not set', () => { const attachment: Attachment.AttachmentType = { - fileName: 'funny-cat.mov', + fileName: '', url: 'funny-cat.mov', contentType: MIME.VIDEO_QUICKTIME, }; @@ -136,6 +136,22 @@ describe('Attachment', () => { const expected = 'session-attachment-1970-01-01-000000_003.mov'; assert.strictEqual(actual, expected); }); + + it('should generate a filename based on filename if present', () => { + const attachment: Attachment.AttachmentType = { + fileName: 'funny-cat.mov', + url: 'funny-cat.mov', + contentType: MIME.VIDEO_QUICKTIME, + }; + const timestamp = new Date(new Date(0).getTimezoneOffset() * 60 * 1000); + const actual = Attachment.getSuggestedFilename({ + attachment, + timestamp, + index: 3, + }); + const expected = 'funny-cat.mov'; + assert.strictEqual(actual, expected); + }); }); }); diff --git a/ts/types/Attachment.ts b/ts/types/Attachment.ts index b162c267a..cdb632a37 100644 --- a/ts/types/Attachment.ts +++ b/ts/types/Attachment.ts @@ -334,6 +334,9 @@ export const getSuggestedFilename = ({ timestamp?: number | Date; index?: number; }): string => { + if (attachment.fileName?.length > 3) { + return attachment.fileName; + } const prefix = 'session-attachment'; const suffix = timestamp ? moment(timestamp).format('-YYYY-MM-DD-HHmmss') : ''; const fileType = getFileExtension(attachment);