From 58530be78bd3d1bd44be071ed046af297774422c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20M?= Date: Thu, 27 Jul 2023 18:06:50 +0200 Subject: [PATCH] feat: upload profile picture from google (#964) * feat: upload profile picture from google * fix: only add profile picture if user don't have any --- server/package.json | 1 + server/src/core/auth/auth.module.ts | 3 +- .../controllers/google-auth.controller.ts | 43 +++++++++++++- .../auth/strategies/google.auth.strategy.ts | 6 +- server/src/utils/image.ts | 10 ++++ server/yarn.lock | 56 ++++++++++++++++++- 6 files changed, 113 insertions(+), 6 deletions(-) diff --git a/server/package.json b/server/package.json index df1dbbaf43..8a592862a1 100644 --- a/server/package.json +++ b/server/package.json @@ -54,6 +54,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "date-fns": "^2.30.0", + "file-type": "13.0.0", "graphql": "^16.6.0", "graphql-type-json": "^0.3.2", "graphql-upload": "^13.0.0", diff --git a/server/src/core/auth/auth.module.ts b/server/src/core/auth/auth.module.ts index ef67e03c6e..caebb345b7 100644 --- a/server/src/core/auth/auth.module.ts +++ b/server/src/core/auth/auth.module.ts @@ -5,6 +5,7 @@ import { PrismaService } from 'src/database/prisma.service'; import { UserModule } from 'src/core/user/user.module'; import { EnvironmentService } from 'src/integrations/environment/environment.service'; import { WorkspaceModule } from 'src/core/workspace/workspace.module'; +import { FileModule } from 'src/core/file/file.module'; import { AuthResolver } from './auth.resolver'; @@ -27,7 +28,7 @@ const jwtModule = JwtModule.registerAsync({ }); @Module({ - imports: [jwtModule, UserModule, WorkspaceModule], + imports: [jwtModule, UserModule, WorkspaceModule, FileModule], controllers: [GoogleAuthController, VerifyAuthController], providers: [ AuthService, diff --git a/server/src/core/auth/controllers/google-auth.controller.ts b/server/src/core/auth/controllers/google-auth.controller.ts index afe6245bad..a9ba1c208c 100644 --- a/server/src/core/auth/controllers/google-auth.controller.ts +++ b/server/src/core/auth/controllers/google-auth.controller.ts @@ -1,6 +1,10 @@ import { Controller, Get, Req, Res, UseGuards } from '@nestjs/common'; import { Response } from 'express'; +import FileType from 'file-type'; +import { v4 as uuidV4 } from 'uuid'; + +import { FileFolder } from 'src/core/file/interfaces/file-folder.interface'; import { GoogleRequest } from 'src/core/auth/strategies/google.auth.strategy'; import { UserService } from 'src/core/user/user.service'; @@ -9,6 +13,8 @@ import { GoogleProviderEnabledGuard } from 'src/core/auth/guards/google-provider import { GoogleOauthGuard } from 'src/core/auth/guards/google-oauth.guard'; import { WorkspaceService } from 'src/core/workspace/services/workspace.service'; import { EnvironmentService } from 'src/integrations/environment/environment.service'; +import { getImageBufferFromUrl } from 'src/utils/image'; +import { FileUploadService } from 'src/core/file/services/file-upload.service'; @Controller('auth/google') export class GoogleAuthController { @@ -17,6 +23,7 @@ export class GoogleAuthController { private readonly userService: UserService, private readonly workspaceService: WorkspaceService, private readonly environmentService: EnvironmentService, + private readonly fileUploadService: FileUploadService, ) {} @Get() @@ -29,7 +36,8 @@ export class GoogleAuthController { @Get('redirect') @UseGuards(GoogleProviderEnabledGuard, GoogleOauthGuard) async googleAuthRedirect(@Req() req: GoogleRequest, @Res() res: Response) { - const { firstName, lastName, email, workspaceInviteHash } = req.user; + const { firstName, lastName, email, picture, workspaceInviteHash } = + req.user; let workspaceId: string | undefined = undefined; if (workspaceInviteHash) { @@ -48,7 +56,7 @@ export class GoogleAuthController { workspaceId = workspace.id; } - const user = await this.userService.createUser( + let user = await this.userService.createUser( { data: { email, @@ -65,6 +73,37 @@ export class GoogleAuthController { workspaceId, ); + if (!user.avatarUrl) { + let imagePath: string | undefined = undefined; + + if (picture) { + // Get image buffer from url + const buffer = await getImageBufferFromUrl(picture); + + // Extract mimetype and extension from buffer + const type = await FileType.fromBuffer(buffer); + + // Upload image + const { paths } = await this.fileUploadService.uploadImage({ + file: buffer, + filename: `${uuidV4()}.${type?.ext}`, + mimeType: type?.mime, + fileFolder: FileFolder.ProfilePicture, + }); + + imagePath = paths[0]; + } + + user = await this.userService.update({ + where: { + id: user.id, + }, + data: { + avatarUrl: imagePath, + }, + }); + } + const loginToken = await this.tokenService.generateLoginToken(user.email); return res.redirect(this.tokenService.computeRedirectURI(loginToken.token)); diff --git a/server/src/core/auth/strategies/google.auth.strategy.ts b/server/src/core/auth/strategies/google.auth.strategy.ts index 0c5f8f3ff2..22a109e4e1 100644 --- a/server/src/core/auth/strategies/google.auth.strategy.ts +++ b/server/src/core/auth/strategies/google.auth.strategy.ts @@ -11,6 +11,7 @@ export type GoogleRequest = Request & { firstName?: string | null; lastName?: string | null; email: string; + picture: string | null; workspaceInviteHash?: string; }; }; @@ -45,16 +46,17 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { profile: any, done: VerifyCallback, ): Promise { - const { name, emails } = profile; + const { name, emails, photos } = profile; const state = typeof request.query.state === 'string' ? JSON.parse(request.query.state) : undefined; - const user = { + const user: GoogleRequest['user'] = { email: emails[0].value, firstName: name.givenName, lastName: name.familyName, + picture: photos?.[0]?.value, workspaceInviteHash: state.workspaceInviteHash, }; done(null, user); diff --git a/server/src/utils/image.ts b/server/src/utils/image.ts index e659cfb216..58bd4b9bdc 100644 --- a/server/src/utils/image.ts +++ b/server/src/utils/image.ts @@ -1,3 +1,5 @@ +import axios from 'axios'; + const cropRegex = /([w|h])([0-9]+)/; export type ShortCropSize = `${'w' | 'h'}${number}` | 'original'; @@ -19,3 +21,11 @@ export const getCropSize = (value: ShortCropSize): CropSize | null => { value: +match[2], }; }; + +export const getImageBufferFromUrl = async (url: string): Promise => { + const response = await axios.get(url, { + responseType: 'arraybuffer', + }); + + return Buffer.from(response.data, 'binary'); +}; diff --git a/server/yarn.lock b/server/yarn.lock index 1291460ee0..0653b1d939 100644 --- a/server/yarn.lock +++ b/server/yarn.lock @@ -2559,6 +2559,11 @@ resolved "https://registry.yarnpkg.com/@sqltools/formatter/-/formatter-1.2.5.tgz#3abc203c79b8c3e90fd6c156a0c62d5403520e12" integrity sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw== +"@tokenizer/token@^0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@tokenizer/token/-/token-0.1.1.tgz#f0d92c12f87079ddfd1b29f614758b9696bc29e3" + integrity sha512-XO6INPbZCxdprl+9qa/AAbFFOMzzwqYxpjPgLICrMD6C2FCw6qfJOPcBk6JqqPLSaZ/Qx87qn4rpPmPMwaAK6w== + "@ts-morph/common@~0.17.0": version "0.17.0" resolved "https://registry.npmjs.org/@ts-morph/common/-/common-0.17.0.tgz" @@ -5063,6 +5068,16 @@ file-entry-cache@^6.0.1: dependencies: flat-cache "^3.0.4" +file-type@13.0.0: + version "13.0.0" + resolved "https://registry.yarnpkg.com/file-type/-/file-type-13.0.0.tgz#00091b5b642f131d4cfe9f51192270e363e3d54c" + integrity sha512-fuTZAd04cbjCOcv+Y9erYUw92G2n7DSsMhIWHNunQz5ajIlDs+yamZ7QgtHe+k3XSFnO0NOUiLq9AA8w6ut+9g== + dependencies: + readable-web-to-node-stream "^2.0.0" + strtok3 "^5.0.1" + token-types "^2.0.0" + typedarray-to-buffer "^3.1.5" + filename-reserved-regex@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/filename-reserved-regex/-/filename-reserved-regex-2.0.0.tgz" @@ -5659,7 +5674,7 @@ iconv-lite@0.4.24, iconv-lite@^0.4.24: dependencies: safer-buffer ">= 2.1.2 < 3" -ieee754@^1.1.13: +ieee754@^1.1.13, ieee754@^1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz" integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== @@ -5951,6 +5966,11 @@ is-typed-array@^1.1.10, is-typed-array@^1.1.9: dependencies: which-typed-array "^1.1.11" +is-typedarray@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" + integrity sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA== + is-unicode-supported@^0.1.0: version "0.1.0" resolved "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz" @@ -7341,6 +7361,11 @@ pause@0.0.1: resolved "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz" integrity sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg== +peek-readable@^3.1.0: + version "3.1.4" + resolved "https://registry.yarnpkg.com/peek-readable/-/peek-readable-3.1.4.tgz#f5c3b41a4eeb63a1322c4131f0b5bac7105b892e" + integrity sha512-DX7ec7frSMtCWw+zMd27f66hcxIz/w9LQTY2RflB4WNHCVPAye1pJiP2t3gvaaOhu7IOhtPbHw8MemMj+F5lrg== + picocolors@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz" @@ -7603,6 +7628,11 @@ readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.6.0: string_decoder "^1.1.1" util-deprecate "^1.0.1" +readable-web-to-node-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/readable-web-to-node-stream/-/readable-web-to-node-stream-2.0.0.tgz#751e632f466552ac0d5c440cc01470352f93c4b7" + integrity sha512-+oZJurc4hXpaaqsN68GoZGQAQIA3qr09Or4fqEsargABnbe5Aau8hFn6ISVleT3cpY/0n/8drn7huyyEvTbghA== + readdir-glob@^1.0.0: version "1.1.3" resolved "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz" @@ -8148,6 +8178,15 @@ strnum@^1.0.5: resolved "https://registry.yarnpkg.com/strnum/-/strnum-1.0.5.tgz#5c4e829fe15ad4ff0d20c3db5ac97b73c9b072db" integrity sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA== +strtok3@^5.0.1: + version "5.0.2" + resolved "https://registry.yarnpkg.com/strtok3/-/strtok3-5.0.2.tgz#bb81f1f56742e16f1a30ccce5dc3d9498aa5475a" + integrity sha512-EFeVpFC5qDsqPEJSrIYyS/ueFBknGhgSK9cW+YAJF/cgJG/KSjoK7X6rK5xnpcLe7y1LVkVFCXWbAb+ClNKzKQ== + dependencies: + "@tokenizer/token" "^0.1.1" + debug "^4.1.1" + peek-readable "^3.1.0" + subscriptions-transport-ws@0.11.0: version "0.11.0" resolved "https://registry.npmjs.org/subscriptions-transport-ws/-/subscriptions-transport-ws-0.11.0.tgz" @@ -8381,6 +8420,14 @@ toidentifier@1.0.1: resolved "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz" integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== +token-types@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/token-types/-/token-types-2.1.1.tgz#bd585d64902aaf720b8979d257b4b850b4d45c45" + integrity sha512-wnQcqlreS6VjthyHO3Y/kpK/emflxDBNhlNUPfh7wE39KnuDdOituXomIbyI79vBtF0Ninpkh72mcuRHo+RG3Q== + dependencies: + "@tokenizer/token" "^0.1.1" + ieee754 "^1.2.1" + tr46@~0.0.3: version "0.0.3" resolved "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz" @@ -8619,6 +8666,13 @@ typed-array-length@^1.0.4: for-each "^0.3.3" is-typed-array "^1.1.9" +typedarray-to-buffer@^3.1.5: + version "3.1.5" + resolved "https://registry.yarnpkg.com/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz#a97ee7a9ff42691b9f783ff1bc5112fe3fca9080" + integrity sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q== + dependencies: + is-typedarray "^1.0.0" + typedarray@^0.0.6: version "0.0.6" resolved "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz"