Skip to content

Commit

Permalink
feat: initial avatars on the fly (#9675)
Browse files Browse the repository at this point in the history
Signed-off-by: Matt Krick <matt.krick@gmail.com>
  • Loading branch information
mattkrick committed Apr 29, 2024
1 parent b433f7f commit e783662
Show file tree
Hide file tree
Showing 17 changed files with 121 additions and 43 deletions.
14 changes: 2 additions & 12 deletions packages/server/database/types/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ interface Input {
lastSeenAt?: Date
lastSeenAtURLs?: string[]
updatedAt?: Date
picture?: string
picture: string
inactive?: boolean
identities?: AuthIdentity[]
isWatched?: boolean
Expand All @@ -24,9 +24,6 @@ interface Input {
tms?: string[]
}

const letters = 'abcdefghijklmnopqrstuvwxyz'
const AVATAR_BUCKET = `https://${process.env.AWS_S3_BUCKET}/static/avatars`

export default class User {
id: string
preferredName: string
Expand Down Expand Up @@ -69,19 +66,12 @@ export default class User {
sendSummaryEmail,
tier
} = input
const avatarName =
preferredName
.toLowerCase()
.split('')
.filter((letter) => letters.includes(letter))
.slice(0, 2)
.join('') || 'pa'
const now = new Date()
this.id = id ?? `local|${generateUID()}`
this.tms = tms || []
this.email = email
this.createdAt = createdAt || now
this.picture = picture || `${AVATAR_BUCKET}/${avatarName}.png`
this.picture = picture
this.updatedAt = updatedAt || now
this.featureFlags = featureFlags || []
this.identities = identities || []
Expand Down
12 changes: 6 additions & 6 deletions packages/server/fileStorage/FileStoreManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,25 +7,25 @@ export default abstract class FileStoreManager {
abstract prependPath(partialPath: string, assetDir?: FileAssetDir): string
abstract getPublicFileLocation(fullPath: string): string

protected abstract putFile(file: Buffer, fullPath: string): Promise<string>
protected abstract putUserFile(file: Buffer, partialPath: string): Promise<string>
abstract putBuildFile(file: Buffer, partialPath: string): Promise<string>
async putUserAvatar(file: Buffer, userId: string, ext: string, name?: string) {
protected abstract putFile(file: ArrayBufferLike, fullPath: string): Promise<string>
protected abstract putUserFile(file: ArrayBufferLike, partialPath: string): Promise<string>
abstract putBuildFile(file: ArrayBufferLike, partialPath: string): Promise<string>
async putUserAvatar(file: ArrayBufferLike, userId: string, ext: string, name?: string) {
const filename = name ?? generateUID()
// replace the first dot, if there is one, but not any other dots
const dotfreeExt = ext.replace(/^\./, '')
const partialPath = `User/${userId}/picture/${filename}.${dotfreeExt}`
return this.putUserFile(file, partialPath)
}

async putOrgAvatar(file: Buffer, orgId: string, ext: string, name?: string) {
async putOrgAvatar(file: ArrayBufferLike, orgId: string, ext: string, name?: string) {
const filename = name ?? generateUID()
const dotfreeExt = ext.replace(/^\./, '')
const partialPath = `Organization/${orgId}/picture/${filename}.${dotfreeExt}`
return this.putUserFile(file, partialPath)
}

async putTemplateIllustration(file: Buffer, orgId: string, ext: string, name?: string) {
async putTemplateIllustration(file: ArrayBufferLike, orgId: string, ext: string, name?: string) {
const filename = name ?? generateUID()
const dotfreeExt = ext.replace(/^\./, '')
const partialPath = `Organization/${orgId}/template/${filename}.${dotfreeExt}`
Expand Down
6 changes: 3 additions & 3 deletions packages/server/fileStorage/GCSManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,12 +112,12 @@ export default class GCSManager extends FileStoreManager {
return this.accessToken
}

protected async putUserFile(file: Buffer, partialPath: string) {
protected async putUserFile(file: ArrayBufferLike, partialPath: string) {
const fullPath = this.prependPath(partialPath)
return this.putFile(file, fullPath)
}

protected async putFile(file: Buffer, fullPath: string) {
protected async putFile(file: ArrayBufferLike, fullPath: string) {
const url = new URL(`https://storage.googleapis.com/upload/storage/v1/b/${this.bucket}/o`)
url.searchParams.append('uploadType', 'media')
url.searchParams.append('name', fullPath)
Expand All @@ -143,7 +143,7 @@ export default class GCSManager extends FileStoreManager {
return this.getPublicFileLocation(fullPath)
}

putBuildFile(file: Buffer, partialPath: string): Promise<string> {
putBuildFile(file: ArrayBufferLike, partialPath: string): Promise<string> {
const fullPath = this.prependPath(partialPath, 'build')
return this.putFile(file, fullPath)
}
Expand Down
6 changes: 3 additions & 3 deletions packages/server/fileStorage/LocalFileStoreManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,14 @@ export default class LocalFileSystemManager extends FileStoreManager {
}
}

protected async putUserFile(file: Buffer, partialPath: string) {
protected async putUserFile(file: ArrayBufferLike, partialPath: string) {
const fullPath = this.prependPath(partialPath)
return this.putFile(file, fullPath)
}
protected async putFile(file: Buffer, fullPath: string) {
protected async putFile(file: ArrayBufferLike, fullPath: string) {
const fsAbsLocation = path.join(process.cwd(), fullPath)
await fs.promises.mkdir(path.dirname(fsAbsLocation), {recursive: true})
await fs.promises.writeFile(fsAbsLocation, file)
await fs.promises.writeFile(fsAbsLocation, Buffer.from(file))
return this.getPublicFileLocation(fullPath)
}

Expand Down
8 changes: 4 additions & 4 deletions packages/server/fileStorage/S3FileStoreManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,13 @@ export default class S3Manager extends FileStoreManager {
})
}

protected async putUserFile(file: Buffer, partialPath: string) {
protected async putUserFile(file: ArrayBufferLike, partialPath: string) {
const fullPath = this.prependPath(partialPath)
return this.putFile(file, fullPath)
}
protected async putFile(file: Buffer, fullPath: string) {
protected async putFile(file: ArrayBufferLike, fullPath: string) {
const s3Params = {
Body: file,
Body: Buffer.from(file),
Bucket: this.bucket,
Key: fullPath,
ContentType: mime.lookup(fullPath) || 'application/octet-stream'
Expand All @@ -63,7 +63,7 @@ export default class S3Manager extends FileStoreManager {
return encodeURI(`${this.baseUrl}${fullPath}`)
}

putBuildFile(file: Buffer, partialPath: string): Promise<string> {
putBuildFile(file: ArrayBufferLike, partialPath: string): Promise<string> {
const fullPath = this.prependPath(partialPath, 'build')
return this.putFile(file, fullPath)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import {createAvatar} from '@dicebear/core'
import * as initials from '@dicebear/initials'
import sharp from 'sharp'
import tailwindPreset from '../../../../../client/tailwindTheme'
import getFileStoreManager from '../../../../fileStorage/getFileStoreManager'

export const generateIdenticon = async (userId: string, name: string) => {
const letters = 'abcdefghijklmnopqrstuvwxyz'
const {colors} = tailwindPreset.theme
const backgroundColor = Object.values(colors)
.map((color) => {
return color['500']?.slice(1) ?? undefined
})
.filter(Boolean)

const seed =
name
.toLowerCase()
.split('')
.filter((letter) => letters.includes(letter))
.slice(0, 2)
.join('') || 'pa'
const avatar = createAvatar(initials, {
seed,
backgroundColor
})
const svgBuffer = await avatar.toArrayBuffer()
const pngBuffer = await sharp(svgBuffer).png().toBuffer()
const manager = getFileStoreManager()
const publicLocation = await manager.putUserAvatar(pngBuffer, userId, 'png')
return publicLocation
}
3 changes: 3 additions & 0 deletions packages/server/graphql/private/mutations/loginSAML.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import bootstrapNewUser from '../../mutations/helpers/bootstrapNewUser'
import getSignOnURL from '../../public/mutations/helpers/SAMLHelpers/getSignOnURL'
import {SSORelayState} from '../../public/queries/SAMLIdP'
import {MutationResolvers} from '../resolverTypes'
import {generateIdenticon} from './helpers/generateIdenticon'

const serviceProvider = samlify.ServiceProvider({})
samlify.setSchemaValidator(samlXMLValidator)
Expand Down Expand Up @@ -135,10 +136,12 @@ const loginSAML: MutationResolvers['loginSAML'] = async (
}

const userId = `sso|${generateUID()}`
const picture = await generateIdenticon(userId, preferredName)
const tempUser = new User({
id: userId,
email,
preferredName,
picture,
tier: 'enterprise'
})

Expand Down
3 changes: 2 additions & 1 deletion packages/server/graphql/public/mutations/loginWithGoogle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import getSAMLURLFromEmail from '../../../utils/getSAMLURLFromEmail'
import GoogleServerManager from '../../../utils/GoogleServerManager'
import standardError from '../../../utils/standardError'
import bootstrapNewUser from '../../mutations/helpers/bootstrapNewUser'
import {generateIdenticon} from '../../private/mutations/helpers/generateIdenticon'
import {MutationResolvers} from '../resolverTypes'

const loginWithGoogle: MutationResolvers['loginWithGoogle'] = async (
Expand Down Expand Up @@ -93,7 +94,7 @@ const loginWithGoogle: MutationResolvers['loginWithGoogle'] = async (
const newUser = new User({
id: userId,
preferredName,
picture,
picture: picture || (await generateIdenticon(userId, preferredName)),
email,
identities: [identity],
pseudoId
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import getSAMLURLFromEmail from '../../../utils/getSAMLURLFromEmail'
import MicrosoftServerManager from '../../../utils/MicrosoftServerManager'
import standardError from '../../../utils/standardError'
import bootstrapNewUser from '../../mutations/helpers/bootstrapNewUser'
import {generateIdenticon} from '../../private/mutations/helpers/generateIdenticon'
import {MutationResolvers} from '../resolverTypes'

const loginWithMicrosoft: MutationResolvers['loginWithMicrosoft'] = async (
Expand Down Expand Up @@ -96,6 +97,7 @@ const loginWithMicrosoft: MutationResolvers['loginWithMicrosoft'] = async (
const newUser = new User({
id: userId,
preferredName,
picture: await generateIdenticon(userId, preferredName),
email,
identities: [identity],
pseudoId
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,12 @@ const signUpWithPassword: MutationResolvers['signUpWithPassword'] = async (
return createEmailVerification({invitationToken, password, pseudoId, email, redirectTo})
}
const hashedPassword = await bcrypt.hash(password, Security.SALT_ROUNDS)
const newUser = createNewLocalUser({email, hashedPassword, isEmailVerified: false, pseudoId})
const newUser = await createNewLocalUser({
email,
hashedPassword,
isEmailVerified: false,
pseudoId
})
// MUTATIVE
context.authToken = await bootstrapNewUser(newUser, isOrganic, dataLoader)
return {
Expand Down
2 changes: 1 addition & 1 deletion packages/server/graphql/public/mutations/verifyEmail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ const verifyEmail: MutationResolvers['verifyEmail'] = async (
return {error: {message: 'Invalid hash for email. Please reverify'}}
}
// user does not exist, create them bootstrap
const newUser = createNewLocalUser({email, hashedPassword, isEmailVerified: true, pseudoId})
const newUser = await createNewLocalUser({email, hashedPassword, isEmailVerified: true, pseudoId})
// it's possible that the invitationToken is no good.
// if that happens, then they'll get into the app & won't be on any team
// edge case because that requires the invitation token to have expired
Expand Down
2 changes: 2 additions & 0 deletions packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@
"dependencies": {
"@amplitude/analytics-node": "^1.3.2",
"@aws-sdk/client-s3": "^3.347.1",
"@dicebear/core": "^8.0.1",
"@dicebear/initials": "^8.0.1",
"@graphql-tools/schema": "^9.0.16",
"@mattkrick/sanitize-svg": "0.4.0",
"@octokit/graphql-schema": "^10.36.0",
Expand Down
8 changes: 6 additions & 2 deletions packages/server/utils/createNewLocalUser.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {AuthIdentityTypeEnum} from '../../client/types/constEnums'
import AuthIdentityLocal from '../database/types/AuthIdentityLocal'
import User from '../database/types/User'
import generateUID from '../generateUID'
import {generateIdenticon} from '../graphql/private/mutations/helpers/generateIdenticon'

interface Props {
email: string
Expand All @@ -9,17 +11,19 @@ interface Props {
isEmailVerified: boolean
}

const createNewLocalUser = (props: Props) => {
const createNewLocalUser = async (props: Props) => {
const {email, hashedPassword, pseudoId, isEmailVerified} = props
const nickname = email.split('@')[0]!
const preferredName = nickname.length === 1 ? nickname.repeat(2) : nickname
const userId = `local|${generateUID()}`
const newUser = new User({
id: userId,
preferredName,
email,
picture: await generateIdenticon(userId, preferredName),
identities: [],
pseudoId
})
const {id: userId} = newUser
const identityId = `${userId}:${AuthIdentityTypeEnum.LOCAL}`
newUser.identities.push(new AuthIdentityLocal({hashedPassword, id: identityId, isEmailVerified}))
return newUser
Expand Down
9 changes: 5 additions & 4 deletions scripts/webpack/dev.servers.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,7 @@ module.exports = {
},
output: {
filename: '[name].js',
path: path.join(PROJECT_ROOT, 'dev'),
libraryTarget: 'commonjs'
path: path.join(PROJECT_ROOT, 'dev')
},
resolve: {
alias: {
Expand All @@ -54,13 +53,15 @@ module.exports = {
target: 'node',
externals: [
nodeExternals({
allowlist: [/parabol-client/, /parabol-server/]
allowlist: [/parabol-client/, /parabol-server/, /@dicebear/]
})
],
plugins: [
new webpack.DefinePlugin({
__PRODUCTION__: false
})
}),
new webpack.IgnorePlugin({resourceRegExp: /^exiftool-vendored$/, contextRegExp: /@dicebear/}),
new webpack.IgnorePlugin({resourceRegExp: /^@resvg\/resvg-js$/, contextRegExp: /@dicebear/})
],
module: {
rules: [
Expand Down
5 changes: 4 additions & 1 deletion scripts/webpack/prod.servers.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ module.exports = (config) => {
externals: [
!noDeps &&
nodeExternals({
allowlist: [/parabol-client/, /parabol-server/]
allowlist: [/parabol-client/, /parabol-server/, /@dicebear/]
})
].filter(Boolean),
optimization: {
Expand All @@ -92,6 +92,9 @@ module.exports = (config) => {
new webpack.IgnorePlugin({resourceRegExp: /^canvas$/, contextRegExp: /jsdom$/}),
// native bindings might be faster, but abandonware & not currently used
new webpack.IgnorePlugin({resourceRegExp: /^pg-native$/, contextRegExp: /pg\/lib/}),
new webpack.IgnorePlugin({resourceRegExp: /^exiftool-vendored$/, contextRegExp: /@dicebear/}),
new webpack.IgnorePlugin({resourceRegExp: /^@resvg\/resvg-js$/, contextRegExp: /@dicebear/}),

noDeps &&
new CopyWebpackPlugin({
patterns: [
Expand Down
6 changes: 4 additions & 2 deletions scripts/webpack/toolbox.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,15 @@ module.exports = {
target: 'node',
externals: [
nodeExternals({
allowlist: [/parabol-client/, '/parabol-server/']
allowlist: [/parabol-client/, /parabol-server/, /@dicebear/]
})
],
plugins: [
new webpack.DefinePlugin({
__PRODUCTION__: true
})
}),
new webpack.IgnorePlugin({resourceRegExp: /^exiftool-vendored$/, contextRegExp: /@dicebear/}),
new webpack.IgnorePlugin({resourceRegExp: /^@resvg\/resvg-js$/, contextRegExp: /@dicebear/})
// new CircularDependencyPlugin({
// // `onStart` is called before the cycle detection starts
// onStart({compilation}) {
Expand Down
Loading

0 comments on commit e783662

Please sign in to comment.