Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add user profile feature #1923

Merged
merged 38 commits into from
Jan 25, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
ecb8227
feat: Add user profile with photo (#615)
leblowl Sep 21, 2023
dbc3409
Use package-log.json from develop for now
leblowl Oct 11, 2023
11b9c1e
Fixup due to rebase mistake
leblowl Oct 11, 2023
d4a8082
Fixup for @ipld/dag-cbor
leblowl Oct 11, 2023
ba08bdf
Fixup/reverting Jest changes
leblowl Oct 11, 2023
0b418e7
Refactor adding photo to message in selector
leblowl Oct 31, 2023
98e736e
Move fileToBase64String to common package
leblowl Oct 31, 2023
fa6089e
Add dash to UserProfilePanel prefix
leblowl Oct 31, 2023
46a0690
Add test for saveUserProfile.saga
leblowl Nov 1, 2023
35330c5
Merge remote-tracking branch 'origin/develop' into pr-custom-profile-…
leblowl Nov 2, 2023
1eb1ec9
Add e2e test and fix TS and lint errors
leblowl Nov 3, 2023
c7e03c9
Add test for user profile selectors
leblowl Nov 6, 2023
128405d
Convert UserProfileStore into NestJS provider
leblowl Nov 6, 2023
23943cf
Add additional check for photo data URL prefix and fix test
leblowl Nov 8, 2023
115e35c
Improve validation
leblowl Nov 10, 2023
1594bfd
Add additional tests around user profile URL validation
leblowl Nov 13, 2023
7cc945b
Merge branch 'develop' into pr-custom-profile-image
leblowl Jan 23, 2024
2d3099f
Merge remote-tracking branch 'origin/develop' into pr-custom-profile-…
leblowl Jan 23, 2024
3660f28
Fix lint/backend build
leblowl Jan 23, 2024
0a96c8b
Fix build
leblowl Jan 23, 2024
a276ced
Remove sign/verify methods for binary data
leblowl Jan 23, 2024
301c02a
Update package-lock.json
leblowl Jan 23, 2024
6c812c2
Pull desktop package lock from develop
leblowl Jan 23, 2024
d669c02
Pull state mgr package lock from develop
leblowl Jan 23, 2024
8633707
Fix more things
leblowl Jan 23, 2024
298c0e6
Fixup from merge
leblowl Jan 23, 2024
40d5c6c
Clean things up a bit
leblowl Jan 23, 2024
63d08e8
Update changelog
leblowl Jan 23, 2024
828dc06
Fixup
leblowl Jan 23, 2024
742b70c
Fix mobile
leblowl Jan 24, 2024
5520748
Add profile photo to mobile messages
leblowl Jan 25, 2024
4980cb0
Fix mobile react native image tag
leblowl Jan 25, 2024
10a4aa3
Fix dimensions for mobile message photo
leblowl Jan 25, 2024
e0769da
Fix jdenticon on mobile
leblowl Jan 25, 2024
320f376
Adjust style mobile
leblowl Jan 25, 2024
32444a9
Adjust style mobile
leblowl Jan 25, 2024
db2e26e
Adjust style mobile
leblowl Jan 25, 2024
9981ec7
Fix react native Image usage
leblowl Jan 25, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74,519 changes: 37,424 additions & 37,095 deletions packages/backend/package-lock.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ import {
SaveCSRPayload,
CommunityMetadata,
CommunityMetadataPayload,
UserProfile,
UserProfilesLoadedEvent,
} from '@quiet/types'
import { CONFIG_OPTIONS, QUIET_DIR, SERVER_IO_PROVIDER, SOCKS_PROXY_AGENT } from '../const'
import { ConfigOptions, GetPorts, ServerIoProviderTypes } from '../types'
Expand Down Expand Up @@ -318,6 +320,7 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI

this.serverIoProvider.io.emit(SocketActionTypes.COMMUNITY, { id: payload.id })
}

