diff --git a/CHANGELOG.md b/CHANGELOG.md index 56600bb044..40cdf52519 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ # New features: * Add support for new format of invitation link: `c=&t=&s=&i=` ([#2310](https://github.com/TryQuiet/quiet/issues/2310)) +* Use server for downloading initial community metadata if v2 invitation link is detected ([#2295](https://github.com/TryQuiet/quiet/issues/2295)) # Refactorings: diff --git a/packages/backend/package-lock.json b/packages/backend/package-lock.json index 2697f66df2..43868588fa 100644 --- a/packages/backend/package-lock.json +++ b/packages/backend/package-lock.json @@ -26,6 +26,7 @@ "dotenv": "8.2.0", "events": "^3.2.0", "express": "^4.17.1", + "fetch-retry": "^6.0.0", "fastq": "^1.17.1", "get-port": "^5.1.1", "go-ipfs": "npm:mocked-go-ipfs@0.17.0", @@ -10927,6 +10928,11 @@ "webidl-conversions": "^4.0.2" } }, + "node_modules/fetch-retry": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/fetch-retry/-/fetch-retry-6.0.0.tgz", + "integrity": "sha512-BUFj1aMubgib37I3v4q78fYo63Po7t4HUPTpQ6/QE6yK6cIQrP+W43FYToeTEyg5m2Y7eFUtijUuAv/PDlWuag==" + }, "node_modules/file-type": { "version": "18.2.0", "license": "MIT", @@ -30874,6 +30880,11 @@ "fetch-mock": "^9.11.0" } }, + "fetch-retry": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/fetch-retry/-/fetch-retry-6.0.0.tgz", + "integrity": "sha512-BUFj1aMubgib37I3v4q78fYo63Po7t4HUPTpQ6/QE6yK6cIQrP+W43FYToeTEyg5m2Y7eFUtijUuAv/PDlWuag==" + }, "file-type": { "version": "18.2.0", "requires": { diff --git a/packages/backend/package.json b/packages/backend/package.json index 87312a5a6e..8a443cf572 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -108,6 +108,7 @@ "dotenv": "8.2.0", "events": "^3.2.0", "express": "^4.17.1", + "fetch-retry": "^6.0.0", "fastq": "^1.17.1", "get-port": "^5.1.1", "go-ipfs": "npm:mocked-go-ipfs@0.17.0", diff --git a/packages/backend/src/nest/connections-manager/connections-manager.module.ts b/packages/backend/src/nest/connections-manager/connections-manager.module.ts index 303c3d663c..42a6238947 100644 --- a/packages/backend/src/nest/connections-manager/connections-manager.module.ts +++ b/packages/backend/src/nest/connections-manager/connections-manager.module.ts @@ -5,9 +5,10 @@ import { SocketModule } from '../socket/socket.module' import { StorageModule } from '../storage/storage.module' import { TorModule } from '../tor/tor.module' import { ConnectionsManagerService } from './connections-manager.service' +import { ServerProxyServiceModule } from '../storageServerProxy/storageServerProxy.module' @Module({ - imports: [RegistrationModule, StorageModule, TorModule, SocketModule, LocalDbModule], + imports: [RegistrationModule, StorageModule, TorModule, SocketModule, LocalDbModule, ServerProxyServiceModule], providers: [ConnectionsManagerService], exports: [ConnectionsManagerService], }) diff --git a/packages/backend/src/nest/connections-manager/connections-manager.service.tor.spec.ts b/packages/backend/src/nest/connections-manager/connections-manager.service.tor.spec.ts index 5753e3feb0..9f448837d9 100644 --- a/packages/backend/src/nest/connections-manager/connections-manager.service.tor.spec.ts +++ b/packages/backend/src/nest/connections-manager/connections-manager.service.tor.spec.ts @@ -178,7 +178,6 @@ describe('Connections manager', () => { const spyOnDestroyHiddenService = jest.spyOn(tor, 'destroyHiddenService') await connectionsManagerService.init() const network = await connectionsManagerService.getNetwork() - console.log('network', network) expect(network.hiddenService.onionAddress.split('.')[0]).toHaveLength(56) expect(network.hiddenService.privateKey).toHaveLength(99) const peerId = await PeerId.createFromJSON(network.peerId) diff --git a/packages/backend/src/nest/connections-manager/connections-manager.service.ts b/packages/backend/src/nest/connections-manager/connections-manager.service.ts index f52de0f6bb..fef770f7fb 100644 --- a/packages/backend/src/nest/connections-manager/connections-manager.service.ts +++ b/packages/backend/src/nest/connections-manager/connections-manager.service.ts @@ -1,69 +1,68 @@ +import { peerIdFromKeys } from '@libp2p/peer-id' import { Inject, Injectable, OnModuleInit } from '@nestjs/common' import { Crypto } from '@peculiar/webcrypto' -import { Agent } from 'https' -import fs from 'fs' -import path from 'path' -import { peerIdFromKeys } from '@libp2p/peer-id' -import { setEngine, CryptoEngine } from 'pkijs' import { EventEmitter } from 'events' +import fs from 'fs' import getPort from 'get-port' +import { Agent } from 'https' +import path from 'path' import PeerId from 'peer-id' +import { CryptoEngine, setEngine } from 'pkijs' import { getLibp2pAddressesFromCsrs, removeFilesFromDir } from '../common/utils' +import { LazyModuleLoader } from '@nestjs/core' +import { createLibp2pAddress, isPSKcodeValid, p2pAddressesToPairs } from '@quiet/common' +import { CertFieldsTypes, getCertFieldValue, loadCertificate } from '@quiet/identity' import { - GetMessagesPayload, ChannelMessageIdsResponse, - type DeleteChannelResponse, + ChannelSubscribedPayload, ChannelsReplicatedPayload, Community, - CommunityId, + CommunityMetadata, + CommunityOwnership, ConnectionProcessInfo, CreateChannelPayload, CreateChannelResponse, + CreateNetworkPayload, DeleteFilesFromChannelSocketPayload, DownloadStatus, ErrorMessages, FileMetadata, - MessagesLoadedPayload, + GetMessagesPayload, InitCommunityPayload, + MessagesLoadedPayload, NetworkDataPayload, NetworkInfo, NetworkStats, - type SavedOwnerCertificatePayload, PushNotificationPayload, - RegisterOwnerCertificatePayload, RemoveDownloadStatus, + SaveCSRPayload, SendCertificatesResponse, SendMessagePayload, - ChannelSubscribedPayload, SocketActionTypes, - StorePeerListPayload, UploadFilePayload, - PeerId as PeerIdType, - SaveCSRPayload, - CommunityMetadata, - type PermsData, + type DeleteChannelResponse, + type SavedOwnerCertificatePayload, type UserProfile, type UserProfilesStoredEvent, } from '@quiet/types' +import Logger from '../common/logger' import { CONFIG_OPTIONS, QUIET_DIR, SERVER_IO_PROVIDER, SOCKS_PROXY_AGENT } from '../const' -import { ConfigOptions, GetPorts, ServerIoProviderTypes } from '../types' -import { SocketService } from '../socket/socket.service' -import { RegistrationService } from '../registration/registration.service' -import { LocalDbService } from '../local-db/local-db.service' -import { StorageService } from '../storage/storage.service' -import { ServiceState, TorInitState } from './connections-manager.types' import { Libp2pService } from '../libp2p/libp2p.service' -import { Tor } from '../tor/tor.service' -import { LocalDBKeys } from '../local-db/local-db.types' import { Libp2pEvents, Libp2pNodeParams } from '../libp2p/libp2p.types' +import { LocalDbService } from '../local-db/local-db.service' +import { LocalDBKeys } from '../local-db/local-db.types' +import { RegistrationService } from '../registration/registration.service' import { RegistrationEvents } from '../registration/registration.types' -import { StorageEvents } from '../storage/storage.types' -import { LazyModuleLoader } from '@nestjs/core' -import Logger from '../common/logger' import { emitError } from '../socket/socket.errors' -import { createLibp2pAddress, isPSKcodeValid } from '@quiet/common' -import { CertFieldsTypes, createRootCA, getCertFieldValue, loadCertificate } from '@quiet/identity' +import { SocketService } from '../socket/socket.service' +import { StorageService } from '../storage/storage.service' +import { StorageEvents } from '../storage/storage.types' +import { ServerProxyService } from '../storageServerProxy/storageServerProxy.service' +import { ServerStoredCommunityMetadata } from '../storageServerProxy/storageServerProxy.types' +import { Tor } from '../tor/tor.service' +import { ConfigOptions, GetPorts, ServerIoProviderTypes } from '../types' +import { ServiceState, TorInitState } from './connections-manager.types' @Injectable() export class ConnectionsManagerService extends EventEmitter implements OnModuleInit { @@ -81,6 +80,7 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI @Inject(SOCKS_PROXY_AGENT) public readonly socksProxyAgent: Agent, private readonly socketService: SocketService, private readonly registrationService: RegistrationService, + private readonly storageServerProxyService: ServerProxyService, private readonly localDbService: LocalDbService, private readonly storageService: StorageService, private readonly tor: Tor, @@ -628,6 +628,33 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI await this.storageService?.saveCSR(payload) }) + this.socketService.on( + SocketActionTypes.DOWNLOAD_INVITE_DATA, + async (payload: { cid: string; serverAddress: string }, callback: (response: CreateNetworkPayload) => void) => { + this.logger(`socketService - ${SocketActionTypes.DOWNLOAD_INVITE_DATA}`) + this.storageServerProxyService.setServerAddress(payload.serverAddress) + let downloadedData: ServerStoredCommunityMetadata + try { + downloadedData = await this.storageServerProxyService.downloadData(payload.cid) + } catch (e) { + this.logger.error(`Downloading community data failed`, e) + emitError(this.serverIoProvider.io, { + type: SocketActionTypes.DOWNLOAD_INVITE_DATA, + message: ErrorMessages.STORAGE_SERVER_CONNECTION_FAILED, + }) + return + } + + const createNetworkPayload: CreateNetworkPayload = { + ownership: CommunityOwnership.User, + peers: p2pAddressesToPairs(downloadedData.peerList), + psk: downloadedData.psk, + ownerOrbitDbIdentity: downloadedData.ownerOrbitDbIdentity, + } + callback(createNetworkPayload) + } + ) + // Public Channels this.socketService.on( SocketActionTypes.CREATE_CHANNEL, diff --git a/packages/backend/src/nest/registration/registration.functions.ts b/packages/backend/src/nest/registration/registration.functions.ts index 3eea07d353..16ddf4d6af 100644 --- a/packages/backend/src/nest/registration/registration.functions.ts +++ b/packages/backend/src/nest/registration/registration.functions.ts @@ -64,7 +64,6 @@ export const extractPendingCsrs = async (payload: { csrs: string[]; certificates pendingCsrs.push(csr) } } - logger('DuplicatedCertBug', { parsedUniqueCsrs, pendingNames, certNames }) return pendingCsrs } diff --git a/packages/backend/src/nest/registration/registration.service.ts b/packages/backend/src/nest/registration/registration.service.ts index 9c04e2bb56..87afbada72 100644 --- a/packages/backend/src/nest/registration/registration.service.ts +++ b/packages/backend/src/nest/registration/registration.service.ts @@ -62,7 +62,6 @@ export class RegistrationService extends EventEmitter implements OnModuleInit { certificates: (await this.storageService?.loadAllCertificates()) as string[], }) - this.logger('Finished issuing certificates') // Event processing finished this.registrationEventInProgress = false @@ -90,14 +89,13 @@ export class RegistrationService extends EventEmitter implements OnModuleInit { return } - this.logger('DuplicatedCertBug', { payload }) const pendingCsrs = await extractPendingCsrs(payload) - this.logger('DuplicatedCertBug', { pendingCsrs }) await Promise.all( pendingCsrs.map(async csr => { await this.registerUserCertificate(csr) }) ) + this.logger('Finished issuing certificates') } // TODO: This doesn't save the owner's certificate in OrbitDB, so perhaps we @@ -121,7 +119,6 @@ export class RegistrationService extends EventEmitter implements OnModuleInit { public async registerUserCertificate(csr: string): Promise { const result = await issueCertificate(csr, this.permsData) - this.logger('DuplicatedCertBug', { result }) if (result?.cert) { // Save certificate (awaited) so that we are sure that the certs // are saved before processing the next round of CSRs. diff --git a/packages/backend/src/nest/socket/socket.service.ts b/packages/backend/src/nest/socket/socket.service.ts index 77364128dd..48b51151d5 100644 --- a/packages/backend/src/nest/socket/socket.service.ts +++ b/packages/backend/src/nest/socket/socket.service.ts @@ -174,15 +174,8 @@ export class SocketService extends EventEmitter implements OnModuleInit { socket.on( SocketActionTypes.DOWNLOAD_INVITE_DATA, async (payload: { serverAddress: string; cid: string }, callback: (response: CreateNetworkPayload) => void) => { - // this.emit(SocketActionTypes.DOWNLOAD_INVITE_DATA, payload, callback) - console.log('download invite data', payload) - // Mock it for now - callback({ - ownership: CommunityOwnership.User, - peers: [], - psk: '', - ownerOrbitDbIdentity: '', - }) + console.log('SOCKET Downloading invite data', payload) + this.emit(SocketActionTypes.DOWNLOAD_INVITE_DATA, payload, callback) } ) diff --git a/packages/backend/src/nest/storage/certifacteRequests/certificatesRequestsStore.ts b/packages/backend/src/nest/storage/certifacteRequests/certificatesRequestsStore.ts index 5c30871d59..e070fda53a 100644 --- a/packages/backend/src/nest/storage/certifacteRequests/certificatesRequestsStore.ts +++ b/packages/backend/src/nest/storage/certifacteRequests/certificatesRequestsStore.ts @@ -100,14 +100,11 @@ export class CertificatesRequestsStore extends EventEmitter { return e.payload.value }) - this.logger('DuplicatedCertBug', { allEntries }) const allCsrsUnique = [...new Set(allEntries)] - this.logger('DuplicatedCertBug', { allCsrsUnique }) await Promise.all( allCsrsUnique .filter(async csr => { const validation = await CertificatesRequestsStore.validateUserCsr(csr) - this.logger('DuplicatedCertBug', { validation, csr }) if (validation) return true return false }) @@ -121,7 +118,6 @@ export class CertificatesRequestsStore extends EventEmitter { filteredCsrsMap.set(pubKey, csr) }) ) - this.logger('DuplicatedCertBug', '[...filteredCsrsMap.values()]', [...filteredCsrsMap.values()]) return [...filteredCsrsMap.values()] } diff --git a/packages/backend/src/nest/storage/certificates/certificates.store.ts b/packages/backend/src/nest/storage/certificates/certificates.store.ts index 66fb655ffc..e9c833b8d2 100644 --- a/packages/backend/src/nest/storage/certificates/certificates.store.ts +++ b/packages/backend/src/nest/storage/certificates/certificates.store.ts @@ -166,7 +166,6 @@ export class CertificatesStore extends EventEmitter { } const validation = await this.validateCertificate(certificate) - this.logger('DuplicatedCertBug', { validation, certificate }) if (validation) { const parsedCertificate = parseCertificate(certificate) const pubkey = keyFromCertificate(parsedCertificate) diff --git a/packages/backend/src/nest/storage/storage.service.spec.ts b/packages/backend/src/nest/storage/storage.service.spec.ts index fa65bcd37b..c07906efe5 100644 --- a/packages/backend/src/nest/storage/storage.service.spec.ts +++ b/packages/backend/src/nest/storage/storage.service.spec.ts @@ -43,6 +43,7 @@ import { CertificatesRequestsStore } from './certifacteRequests/certificatesRequ import { CertificatesStore } from './certificates/certificates.store' import { CommunityMetadataStore } from './communityMetadata/communityMetadata.store' import { OrbitDb } from './orbitDb/orbitDb.service' +import { UserProfileStore } from './userProfile/userProfile.store' const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) @@ -72,6 +73,7 @@ describe('StorageService', () => { let certificatesRequestsStore: CertificatesRequestsStore let certificatesStore: CertificatesStore let communityMetadataStore: CommunityMetadataStore + let userProfileStore: UserProfileStore let orbitDbService: OrbitDb let peerId: PeerId @@ -135,7 +137,7 @@ describe('StorageService', () => { certificatesRequestsStore = await module.resolve(CertificatesRequestsStore) certificatesStore = await module.resolve(CertificatesStore) communityMetadataStore = await module.resolve(CommunityMetadataStore) - console.log({ communityMetadataStore }) + userProfileStore = await module.resolve(UserProfileStore) lazyModuleLoader = await module.resolve(LazyModuleLoader) orbitDbDir = await module.resolve(ORBIT_DB_DIR) @@ -262,16 +264,17 @@ describe('StorageService', () => { const certificatesDbAddress = certificatesStore.getAddress() const certificatesRequestsDbAddress = certificatesRequestsStore.getAddress() const communityMetadataDbAddress = communityMetadataStore.getAddress() + const userProfileDbAddress = userProfileStore.getAddress() expect(channelsDbAddress).not.toBeFalsy() expect(certificatesDbAddress).not.toBeFalsy() expect(subscribeToPubSubSpy).toBeCalledTimes(2) // Storage initialization: expect(subscribeToPubSubSpy).toHaveBeenNthCalledWith(1, [ + StorageService.dbAddress(communityMetadataDbAddress), StorageService.dbAddress(channelsDbAddress), StorageService.dbAddress(certificatesDbAddress), StorageService.dbAddress(certificatesRequestsDbAddress), - StorageService.dbAddress(communityMetadataDbAddress), - '/orbitdb/zdpuAyScVHonV7KUdb3rdNmC9ZurssGdfgveYm3ds7KNJ6CpU/user-profiles', + StorageService.dbAddress(userProfileDbAddress), ]) // Creating channel: expect(subscribeToPubSubSpy).toHaveBeenNthCalledWith(2, [StorageService.dbAddress(db.address)]) diff --git a/packages/backend/src/nest/storage/storage.service.ts b/packages/backend/src/nest/storage/storage.service.ts index 1d9ba7eafe..2173581d63 100644 --- a/packages/backend/src/nest/storage/storage.service.ts +++ b/packages/backend/src/nest/storage/storage.service.ts @@ -153,7 +153,9 @@ export class StorageService extends EventEmitter { private async startReplicate() { const dbs = [] - + if (this.communityMetadataStore?.getAddress()) { + dbs.push(this.communityMetadataStore.getAddress()) + } if (this.channels?.address) { dbs.push(this.channels.address) } @@ -163,9 +165,6 @@ export class StorageService extends EventEmitter { if (this.certificatesRequestsStore.getAddress()) { dbs.push(this.certificatesRequestsStore.getAddress()) } - if (this.communityMetadataStore?.getAddress()) { - dbs.push(this.communityMetadataStore.getAddress()) - } if (this.userProfileStore.getAddress()) { dbs.push(this.userProfileStore.getAddress()) } diff --git a/packages/backend/src/nest/storageServerProxy/storageServerProxy.module.ts b/packages/backend/src/nest/storageServerProxy/storageServerProxy.module.ts new file mode 100644 index 0000000000..9fc2a49862 --- /dev/null +++ b/packages/backend/src/nest/storageServerProxy/storageServerProxy.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common' +import { ServerProxyService } from './storageServerProxy.service' + +@Module({ + providers: [ServerProxyService], + exports: [ServerProxyService], +}) +export class ServerProxyServiceModule {} diff --git a/packages/backend/src/nest/storageServerProxy/storageServerProxy.service.spec.ts b/packages/backend/src/nest/storageServerProxy/storageServerProxy.service.spec.ts new file mode 100644 index 0000000000..f61d0eaa35 --- /dev/null +++ b/packages/backend/src/nest/storageServerProxy/storageServerProxy.service.spec.ts @@ -0,0 +1,79 @@ +import { Test } from '@nestjs/testing' +import { ServerProxyServiceModule } from './storageServerProxy.module' +import { ServerProxyService } from './storageServerProxy.service' +import { ServerStoredCommunityMetadata } from './storageServerProxy.types' +import { jest } from '@jest/globals' +import { prepareResponse } from './testUtils' +import { createLibp2pAddress, getValidInvitationUrlTestData, validInvitationDatav1 } from '@quiet/common' + +const mockFetch = async (responseData: Partial[]) => { + /** Mock fetch responses and then initialize nest service */ + const mockedFetch = jest.fn(() => { + return Promise.resolve(prepareResponse(responseData[0])) + }) + + for (const data of responseData) { + mockedFetch.mockResolvedValueOnce(prepareResponse(data)) + } + + global.fetch = mockedFetch + const module = await Test.createTestingModule({ + imports: [ServerProxyServiceModule], + }).compile() + return module.get(ServerProxyService) +} + +describe('Server Proxy Service', () => { + let clientMetadata: ServerStoredCommunityMetadata + beforeEach(() => { + const data = getValidInvitationUrlTestData(validInvitationDatav1[0]).data + clientMetadata = { + id: '12345678', + ownerCertificate: 'MIIDeTCCAyCgAwIBAgIGAYv8J0ToMAoGCCqGSM49BAMCMBIxEDAOBgNVBAMTB21hYzIzMT', + rootCa: 'MIIBUjCB+KADAgECAgEBMAoGCCqGSM49BAMCMBIxEDAOBgNVBAM', + ownerOrbitDbIdentity: data.ownerOrbitDbIdentity, + peerList: [createLibp2pAddress(data.pairs[0].onionAddress, data.pairs[0].peerId)], + psk: data.psk, + } + }) + + afterEach(async () => { + jest.clearAllMocks() + }) + + it('downloads data for existing cid and proper server address', async () => { + const service = await mockFetch([ + { status: 200, json: () => Promise.resolve({ access_token: 'secretToken' }) }, + { status: 200, json: () => Promise.resolve(clientMetadata) }, + ]) + service.setServerAddress('http://whatever') + const data = await service.downloadData('cid') + expect(data).toEqual(clientMetadata) + expect(global.fetch).toHaveBeenCalledTimes(2) + }) + + it('throws error if downloaded metadata does not have proper schema', async () => { + const metadataLackingField = { + id: clientMetadata.id, + ownerCertificate: clientMetadata.ownerCertificate, + rootCa: clientMetadata.rootCa, + ownerOrbitDbIdentity: clientMetadata.ownerOrbitDbIdentity, + peerList: clientMetadata.peerList, + } + const service = await mockFetch([ + { status: 200, json: () => Promise.resolve({ access_token: 'secretToken' }) }, + { status: 200, json: () => Promise.resolve(metadataLackingField) }, + ]) + service.setServerAddress('http://whatever') + expect(service.downloadData('cid')).rejects.toThrow('Invalid metadata') + }) + + it('obtains token', async () => { + const expectedToken = 'verySecretToken' + const service = await mockFetch([{ status: 200, json: () => Promise.resolve({ access_token: expectedToken }) }]) + service.setServerAddress('http://whatever') + const token = await service.auth() + expect(token).toEqual(expectedToken) + expect(global.fetch).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/backend/src/nest/storageServerProxy/storageServerProxy.service.ts b/packages/backend/src/nest/storageServerProxy/storageServerProxy.service.ts new file mode 100644 index 0000000000..16728f660d --- /dev/null +++ b/packages/backend/src/nest/storageServerProxy/storageServerProxy.service.ts @@ -0,0 +1,102 @@ +import { Injectable } from '@nestjs/common' +import EventEmitter from 'events' +import { ServerStoredCommunityMetadata } from './storageServerProxy.types' +import fetchRetry from 'fetch-retry' +import Logger from '../common/logger' +import { isServerStoredMetadata } from '../validation/validators' + +class HTTPResponseError extends Error { + response: Response + constructor(message: string, response: Response) { + super(`${message}: ${response.status} ${response.statusText}`) + this.response = response + } +} + +@Injectable() +export class ServerProxyService extends EventEmitter { + private readonly logger = Logger(ServerProxyService.name) + _serverAddress: string + fetch: any + + constructor() { + super() + this.fetch = fetchRetry(global.fetch) + } + + get serverAddress() { + if (!this._serverAddress) throw new Error('Server address is required') + return this._serverAddress + } + + setServerAddress = (serverAddress: string) => { + this._serverAddress = serverAddress + } + + get authUrl() { + const authUrl = new URL('auth', this.serverAddress) + return authUrl.href + } + + getInviteUrl = (cid: string) => { + const invitationUrl = new URL('invite', this.serverAddress) + invitationUrl.searchParams.append('CID', cid) + return invitationUrl.href + } + + getAuthorizationHeader = (token: string) => { + return `Bearer ${token}` + } + + auth = async (): Promise => { + this.logger('Authenticating') + const authResponse = await this.fetch(this.authUrl, { + method: 'POST', + }) + this.logger('Auth response status', authResponse.status) + const authResponseData = await authResponse.json() + return authResponseData['access_token'] + } + + validateMetadata = (data: ServerStoredCommunityMetadata) => { + if (!isServerStoredMetadata(data)) throw new Error('Invalid metadata') + } + + public downloadData = async (cid: string): Promise => { + this.logger(`Downloading data for cid: ${cid}`) + const accessToken = await this.auth() + const dataResponse: Response = await this.fetch(this.getInviteUrl(cid), { + method: 'GET', + headers: { Authorization: this.getAuthorizationHeader(accessToken) }, + retries: 3, + }) + this.logger('Download data response status', dataResponse.status) + if (!dataResponse.ok) { + throw new HTTPResponseError('Failed to download data', dataResponse) + } + const data: ServerStoredCommunityMetadata = await dataResponse.json() + this.validateMetadata(data) + this.logger('Downloaded data', data) + return data + } + + public uploadData = async (cid: string, data: ServerStoredCommunityMetadata) => { + this.logger(`Uploading data for cid: ${cid}`, data) + this.validateMetadata(data) + const accessToken = await this.auth() + const dataResponse: Response = await this.fetch(this.getInviteUrl(cid), { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: this.getAuthorizationHeader(accessToken), + }, + body: JSON.stringify(data), + retries: 3, + }) + this.logger('Upload data response status', dataResponse.status) + if (!dataResponse.ok) { + throw new HTTPResponseError('Failed to upload data', dataResponse) + } + return cid + } +} diff --git a/packages/backend/src/nest/storageServerProxy/storageServerProxy.types.ts b/packages/backend/src/nest/storageServerProxy/storageServerProxy.types.ts new file mode 100644 index 0000000000..281f3c749a --- /dev/null +++ b/packages/backend/src/nest/storageServerProxy/storageServerProxy.types.ts @@ -0,0 +1,8 @@ +export type ServerStoredCommunityMetadata = { + id: string + ownerCertificate: string + rootCa: string + ownerOrbitDbIdentity: string + peerList: string[] + psk: string +} diff --git a/packages/backend/src/nest/storageServerProxy/testUtils.ts b/packages/backend/src/nest/storageServerProxy/testUtils.ts new file mode 100644 index 0000000000..710be9b126 --- /dev/null +++ b/packages/backend/src/nest/storageServerProxy/testUtils.ts @@ -0,0 +1,34 @@ +export const prepareResponse = (responseData: Partial) => { + const ok = responseData.status ? responseData.status >= 200 && responseData.status < 300 : false + const response: Response = { + headers: new Headers(), + ok, + redirected: false, + status: 200, + statusText: '', + type: 'basic', + url: '', + clone: function (): Response { + throw new Error('Function not implemented.') + }, + body: null, + bodyUsed: false, + arrayBuffer: function (): Promise { + throw new Error('Function not implemented.') + }, + blob: function (): Promise { + throw new Error('Function not implemented.') + }, + formData: function (): Promise { + throw new Error('Function not implemented.') + }, + json: function (): Promise { + throw new Error('Function not implemented.') + }, + text: function (): Promise { + throw new Error('Function not implemented.') + }, + ...responseData, + } + return response +} diff --git a/packages/backend/src/nest/validation/validators.ts b/packages/backend/src/nest/validation/validators.ts index cc872c26ed..fff7900123 100644 --- a/packages/backend/src/nest/validation/validators.ts +++ b/packages/backend/src/nest/validation/validators.ts @@ -1,6 +1,8 @@ import _ from 'validator' import joi from 'joi' import { ChannelMessage, PublicChannel } from '@quiet/types' +import { ServerStoredCommunityMetadata } from '../storageServerProxy/storageServerProxy.types' +import { isPSKcodeValid } from '@quiet/common' const messageMediaSchema = joi.object({ path: joi.string().allow(null), @@ -38,6 +40,21 @@ const channelSchema = joi.object({ address: joi.string(), }) +// TODO: make this validator more strict +const metadataSchema = joi.object({ + id: joi.string().required(), + ownerCertificate: joi.string().required(), + rootCa: joi.string().required(), + ownerOrbitDbIdentity: joi.string().required(), + peerList: joi.array().items(joi.string()).required(), + psk: joi + .string() + .required() + .custom((value, _helpers) => { + return isPSKcodeValid(value) + }), +}) + export const isUser = (publicKey: string, halfKey: string): boolean => { return publicKey.length === 66 && halfKey.length === 64 && _.isHexadecimal(publicKey) && _.isHexadecimal(halfKey) } @@ -61,10 +78,18 @@ export const isChannel = (channel: PublicChannel): boolean => { return !value.error } +export const isServerStoredMetadata = (metadata: ServerStoredCommunityMetadata): boolean => { + const value = metadataSchema.validate(metadata) + // Leave this log for first iterations of QSS + console.log(value.error) + return !value.error +} + export default { isUser, isMessage, isDirectMessage, isChannel, isConversation, + isServerStoredMetadata, } diff --git a/packages/desktop/src/renderer/components/CreateJoinCommunity/JoinCommunity/JoinCommunity.stories.tsx b/packages/desktop/src/renderer/components/CreateJoinCommunity/JoinCommunity/JoinCommunity.stories.tsx index 0b4dd62fb1..ba9d505817 100644 --- a/packages/desktop/src/renderer/components/CreateJoinCommunity/JoinCommunity/JoinCommunity.stories.tsx +++ b/packages/desktop/src/renderer/components/CreateJoinCommunity/JoinCommunity/JoinCommunity.stories.tsx @@ -11,6 +11,7 @@ const Template: ComponentStory = args => } export const Component = Template.bind({}) +export const ServerError = Template.bind({}) const args: PerformCommunityActionProps = { open: true, @@ -28,7 +29,16 @@ const args: PerformCommunityActionProps = { revealInputValue: false, } +const serverErrorArgs: PerformCommunityActionProps = { + ...args, + serverErrorMessage: 'Could not connect to the server', + clearServerError: function (): void { + console.log('Clearing server error message') + }, +} + Component.args = args +ServerError.args = serverErrorArgs const component: ComponentMeta = { title: 'Components/JoinCommunity', diff --git a/packages/desktop/src/renderer/components/CreateJoinCommunity/JoinCommunity/JoinCommunity.tsx b/packages/desktop/src/renderer/components/CreateJoinCommunity/JoinCommunity/JoinCommunity.tsx index 0d03c60f01..c15a598c2c 100644 --- a/packages/desktop/src/renderer/components/CreateJoinCommunity/JoinCommunity/JoinCommunity.tsx +++ b/packages/desktop/src/renderer/components/CreateJoinCommunity/JoinCommunity/JoinCommunity.tsx @@ -1,11 +1,12 @@ -import { communities, connection, identity } from '@quiet/state-manager' -import { CommunityOwnership, InvitationData } from '@quiet/types' +import { communities, connection, errors, identity } from '@quiet/state-manager' +import { CommunityOwnership, InvitationData, SocketActionTypes } from '@quiet/types' import React, { useEffect, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' import PerformCommunityActionComponent from '../../../components/CreateJoinCommunity/PerformCommunityActionComponent' import { useModal } from '../../../containers/hooks' import { ModalName } from '../../../sagas/modals/modals.types' import { socketSelectors } from '../../../sagas/socket/socket.selectors' +import { errors as errorsState } from '@quiet/state-manager' const JoinCommunity = () => { const dispatch = useDispatch() @@ -20,7 +21,12 @@ const JoinCommunity = () => { const torBootstrapProcessSelector = useSelector(connection.selectors.torBootstrapProcess) + const downloadInviteDataError = useSelector( + errors.selectors.generalErrorByType(SocketActionTypes.DOWNLOAD_INVITE_DATA) + ) + const [revealInputValue, setRevealInputValue] = useState(false) + const [serverErrorMessage, setServerErrorMessage] = useState('') useEffect(() => { if (isConnected && !currentCommunity && !joinCommunityModal.open) { @@ -47,6 +53,19 @@ const JoinCommunity = () => { } } + useEffect(() => { + if (downloadInviteDataError?.message) { + setServerErrorMessage(downloadInviteDataError.message) + } + }, [downloadInviteDataError]) + + const clearServerError = () => { + if (downloadInviteDataError) { + dispatch(errorsState.actions.clearError(downloadInviteDataError)) + setServerErrorMessage('') + } + } + const handleClickInputReveal = () => { revealInputValue ? setRevealInputValue(false) : setRevealInputValue(true) } @@ -62,6 +81,8 @@ const JoinCommunity = () => { hasReceivedResponse={Boolean(currentIdentity && !currentIdentity.userCertificate)} revealInputValue={revealInputValue} handleClickInputReveal={handleClickInputReveal} + serverErrorMessage={serverErrorMessage} + clearServerError={clearServerError} /> ) } diff --git a/packages/desktop/src/renderer/components/CreateJoinCommunity/PerformCommunityActionComponent.tsx b/packages/desktop/src/renderer/components/CreateJoinCommunity/PerformCommunityActionComponent.tsx index 5bff2b131f..8e08f02c8e 100644 --- a/packages/desktop/src/renderer/components/CreateJoinCommunity/PerformCommunityActionComponent.tsx +++ b/packages/desktop/src/renderer/components/CreateJoinCommunity/PerformCommunityActionComponent.tsx @@ -12,7 +12,7 @@ import { LoadingButton } from '../ui/LoadingButton/LoadingButton' import { CreateCommunityDictionary, JoinCommunityDictionary } from '../CreateJoinCommunity/community.dictionary' -import { CommunityOwnership, InvitationData, InvitationPair } from '@quiet/types' +import { CommunityOwnership, ErrorPayload } from '@quiet/types' import { Controller, useForm } from 'react-hook-form' import { TextInput } from '../../forms/components/textInput' @@ -20,7 +20,7 @@ import { InviteLinkErrors } from '../../forms/fieldsErrors' import { IconButton, InputAdornment } from '@mui/material' import VisibilityOff from '@mui/icons-material/VisibilityOff' import Visibility from '@mui/icons-material/Visibility' -import { composeInvitationShareUrl, parseName } from '@quiet/common' +import { parseName } from '@quiet/common' import { getInvitationCodes } from '@quiet/state-manager' const PREFIX = 'PerformCommunityActionComponent' @@ -137,6 +137,8 @@ export interface PerformCommunityActionProps { hasReceivedResponse: boolean revealInputValue?: boolean handleClickInputReveal?: () => void + serverErrorMessage?: string + clearServerError?: () => void } export const PerformCommunityActionComponent: React.FC = ({ @@ -150,6 +152,8 @@ export const PerformCommunityActionComponent: React.FC { const [formSent, setFormSent] = useState(false) @@ -174,6 +178,14 @@ export const PerformCommunityActionComponent: React.FC { + if (serverErrorMessage) { + setError('name', { message: serverErrorMessage }) + setFormSent(false) + clearServerError?.() + } + }, [serverErrorMessage]) + const onSubmit = (values: PerformCommunityActionFormValues) => submitForm(handleCommunityAction, values, setFormSent) const submitForm = ( diff --git a/packages/e2e-tests/src/QSS/Dockerfile b/packages/e2e-tests/src/QSS/Dockerfile new file mode 100644 index 0000000000..80b5543413 --- /dev/null +++ b/packages/e2e-tests/src/QSS/Dockerfile @@ -0,0 +1,6 @@ +FROM node:18.12.1@sha256:e9ad817b0d42b4d177a4bef8a0aff97c352468a008c3fdb2b4a82533425480df +RUN git clone https://github.com/TryQuiet/quiet-storage-service.git +WORKDIR /quiet-storage-service +RUN mkdir storage +RUN npm install +CMD JWT_SECRET=101010 npm run start \ No newline at end of file diff --git a/packages/e2e-tests/src/QSS/README.md b/packages/e2e-tests/src/QSS/README.md new file mode 100644 index 0000000000..7a1edd023f --- /dev/null +++ b/packages/e2e-tests/src/QSS/README.md @@ -0,0 +1,4 @@ +## Run qss for e2e test + +docker build -t qss . +docker run -p 3000:3000 -v $(pwd)/storage/:/quiet-storage-service/storage/ qss \ No newline at end of file diff --git a/packages/e2e-tests/src/QSS/storage/QmaRchXhkPWq8iLiMZwFfd2Yi4iESWhAYYJt8cTCVXSwpG.json b/packages/e2e-tests/src/QSS/storage/QmaRchXhkPWq8iLiMZwFfd2Yi4iESWhAYYJt8cTCVXSwpG.json new file mode 100644 index 0000000000..717a1fa501 --- /dev/null +++ b/packages/e2e-tests/src/QSS/storage/QmaRchXhkPWq8iLiMZwFfd2Yi4iESWhAYYJt8cTCVXSwpG.json @@ -0,0 +1,10 @@ + { + "id": "id", + "rootCa": "rootCa", + "ownerCertificate": "ownerCertificate", + "ownerOrbitDbIdentity": "ownerOrbitDbIdentity", + "peerList": [ + "/dns4/pgzlcstu4ljvma7jqyalimcxlvss5bwlbba3c3iszgtwxee4qjdlgeqd.onion/tcp/80/ws/p2p/QmaRchXhkPWq8iLiMZwFfd2Yi4iESWhAYYJt8cTCVXSwpG" + ], + "psk": "5T9GBVpDoRpKJQK4caDTz5e5nym2zprtoySL2oLrzr4=" + } \ No newline at end of file diff --git a/packages/e2e-tests/src/tests/joiningWithQSS.test.ts b/packages/e2e-tests/src/tests/joiningWithQSS.test.ts new file mode 100644 index 0000000000..97a1ef20a5 --- /dev/null +++ b/packages/e2e-tests/src/tests/joiningWithQSS.test.ts @@ -0,0 +1,147 @@ +import { composeInvitationShareUrl, createLibp2pAddress, parseInvitationCode } from '@quiet/common' +import { App, Channel, CreateCommunityModal, JoinCommunityModal, RegisterUsernameModal, Sidebar } from '../selectors' +import { InvitationDataV1, InvitationDataV2, InvitationDataVersion } from '@quiet/types' +import { UserTestData } from '../types' +import { sleep } from '../utils' +import fs from 'fs' +import path from 'path' + +jest.setTimeout(1200000) // 20 minutes + +// Run QSS locally before this test +const serverAddress = 'http://127.0.0.1:3000' + +describe('User joining with storage server', () => { + let users: Record + const communityName = 'Filmmakers' + let generalChannelOwner: Channel + let invitationLinkV1: string + let invitationLinkV2: string + + beforeAll(async () => { + users = { + owner: { + username: 'owner', + messages: ['Hi'], + app: new App(), + }, + user1: { + username: 'user-joining-1', + messages: ['Nice to meet you all'], + app: new App(), + }, + } + }) + + afterAll(async () => { + await users.owner.app.close() + await users.user1.app.close() + }) + + describe('Owner creates the community', () => { + it('Owner opens the app', async () => { + await users.owner.app.open() + }) + + it('Owner sees "join community" modal and switches to "create community" modal', async () => { + const joinModal = new JoinCommunityModal(users.owner.app.driver) + const isJoinModal = await joinModal.element.isDisplayed() + expect(isJoinModal).toBeTruthy() + await joinModal.switchToCreateCommunity() + }) + it('Owner submits valid community name', async () => { + const createModal = new CreateCommunityModal(users.owner.app.driver) + const isCreateModal = await createModal.element.isDisplayed() + expect(isCreateModal).toBeTruthy() + await createModal.typeCommunityName(communityName) + await createModal.submit() + }) + it('Owner sees "register username" modal and submits valid username', async () => { + const registerModal = new RegisterUsernameModal(users.owner.app.driver) + const isRegisterModal = await registerModal.element.isDisplayed() + expect(isRegisterModal).toBeTruthy() + await registerModal.typeUsername(users.owner.username) + await registerModal.submit() + }) + it('Owner registers successfully and sees general channel', async () => { + generalChannelOwner = new Channel(users.owner.app.driver, 'general') + const isGeneralChannel = await generalChannelOwner.element.isDisplayed() + const generalChannelText = await generalChannelOwner.element.getText() + expect(isGeneralChannel).toBeTruthy() + expect(generalChannelText).toEqual('# general') + }) + it('Owner opens the settings tab and gets an invitation link', async () => { + const settingsModal = await new Sidebar(users.owner.app.driver).openSettings() + const isSettingsModal = await settingsModal.element.isDisplayed() + expect(isSettingsModal).toBeTruthy() + const invitationCodeElement = await settingsModal.invitationCode() + await sleep(2000) + invitationLinkV1 = await invitationCodeElement.getText() + await sleep(2000) + console.log({ invitationLinkV1 }) + expect(invitationLinkV1).not.toBeUndefined() + await settingsModal.close() + }) + }) + + describe('Guest joins using v2 invitation link', () => { + it('Prepare v2 link', async () => { + // Workaround until we have an UI for retrieving v2 invitation link from owner + // Compose server data from v1 invitation link + // @ts-expect-error + const v1Data: InvitationDataV1 = parseInvitationCode(invitationLinkV1.split('#')[1]) + const data: InvitationDataV2 = { + version: InvitationDataVersion.v2, + cid: 'QmaRchXhkPWq8iLiMZwFfd2Yi4iESWhAYYJt8cTCVXSwpG', + token: '898989', + serverAddress: serverAddress, + inviterAddress: 'pgzlcstu4ljvma7jqyalimcxlvss5bwlbba3c3iszgtwxee4qjdlgeqd', + } + + invitationLinkV2 = decodeURIComponent(composeInvitationShareUrl(data)) + console.log({ invitationLinkV2 }) + const serverData = { + id: 'id', + rootCa: 'rootCa', + ownerCertificate: 'ownerCertificate', + ownerOrbitDbIdentity: v1Data.ownerOrbitDbIdentity, + peerList: [createLibp2pAddress(v1Data.pairs[0].onionAddress, v1Data.pairs[0].peerId)], + psk: v1Data.psk, + } + // Write metadata to 'storage' that is used by docker container + fs.writeFileSync( + path.join(`${__dirname}`, '..', 'QSS', 'storage', `${data.cid}.json`), + JSON.stringify(serverData, null, 2) + ) + }) + it('Guest opens the app', async () => { + await users.user1.app.open() + }) + it('Guest submits invitation code v2 received from owner', async () => { + const joinCommunityModal = new JoinCommunityModal(users.user1.app.driver) + const isJoinCommunityModal = await joinCommunityModal.element.isDisplayed() + expect(isJoinCommunityModal).toBeTruthy() + console.log({ invitationCode: invitationLinkV2 }) + await joinCommunityModal.typeCommunityCode(invitationLinkV2) + await joinCommunityModal.submit() + }) + + it('Guest submits valid username', async () => { + const registerModal = new RegisterUsernameModal(users.user1.app.driver) + const isRegisterModal = await registerModal.element.isDisplayed() + expect(isRegisterModal).toBeTruthy() + await registerModal.clearInput() + await registerModal.typeUsername(users.user1.username) + await registerModal.submit() + }) + it('Guest joins successfully, sees general channel and sends a message', async () => { + const general = new Channel(users.user1.app.driver, 'general') + await general.element.isDisplayed() + const isMessageInput = await general.messageInput.isDisplayed() + expect(isMessageInput).toBeTruthy() + await sleep(2000) + await general.sendMessage(users.user1.messages[0]) + await sleep(2000) + }) + }) +}) diff --git a/packages/e2e-tests/src/tests/multipleClients.test.ts b/packages/e2e-tests/src/tests/multipleClients.test.ts index 59ad62e03a..d8014b4e61 100644 --- a/packages/e2e-tests/src/tests/multipleClients.test.ts +++ b/packages/e2e-tests/src/tests/multipleClients.test.ts @@ -9,14 +9,9 @@ import { Sidebar, } from '../selectors' import logger from '../logger' +import { UserTestData } from '../types' const log = logger('ManyClients') -interface UserTestData { - username: string - app: App - messages: string[] -} - jest.setTimeout(1200000) // 20 minutes describe('Multiple Clients', () => { let generalChannelOwner: Channel diff --git a/packages/e2e-tests/src/tests/userProfile.test.ts b/packages/e2e-tests/src/tests/userProfile.test.ts index 6584dc5175..f37014c651 100644 --- a/packages/e2e-tests/src/tests/userProfile.test.ts +++ b/packages/e2e-tests/src/tests/userProfile.test.ts @@ -13,15 +13,10 @@ import logger from '../logger' import { EXPECTED_IMG_SRC_GIF, EXPECTED_IMG_SRC_JPEG, EXPECTED_IMG_SRC_PNG } from '../profilePhoto.const' import { sleep } from '../utils' import { BACK_ARROW_DATA_TESTID } from '../enums' +import { UserTestData } from '../types' const log = logger('userProfile') -interface UserTestData { - username: string - app: App - messages: string[] -} - jest.setTimeout(900000) describe('User Profile Feature', () => { diff --git a/packages/e2e-tests/src/types.ts b/packages/e2e-tests/src/types.ts new file mode 100644 index 0000000000..65f27be1be --- /dev/null +++ b/packages/e2e-tests/src/types.ts @@ -0,0 +1,7 @@ +import { App } from './selectors' + +export interface UserTestData { + username: string + app: App + messages: string[] +} diff --git a/packages/state-manager/src/sagas/errors/errors.selectors.ts b/packages/state-manager/src/sagas/errors/errors.selectors.ts index 21fb217de9..02d112124f 100644 --- a/packages/state-manager/src/sagas/errors/errors.selectors.ts +++ b/packages/state-manager/src/sagas/errors/errors.selectors.ts @@ -20,6 +20,13 @@ export const generalErrors = createSelector(selectAll, errors => { return errors.filter(error => !error.community) }) +const generalErrorByType = (errorType: string) => { + return createSelector(generalErrors, errors => { + if (!errors || !errors.length) return null + return errors.find(error => error.type === errorType) + }) +} + export const currentCommunityErrors = createSelector(currentCommunityId, selectAll, (community, errors) => { if (!community || !errors) { return {} @@ -33,5 +40,6 @@ export const currentCommunityErrors = createSelector(currentCommunityId, selectA export const errorsSelectors = { generalErrors, + generalErrorByType, currentCommunityErrors, } diff --git a/packages/types/src/errors.ts b/packages/types/src/errors.ts index ede9de81f9..e782aacfe9 100644 --- a/packages/types/src/errors.ts +++ b/packages/types/src/errors.ts @@ -44,4 +44,7 @@ export enum ErrorMessages { // General GENERAL = 'Something went wrong', + + // Storage Server + STORAGE_SERVER_CONNECTION_FAILED = 'Connecting to storage server failed', } diff --git a/packages/types/src/socket.ts b/packages/types/src/socket.ts index e9edb6c5b1..c8b3ba3e7d 100644 --- a/packages/types/src/socket.ts +++ b/packages/types/src/socket.ts @@ -73,6 +73,12 @@ export enum SocketActionTypes { PEER_DISCONNECTED = 'peerDisconnected', TOR_INITIALIZED = 'torInitialized', + // ====== Storage server ====== + + SET_STORAGE_SERVER_ADDRESS = 'setStorageServerAddress', + DOWNLOAD_STORAGE_SERVER_DATA = 'downloadStorageServerData', + UPLOAD_STORAGE_SERVER_DATA = 'uploadStorageServerData', + // ====== Misc ====== /**