Skip to content

Commit 69fc58d

Browse files
authored
Merge pull request #164 from q16marvin/main
feat(backend:users): add avatar synchronization for OIDC users
2 parents a2f86e1 + 22ac4f0 commit 69fc58d

8 files changed

Lines changed: 285 additions & 18 deletions

File tree

backend/src/applications/files/services/files-manager.service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -516,7 +516,7 @@ export class FilesManager {
516516

517517
// do
518518
try {
519-
await downloadFile(this.http, downloadDto, rPath, space)
519+
await downloadFile(this.http, downloadDto, rPath, { space: space })
520520
} finally {
521521
// release lock
522522
await this.filesLockManager.removeLock(fileLock.key)

backend/src/applications/files/utils/download-file.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,12 @@ const parts = [
3535
const regExpPrivateIP = new RegExp(`^(?:${parts.join('|')})$`, 'i')
3636
const errorRegexpPrivateIP = 'Access to internal IP addresses is forbidden'
3737

38-
export async function downloadFile(http: HttpService, downloadDto: DownloadFileDto, dstPath: string, space?: SpaceEnv) {
38+
export async function downloadFile(
39+
http: HttpService,
40+
downloadDto: DownloadFileDto,
41+
dstPath: string,
42+
options?: { space?: SpaceEnv; getContentInfo?: boolean }
43+
) {
3944
// dto must be validated by the caller
4045
const headRes: AxiosResponse = await http.axiosRef({ method: HTTP_METHOD.HEAD, url: downloadDto.url, maxRedirects: 1 })
4146
if (regExpPrivateIP.test(headRes.request.socket.remoteAddress)) {
@@ -45,18 +50,22 @@ export async function downloadFile(http: HttpService, downloadDto: DownloadFileD
4550

4651
// attempt to retrieve the Content-Length header
4752
const contentLength = 'content-length' in headRes.headers ? Number(headRes.headers['content-length']) || null : null
53+
if (options?.getContentInfo) {
54+
return { contentLength: contentLength, contentType: `${headRes.headers['content-type']}`, lastModified: headRes.headers['last-modified'] }
55+
}
56+
4857
if (!contentLength) {
4958
throw new FileError(HttpStatus.BAD_REQUEST, 'Missing "content-length" header')
5059
}
5160

52-
if (space) {
53-
if (space.willExceedQuota(contentLength)) {
61+
if (options?.space) {
62+
if (options.space.willExceedQuota(contentLength)) {
5463
throw new FileError(HttpStatus.INSUFFICIENT_STORAGE, 'Storage quota will be exceeded')
5564
}
5665
// tasking
57-
if (space.task.cacheKey) {
58-
space.task.props.totalSize = contentLength
59-
FileTaskEvent.emit('startWatch', space, FILE_OPERATION.DOWNLOAD, dstPath)
66+
if (options.space.task?.cacheKey) {
67+
options.space.task.props.totalSize = contentLength
68+
FileTaskEvent.emit('startWatch', options.space, FILE_OPERATION.DOWNLOAD, dstPath)
6069
}
6170
}
6271

backend/src/applications/users/services/users-manager.service.spec.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import { Test, TestingModule } from '@nestjs/testing'
22
import bcrypt from 'bcryptjs'
3+
import fs from 'node:fs/promises'
4+
import os from 'node:os'
35
import path from 'node:path'
46
import { Readable } from 'node:stream'
57
import { AuthManager } from '../../../authentication/auth.service'
68
import { comparePassword } from '../../../common/functions'
79
import * as imageModule from '../../../common/image'
810
import { pngMimeType, svgMimeType } from '../../../common/image'
11+
import { configuration } from '../../../configuration/config.environment'
912
import { Cache } from '../../../infrastructure/cache/services/cache.service'
1013
import { DB_TOKEN_PROVIDER } from '../../../infrastructure/database/constants'
1114
import * as filesUtilsModule from '../../files/utils/files'
@@ -44,6 +47,13 @@ describe(UsersManager.name, () => {
4447
let usersQueriesService: UsersQueries
4548
let userTest: UserModel
4649
let deleteUserDto: DeleteUserDto
50+
let testDataPath: string
51+
const initialFilesPaths = {
52+
dataPath: configuration.applications.files.dataPath,
53+
usersPath: configuration.applications.files.usersPath,
54+
spacesPath: configuration.applications.files.spacesPath,
55+
tmpPath: configuration.applications.files.tmpPath
56+
}
4757
const flush = () => new Promise<void>((r) => setImmediate(r))
4858
const okStream = (d = 'OK') => {
4959
const s: any = Readable.from([Buffer.from(d)])
@@ -71,6 +81,12 @@ describe(UsersManager.name, () => {
7181
}
7282

7383
beforeAll(async () => {
84+
testDataPath = await fs.mkdtemp(path.join(os.tmpdir(), 'sync-in-users-manager-spec-'))
85+
configuration.applications.files.dataPath = testDataPath
86+
configuration.applications.files.usersPath = path.join(testDataPath, 'users')
87+
configuration.applications.files.spacesPath = path.join(testDataPath, 'spaces')
88+
configuration.applications.files.tmpPath = path.join(testDataPath, 'tmp')
89+
7490
const module: TestingModule = await Test.createTestingModule({
7591
providers: [
7692
AdminUsersManager,
@@ -100,6 +116,11 @@ describe(UsersManager.name, () => {
100116

101117
afterAll(async () => {
102118
await expect(adminUsersManager.deleteUserSpace(userTest.login)).resolves.not.toThrow()
119+
configuration.applications.files.dataPath = initialFilesPaths.dataPath
120+
configuration.applications.files.usersPath = initialFilesPaths.usersPath
121+
configuration.applications.files.spacesPath = initialFilesPaths.spacesPath
122+
configuration.applications.files.tmpPath = initialFilesPaths.tmpPath
123+
await fs.rm(testDataPath, { recursive: true, force: true })
103124
})
104125

105126
it('instances + findUser/me/fromUserId + impersonation', async () => {
@@ -271,7 +292,7 @@ describe(UsersManager.name, () => {
271292

272293
it('avatars advanced: generateIsNotExists, failure branches, base64 fallback', async () => {
273294
await ensurePaths()
274-
usersManager.findUser = jest.fn().mockResolvedValue({ getInitials: () => 'UT' } as unknown as UserModel)
295+
usersManager.findUser = jest.fn().mockResolvedValue({ login: userTest.login, getInitials: () => 'UT' } as unknown as UserModel)
275296
const [p, m] = (await usersManager.getAvatar(userTest.login, false, true)) as [string, string]
276297
expect(fileName(p)).toBe('avatar.png')
277298
expect(m).toBe(pngMimeType)
@@ -289,7 +310,7 @@ describe(UsersManager.name, () => {
289310
const t = okStream('OK')
290311
t.truncated = true
291312
const mvSpy = jest.spyOn(filesUtilsModule, 'moveFiles').mockResolvedValue(undefined)
292-
await expect(usersManager.updateAvatar(mkReq('image/png', t) as any)).rejects.toThrow('Image is too large (5MB max)')
313+
await expect(usersManager.updateAvatar(mkReq('image/png', t) as any)).rejects.toThrow('Image is too large')
293314
expect(mvSpy).not.toHaveBeenCalled()
294315

295316
jest.spyOn(filesUtilsModule, 'moveFiles').mockRejectedValue(new Error('mv fail'))

backend/src/applications/users/services/users-manager.service.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { FastifyAuthenticatedRequest } from '../../../authentication/interfaces/
1111
import { JwtIdentityPayload } from '../../../authentication/interfaces/jwt-payload.interface'
1212
import { ACTION } from '../../../common/constants'
1313
import { comparePassword, hashPassword } from '../../../common/functions'
14-
import { generateAvatar, pngMimeType, svgMimeType } from '../../../common/image'
14+
import { generateAvatar, imgMimeTypePrefix, pngMimeType, svgMimeType } from '../../../common/image'
1515
import { createLightSlug, genPassword } from '../../../common/shared'
1616
import { configuration, serverConfig } from '../../../configuration/config.environment'
1717
import { isPathExists, moveFiles, sanitizeName } from '../../files/utils/files'
@@ -40,7 +40,7 @@ import { UserModel } from '../models/user.model'
4040
import type { Group } from '../schemas/group.interface'
4141
import type { UserGroup } from '../schemas/user-group.interface'
4242
import type { User } from '../schemas/user.interface'
43-
import { USER_AVATAR_FILE_NAME, USER_AVATAR_MAX_UPLOAD_SIZE, USER_DEFAULT_AVATAR_FILE_PATH } from '../utils/avatar'
43+
import { saveAvatarMetadata, USER_AVATAR_FILE_NAME, USER_AVATAR_MAX_UPLOAD_SIZE, USER_DEFAULT_AVATAR_FILE_PATH } from '../utils/avatar'
4444
import { AdminUsersManager } from './admin-users-manager.service'
4545
import { UsersQueries } from './users-queries.service'
4646

@@ -144,7 +144,7 @@ export class UsersManager {
144144

145145
async updateAvatar(req: FastifyAuthenticatedRequest) {
146146
const part: MultipartFile = await req.file({ limits: { fileSize: USER_AVATAR_MAX_UPLOAD_SIZE } })
147-
if (!part.mimetype.startsWith('image/')) {
147+
if (!part.mimetype.startsWith(imgMimeTypePrefix)) {
148148
throw new HttpException('Unsupported file type', HttpStatus.BAD_REQUEST)
149149
}
150150
const dstPath = path.join(req.user.tmpPath, USER_AVATAR_FILE_NAME)
@@ -156,10 +156,12 @@ export class UsersManager {
156156
}
157157
if (part.file.truncated) {
158158
this.logger.warn({ tag: this.updateAvatar.name, msg: `image is too large` })
159-
throw new HttpException('Image is too large (5MB max)', HttpStatus.PAYLOAD_TOO_LARGE)
159+
throw new HttpException('Image is too large', HttpStatus.PAYLOAD_TOO_LARGE)
160160
}
161+
const avatarPath = path.join(req.user.homePath, USER_AVATAR_FILE_NAME)
161162
try {
162-
await moveFiles(dstPath, path.join(req.user.homePath, USER_AVATAR_FILE_NAME), true)
163+
await moveFiles(dstPath, avatarPath, true)
164+
void saveAvatarMetadata(req.user.login, 'user')
163165
} catch (e) {
164166
this.logger.error({ tag: this.updateAvatar.name, msg: `${e}` })
165167
throw new HttpException('Unable to create avatar', HttpStatus.INTERNAL_SERVER_ERROR)
@@ -214,6 +216,7 @@ export class UsersManager {
214216
const avatarStream: NodeJS.ReadableStream = await generateAvatar(user.getInitials())
215217
try {
216218
await pipeline(avatarStream, avatarFile)
219+
void saveAvatarMetadata(user.login, 'local')
217220
} catch (e) {
218221
this.logger.error({ tag: this.getAvatar.name, msg: `${e}` })
219222
throw new HttpException('Unable to create avatar', HttpStatus.INTERNAL_SERVER_ERROR)

backend/src/applications/users/utils/avatar.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,48 @@ import { convertImageToBase64 } from '../../../common/image'
33
import { STATIC_ASSETS_PATH } from '../../../configuration/config.constants'
44
import { isPathExists } from '../../files/utils/files'
55
import { UserModel } from '../models/user.model'
6+
import { readFile, writeFile } from 'node:fs/promises'
7+
import fs from 'fs/promises'
68

79
export const USER_DEFAULT_AVATAR_FILE_PATH = path.join(STATIC_ASSETS_PATH, 'avatar.svg')
810
export const USER_AVATAR_FILE_NAME = 'avatar.png'
11+
export const USER_AVATAR_INFO = 'avatar.json' // used to determine if the avatar must be updated (oidc/ldap case)
912
export const USER_AVATAR_MAX_UPLOAD_SIZE = 1024 * 1024 * 5 // 5MB
1013

14+
export interface AvatarInfo {
15+
origin: string
16+
size: number
17+
lastModified?: string
18+
}
19+
1120
export async function getAvatarBase64(userLogin: string): Promise<string> {
1221
const userAvatarPath = path.join(UserModel.getHomePath(userLogin), USER_AVATAR_FILE_NAME)
1322
return convertImageToBase64((await isPathExists(userAvatarPath)) ? userAvatarPath : USER_DEFAULT_AVATAR_FILE_PATH)
1423
}
24+
25+
export async function saveAvatarMetadata(userLogin: string, origin: string, size?: number, lastModified?: string): Promise<void> {
26+
const userAvatarInfoPath = path.join(UserModel.getHomePath(userLogin), USER_AVATAR_INFO)
27+
try {
28+
if (size === undefined || lastModified === undefined) {
29+
const userAvatarPath = path.join(UserModel.getHomePath(userLogin), USER_AVATAR_FILE_NAME)
30+
const stats = await fs.stat(userAvatarPath)
31+
size ??= stats.size
32+
lastModified ??= stats.mtime.toUTCString()
33+
}
34+
await writeFile(userAvatarInfoPath, JSON.stringify({ origin, size, lastModified } satisfies AvatarInfo))
35+
} catch {
36+
// ignore
37+
}
38+
}
39+
40+
export async function isAvatarMetadataUnchanged(userLogin: string, origin: string, size: number, lastModified: string): Promise<boolean> {
41+
const userAvatarInfoPath = path.join(UserModel.getHomePath(userLogin), USER_AVATAR_INFO)
42+
if (!(await isPathExists(userAvatarInfoPath))) return false
43+
let avatarInfo: AvatarInfo
44+
try {
45+
avatarInfo = JSON.parse(await readFile(userAvatarInfoPath, 'utf8'))
46+
} catch {
47+
return false
48+
}
49+
return avatarInfo?.origin === origin && avatarInfo?.size === size && avatarInfo?.lastModified === lastModified
50+
}

backend/src/authentication/providers/oidc/auth-provider-oidc.service.spec.ts

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { HttpStatus } from '@nestjs/common'
2+
import { HttpService } from '@nestjs/axios'
23
import { Test, TestingModule } from '@nestjs/testing'
34
import {
45
authorizationCodeGrant,
@@ -10,8 +11,13 @@ import {
1011
randomState
1112
} from 'openid-client'
1213
import { USER_ROLE } from '../../../applications/users/constants/user'
14+
import { UserModel } from '../../../applications/users/models/user.model'
1315
import { AdminUsersManager } from '../../../applications/users/services/admin-users-manager.service'
1416
import { UsersManager } from '../../../applications/users/services/users-manager.service'
17+
import * as avatarUtils from '../../../applications/users/utils/avatar'
18+
import * as filesUtils from '../../../applications/files/utils/files'
19+
import * as downloadFileUtils from '../../../applications/files/utils/download-file'
20+
import * as imageUtils from '../../../common/image'
1521
import { OAuthCookie } from './auth-oidc.constants'
1622
import { AuthProviderOIDC } from './auth-provider-oidc.service'
1723

@@ -85,6 +91,9 @@ describe(AuthProviderOIDC.name, () => {
8591
createUserOrGuest: jest.Mock
8692
updateUserOrGuest: jest.Mock
8793
}
94+
let httpService: {
95+
axiosRef: jest.Mock
96+
}
8897

8998
const makeConfig = (supportsPKCE = true) => ({
9099
serverMetadata: () => ({
@@ -110,9 +119,17 @@ describe(AuthProviderOIDC.name, () => {
110119
createUserOrGuest: jest.fn(),
111120
updateUserOrGuest: jest.fn()
112121
}
122+
httpService = {
123+
axiosRef: jest.fn()
124+
}
113125

114126
const module: TestingModule = await Test.createTestingModule({
115-
providers: [{ provide: UsersManager, useValue: usersManager }, { provide: AdminUsersManager, useValue: adminUsersManager }, AuthProviderOIDC]
127+
providers: [
128+
{ provide: HttpService, useValue: httpService },
129+
{ provide: UsersManager, useValue: usersManager },
130+
{ provide: AdminUsersManager, useValue: adminUsersManager },
131+
AuthProviderOIDC
132+
]
116133
}).compile()
117134

118135
module.useLogger(['fatal'])
@@ -261,4 +278,87 @@ describe(AuthProviderOIDC.name, () => {
261278
)
262279
expect(result.role).toBe(USER_ROLE.ADMINISTRATOR)
263280
})
281+
282+
describe('updatePictureUrl', () => {
283+
const oidcUser = { login: 'alice', tmpPath: '/tmp/sync-in/alice/tmp' } as UserModel
284+
const userInfo = (picture = 'https://cdn.example.test/avatar.jpg') => ({ picture }) as any
285+
286+
it('returns when picture url is invalid', async () => {
287+
const downloadSpy = jest.spyOn(downloadFileUtils, 'downloadFile')
288+
289+
await (service as any).updatePictureUrl(oidcUser, userInfo('not-a-url'))
290+
291+
expect(downloadSpy).not.toHaveBeenCalled()
292+
})
293+
294+
it('stops when content type is not an image', async () => {
295+
const downloadSpy = jest.spyOn(downloadFileUtils, 'downloadFile').mockResolvedValueOnce({
296+
contentType: 'text/plain',
297+
contentLength: 123,
298+
lastModified: 'Mon, 01 Jan 2024 00:00:00 GMT'
299+
} as any)
300+
const convertSpy = jest.spyOn(imageUtils, 'convertTempImageToPng').mockResolvedValue(undefined)
301+
302+
await (service as any).updatePictureUrl(oidcUser, userInfo())
303+
304+
expect(downloadSpy).toHaveBeenCalledTimes(1)
305+
expect(convertSpy).not.toHaveBeenCalled()
306+
})
307+
308+
it('skips update when avatar metadata is unchanged', async () => {
309+
const downloadSpy = jest.spyOn(downloadFileUtils, 'downloadFile').mockResolvedValueOnce({
310+
contentType: 'image/png',
311+
contentLength: 128,
312+
lastModified: 'Mon, 01 Jan 2024 00:00:00 GMT'
313+
} as any)
314+
jest.spyOn(avatarUtils, 'isAvatarMetadataUnchanged').mockResolvedValue(true)
315+
const convertSpy = jest.spyOn(imageUtils, 'convertTempImageToPng').mockResolvedValue(undefined)
316+
317+
await (service as any).updatePictureUrl(oidcUser, userInfo())
318+
319+
expect(downloadSpy).toHaveBeenCalledTimes(1)
320+
expect(convertSpy).not.toHaveBeenCalled()
321+
})
322+
323+
it('downloads and converts avatar when checks pass', async () => {
324+
const downloadSpy = jest
325+
.spyOn(downloadFileUtils, 'downloadFile')
326+
.mockResolvedValueOnce({
327+
contentType: 'image/png',
328+
contentLength: 128,
329+
lastModified: 'Mon, 01 Jan 2024 00:00:00 GMT'
330+
} as any)
331+
.mockResolvedValueOnce(undefined as any)
332+
jest.spyOn(avatarUtils, 'isAvatarMetadataUnchanged').mockResolvedValue(false)
333+
jest.spyOn(filesUtils, 'fileSize').mockResolvedValue(1024)
334+
jest.spyOn(UserModel, 'getHomePath').mockReturnValue('/tmp/sync-in/users/alice')
335+
const convertSpy = jest.spyOn(imageUtils, 'convertTempImageToPng').mockResolvedValue(undefined)
336+
const metadataSpy = jest.spyOn(avatarUtils, 'saveAvatarMetadata').mockResolvedValue(undefined)
337+
338+
await (service as any).updatePictureUrl(oidcUser, userInfo())
339+
340+
expect(downloadSpy).toHaveBeenCalledTimes(2)
341+
expect(convertSpy).toHaveBeenCalledWith('/tmp/sync-in/alice/tmp/avatar.png', '/tmp/sync-in/users/alice/avatar.png')
342+
expect(metadataSpy).toHaveBeenCalledWith('alice', 'https://cdn.example.test/avatar.jpg', 128, 'Mon, 01 Jan 2024 00:00:00 GMT')
343+
})
344+
345+
it('stops after download when avatar size exceeds limit', async () => {
346+
const downloadSpy = jest
347+
.spyOn(downloadFileUtils, 'downloadFile')
348+
.mockResolvedValueOnce({
349+
contentType: 'image/png',
350+
contentLength: 128,
351+
lastModified: 'Mon, 01 Jan 2024 00:00:00 GMT'
352+
} as any)
353+
.mockResolvedValueOnce(undefined as any)
354+
jest.spyOn(avatarUtils, 'isAvatarMetadataUnchanged').mockResolvedValue(false)
355+
jest.spyOn(filesUtils, 'fileSize').mockResolvedValue(avatarUtils.USER_AVATAR_MAX_UPLOAD_SIZE + 1)
356+
const convertSpy = jest.spyOn(imageUtils, 'convertTempImageToPng').mockResolvedValue(undefined)
357+
358+
await (service as any).updatePictureUrl(oidcUser, userInfo())
359+
360+
expect(downloadSpy).toHaveBeenCalledTimes(2)
361+
expect(convertSpy).not.toHaveBeenCalled()
362+
})
363+
})
264364
})

0 commit comments

Comments
 (0)