public async launch(payload: InitCommunityPayload) {
// Start existing community (community that user is already a part of)
this.logger(`Spawning hidden service for community ${payload.id}, peer: ${payload.peerId.id}`)
Expand Down Expand Up @@ -381,13 +384,15 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI
await this.storageService.init(_peerId)
console.log('storage initialized')
}

private attachTorEventsListeners() {
this.logger('attachTorEventsListeners')

this.socketService.on(SocketActionTypes.CONNECTION_PROCESS_INFO, data => {
this.serverIoProvider.io.emit(SocketActionTypes.CONNECTION_PROCESS_INFO, data)
})
}

private attachRegistrationListeners() {
this.registrationService.on(SocketActionTypes.SAVED_OWNER_CERTIFICATE, payload => {
this.serverIoProvider.io.emit(SocketActionTypes.SAVED_OWNER_CERTIFICATE, payload)
Expand All @@ -399,6 +404,7 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI
await this.storageService?.saveCertificate(payload)
})
}

private attachsocketServiceListeners() {
// Community
this.socketService.on(SocketActionTypes.LEAVE_COMMUNITY, async () => {
Expand Down Expand Up @@ -497,7 +503,14 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI
// await this.deleteFilesFromTemporaryDir() //crashes on mobile, will be fixes in next versions
}
)

// User Profile

this.socketService.on(SocketActionTypes.SAVE_USER_PROFILE, async (profile: UserProfile) => {
await this.storageService?.userProfileStore?.addUserProfile(profile)
})
}

private attachStorageListeners() {
if (!this.storageService) return
this.storageService.on(SocketActionTypes.CONNECTION_PROCESS_INFO, data => {
Expand Down Expand Up @@ -575,5 +588,11 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI
}
this.serverIoProvider.io.emit(SocketActionTypes.SAVE_COMMUNITY_METADATA, communityMetadataPayload)
})

// User Profile

this.storageService.on(StorageEvents.LOADED_USER_PROFILES, (payload: UserProfilesLoadedEvent) => {
this.serverIoProvider.io.emit(SocketActionTypes.LOADED_USER_PROFILES, payload)
})
}
}
1 change: 1 addition & 0 deletions packages/backend/src/nest/socket/socket.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
DeleteFilesFromChannelSocketPayload,
SaveCSRPayload,
CommunityMetadata,
UserProfile,
} from '@quiet/types'
import EventEmitter from 'events'
import { CONFIG_OPTIONS, SERVER_IO_PROVIDER } from '../const'
Expand Down
263 changes: 263 additions & 0 deletions packages/backend/src/nest/storage/UserProfileStore.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
import { jest, beforeEach, describe, it, expect, afterEach, beforeAll, test } from '@jest/globals'
import * as Block from 'multiformats/block'
import { sha256 } from 'multiformats/hashes/sha2'
import * as dagCbor from '@ipld/dag-cbor'
import { arrayBufferToString } from 'pvutils'
import { getCrypto, PublicKeyInfo } from 'pkijs'

import { ChannelMessage, NoCryptoEngineError, PublicChannel, UserProfile } from '@quiet/types'
import { configCrypto, generateKeyPair, signData } from '@quiet/identity'

import { isPng, base64DataURLToByteArray, UserProfileStore, UserProfileKeyValueIndex } from './UserProfileStore'

describe('UserProfileStore/isPng', () => {
test('returns true for a valid PNG', () => {
// Bytes in decimal copied out of a PNG file
// e.g. od -t u1 ~/Pictures/test.png | less
const png = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82])
expect(isPng(png)).toBeTruthy()
})

test('returns false for a invalid PNG', () => {
// Changed the first byte from 137 to 136
const png = new Uint8Array([136, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82])
expect(isPng(png)).toBeFalsy()
})

test('returns false for a incomplete PNG', () => {
// Removed last byte from the PNG header
const png = new Uint8Array([137, 80, 78, 71, 13, 10, 26])
expect(isPng(png)).toBeFalsy()
})
})

