Skip to content

Commit

Permalink
feat: Add user profile with photo
Browse files Browse the repository at this point in the history
On the frontend, the user profile sidebar panel simply shows the current user's
name and profile photo. Clicking on the profile photo brings up a context menu
that shows the profile and profile actions (only 'edit profile' for now).
Clicking the 'edit profile' action changes the context menu view such that you
can edit the profile photo. This commit also adds user profile photos to channel
messages. A jdenticon is showed in place of user profile photo if it doesn't
exist.

On the backend, there is a new shared orbitdb user profiles key/value store. The
key/value store maps user public keys with profiles. It uses a custom index so
that we can filter invalid entries thoroughly. In state-manager, a new
saveUserProfile saga is added which signs the profile data and sends it to the
backend.
  • Loading branch information
leblowl committed Oct 11, 2023
1 parent 184d86d commit e100b0b
Show file tree
Hide file tree
Showing 52 changed files with 81,788 additions and 91,989 deletions.
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

0 comments on commit e100b0b

Please sign in to comment.