describe('UserProfileStore/base64DataURLToByteArray', () => {
test("throws error if data URL prefix doesn't exist", () => {
const contents = '1234567'
expect(() => base64DataURLToByteArray(contents)).toThrow(Error)
})

test('throws error if data URL prefix is malformatted', () => {
let contents = ',1234567'
expect(() => base64DataURLToByteArray(contents)).toThrow(Error)

contents = ',1234567'
expect(() => base64DataURLToByteArray(contents)).toThrow(Error)

contents = 'data:,1234567'
expect(() => base64DataURLToByteArray(contents)).toThrow(Error)

contents = ';base64,1234567'
expect(() => base64DataURLToByteArray(contents)).toThrow(Error)

contents = 'dat:;base64,1234567'
expect(() => base64DataURLToByteArray(contents)).toThrow(Error)
})

test('returns Uint8Array if data URL prefix is correct', () => {
// base64 encoding of binary 'm'
// btoa('m') == 'bQ=='
const contents = 'data:mime;base64,bQ=='
expect(base64DataURLToByteArray(contents)).toEqual(new Uint8Array(['m'.charCodeAt(0)]))
})
})

const getUserProfile = async ({
pngByteArray,
signature,
}: {
pngByteArray?: Uint8Array
signature?: string
}): Promise<UserProfile> => {
const crypto = getCrypto()
if (!crypto) throw new NoCryptoEngineError()

const keyPair = await generateKeyPair({ signAlg: configCrypto.signAlg })

// Bytes in decimal copied out of a PNG file
// e.g. od -t u1 ~/Pictures/test.png | less
const png = pngByteArray || new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82])
const pngBase64 = 'data:image/png;base64,' + Buffer.from(png).toString('base64')
const profile = { photo: pngBase64 }

const codec = dagCbor
const hasher = sha256
const { bytes } = await Block.encode({ value: profile, codec: codec, hasher: hasher })
const signatureArrayBuffer = await signData(bytes, keyPair.privateKey)
signature = signature || arrayBufferToString(signatureArrayBuffer)

const pubKeyInfo = new PublicKeyInfo()
await pubKeyInfo.importKey(keyPair.publicKey)
const pubKeyDer = Buffer.from(pubKeyInfo.subjectPublicKey.valueBlock.valueHex).toString('base64')

return {
profile: profile,
profileSig: signature,
pubKey: pubKeyDer,
}
}

describe('UserProfileStore/validateUserProfile', () => {
test('returns false if signature is invalid', async () => {
// Valid PNG
const pngByteArray = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82])
const signature = '1234'
const userProfile = await getUserProfile({ pngByteArray, signature })
expect(await UserProfileStore.validateUserProfile(userProfile)).toBeFalsy()
})

test('returns false if photo is not PNG', async () => {
// Changed the first byte from 137 to 136
const pngByteArray = new Uint8Array([136, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82])
const userProfile = await getUserProfile({ pngByteArray })
expect(await UserProfileStore.validateUserProfile(userProfile)).toBeFalsy()
})

test('returns false if photo is larger than 200KB', async () => {
// 204,800 extra decimal bytes (200KB) with values 1 - 254
const extraData = Array.from({ length: 204_800 }, () => Math.floor(Math.random() * (255 - 1) + 1))
// Valid PNG header
const pngArray = [137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82].concat(extraData)
const pngByteArray = new Uint8Array(pngArray)

const userProfile = await getUserProfile({ pngByteArray })
expect(await UserProfileStore.validateUserProfile(userProfile)).toBeFalsy()
})

test('returns true if user profile is valid', async () => {
// 200,000 extra decimal bytes with values 1 - 254
const extraData = Array.from({ length: 200_000 }, () => Math.floor(Math.random() * (255 - 1) + 1))
// Valid PNG header
const pngArray = [137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82].concat(extraData)
const pngByteArray = new Uint8Array(pngArray)

const userProfile = await getUserProfile({ pngByteArray })
expect(await UserProfileStore.validateUserProfile(userProfile)).toBeTruthy()
})
})

describe('UserProfileStore/validateUserProfileEntry', () => {
test("returns false entry key doesn't match profile pubKey", async () => {
// Valid PNG
const pngByteArray = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82])
const userProfile = await getUserProfile({ pngByteArray })
const userProfileEntry = {
payload: { key: 'incorrect key', value: userProfile },
// These fields are not checked currently
hash: '',
id: '',
next: [''],
v: 1,
clock: {
// Not sure why this type is defined like this:
// https://github.com/orbitdb/orbit-db-types/blob/ed41369e64c054952c1e47505d598342a4967d4c/LogEntry.d.ts#L8C9-L8C17
id: '' as 'string',
time: 1,
},
key: '',
identity: {
id: '',
publicKey: '',
signatures: { id: '', publicKey: '' },
type: '',
},
sig: '',
}
expect(await UserProfileStore.validateUserProfileEntry(userProfileEntry)).toBeFalsy()
})

test('returns true if user profile entry is valid', async () => {
// Valid PNG
const pngByteArray = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82])
const userProfile = await getUserProfile({ pngByteArray })
const userProfileEntry = {
payload: { key: userProfile.pubKey, value: userProfile },
// These fields are not checked currently
hash: '',
id: '',
next: [''],
v: 1,
clock: {
// Not sure why this type is defined like this:
// https://github.com/orbitdb/orbit-db-types/blob/ed41369e64c054952c1e47505d598342a4967d4c/LogEntry.d.ts#L8C9-L8C17
id: '' as 'string',
time: 1,
},
key: '',
identity: {
id: '',
publicKey: '',
signatures: { id: '', publicKey: '' },
type: '',
},
sig: '',
}
expect(await UserProfileStore.validateUserProfileEntry(userProfileEntry)).toBeTruthy()
})
})

describe('UserProfileStore/UserProfileKeyValueIndex', () => {
test('updateIndex skips entry if it is invalid', async () => {
// Valid PNG
const pngByteArray = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82])
const userProfile = await getUserProfile({ pngByteArray })
const userProfileEntry = {
payload: { op: 'PUT', key: 'incorrect key', value: userProfile },
// These fields are not checked currently
hash: '',
id: '',
next: [''],
v: 1,
clock: {
// Not sure why this type is defined like this:
// https://github.com/orbitdb/orbit-db-types/blob/ed41369e64c054952c1e47505d598342a4967d4c/LogEntry.d.ts#L8C9-L8C17
id: '' as 'string',
time: 1,
},
key: '',
identity: {
id: '',
publicKey: '',
signatures: { id: '', publicKey: '' },
type: '',
},
sig: '',
}

const index = new UserProfileKeyValueIndex()
await index.updateIndex({ values: [userProfileEntry] })
expect(index.get('incorrect key')).toEqual(undefined)
})

test('updateIndex adds entry if it is valid', async () => {
// Valid PNG
const pngByteArray = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82])
const userProfile = await getUserProfile({ pngByteArray })
const userProfileEntry = {
payload: { op: 'PUT', key: userProfile.pubKey, value: userProfile },
// These fields are not checked currently
hash: '',
id: '',
next: [''],
v: 1,
clock: {
// Not sure why this type is defined like this:
// https://github.com/orbitdb/orbit-db-types/blob/ed41369e64c054952c1e47505d598342a4967d4c/LogEntry.d.ts#L8C9-L8C17
id: '' as 'string',
time: 1,
},
key: '',
identity: {
id: '',
publicKey: '',
signatures: { id: '', publicKey: '' },
type: '',
},
sig: '',
}

const index = new UserProfileKeyValueIndex()
await index.updateIndex({ values: [userProfileEntry] })
expect(index.get(userProfile.pubKey)).toEqual(userProfile)
})
})
Loading