Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@ export interface CommentRecent {
content: string
modifiedAt: Date
author: Owner
file: { name: string; path: string; mime: string; inTrash: number; fromSpace: number; fromShare: number }
file: { name: string; path: string; mime: string; inTrash: number; fromSpace: number; fromShare: number; displayRootName?: string }
}
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,8 @@ export class CommentsQueries {
mime: files.mime,
inTrash: sql<number>`0`.as('inTrash'),
fromSpace: sql<number>`0`.as('fromSpace'),
fromShare: sql<number>`1`.as('fromShare')
fromShare: sql<number>`1`.as('fromShare'),
displayRootName: shares.name
}
} satisfies CommentRecent | SelectedFields<any, any>)
.from(shares)
Expand Down Expand Up @@ -204,7 +205,8 @@ export class CommentsQueries {
mime: files.mime,
inTrash: sql<number>`${files.inTrash}`.as('inTrash'),
fromSpace: sql<number>`IF (${files.ownerId} = ${userId}, 0, 1)`.as('fromSpace'),
fromShare: sql<number>`0`.as('fromShare')
fromShare: sql<number>`0`.as('fromShare'),
displayRootName: sql<string>`IF (${files.ownerId} = ${userId}, NULL, ${spaces.name})`.as('displayRootName')
}
} satisfies CommentRecent | SelectedFields<any, any>)
.from(spaces)
Expand Down Expand Up @@ -236,10 +238,12 @@ export class CommentsQueries {

async getRecentsFromUser(user: UserModel, limit = 10): Promise<CommentRecent[]> {
const hasPersonal = user.havePermission(USER_PERMISSION.PERSONAL_SPACE)
const [spaceIds, shareIds] = await Promise.all([
user.havePermission(USER_PERMISSION.SPACES) ? this.spacesQueries.spaceIds(user.id) : Promise.resolve([]),
user.havePermission(USER_PERMISSION.SHARES) ? this.sharesQueries.shareIds(user.id, +user.isAdmin) : Promise.resolve([])
const [spaces, shares] = await Promise.all([
user.havePermission(USER_PERMISSION.SPACES) ? this.spacesQueries.spaceIdentities(user.id) : Promise.resolve([]),
user.havePermission(USER_PERMISSION.SHARES) ? this.sharesQueries.shareIdentities(user.id, +user.isAdmin) : Promise.resolve([])
])
const spaceIds = spaces.map(({ id }) => id)
const shareIds = shares.map(({ id }) => id)
const hasSpaces = spaceIds.length > 0
const hasShares = shareIds.length > 0
const sourceCount = +hasPersonal + +hasSpaces + +hasShares
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export interface FileContent {
matches?: string[]
// used for search
score?: number
// used for search display
displayRootName?: string
}

export type FileContentRecordMetadata = Pick<FileContent, 'name' | 'path' | 'size'>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export class FileRecent implements FileRecentSchema {
name: string
mime: string
mtime: number
displayRootName?: string
}

export interface FileRecentLocation {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -297,9 +297,12 @@ export class FilesQueries {
path: filesRecents.path,
name: filesRecents.name,
mime: filesRecents.mime,
mtime: filesRecents.mtime
mtime: filesRecents.mtime,
displayRootName: sql<string>`COALESCE(${spaces.name}, ${shares.name})`.as('displayRootName')
} satisfies FileRecent | SelectedFields<any, any>)
.from(filesRecents)
.leftJoin(spaces, eq(spaces.id, filesRecents.spaceId))
.leftJoin(shares, eq(shares.id, filesRecents.shareId))
.where(or(...where))
.groupBy(filesRecents.id)
.orderBy(desc(filesRecents.mtime))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ describe(FilesRecents.name, () => {
upsertRecent: Mock
}
let spacesQueries: {
spaceIds: Mock
spaceIdentities: Mock
}
let sharesQueries: {
shareIds: Mock
shareIdentities: Mock
}

beforeEach(async () => {
Expand All @@ -38,10 +38,10 @@ describe(FilesRecents.name, () => {
upsertRecent: vi.fn().mockResolvedValue(undefined)
}
spacesQueries = {
spaceIds: vi.fn().mockResolvedValue([])
spaceIdentities: vi.fn().mockResolvedValue([])
}
sharesQueries = {
shareIds: vi.fn().mockResolvedValue([])
shareIdentities: vi.fn().mockResolvedValue([])
}
const module: TestingModule = await Test.createTestingModule({
providers: [
Expand Down Expand Up @@ -74,54 +74,57 @@ describe(FilesRecents.name, () => {

it('should load recents from user accessible spaces and shares', async () => {
const recents = [{ id: 1, name: 'a.txt' }]
spacesQueries.spaceIds.mockResolvedValueOnce([10, 11])
sharesQueries.shareIds.mockResolvedValueOnce([20])
spacesQueries.spaceIdentities.mockResolvedValueOnce([
{ id: 10, alias: 'first', name: 'First' },
{ id: 11, alias: 'second', name: 'Second' }
])
sharesQueries.shareIdentities.mockResolvedValueOnce([{ id: 20, alias: 'shared', name: 'Shared' }])
filesQueries.getRecentsFromUser.mockResolvedValueOnce(recents)

const result = await service.getRecents(
userWithPermissions([USER_PERMISSION.PERSONAL_SPACE, USER_PERMISSION.SPACES, USER_PERMISSION.SHARES], { isAdmin: true }),
25
)

expect(spacesQueries.spaceIds).toHaveBeenCalledWith(7)
expect(sharesQueries.shareIds).toHaveBeenCalledWith(7, 1)
expect(spacesQueries.spaceIdentities).toHaveBeenCalledWith(7)
expect(sharesQueries.shareIdentities).toHaveBeenCalledWith(7, 1)
expect(filesQueries.getRecentsFromUser).toHaveBeenCalledWith(7, [10, 11], [20], 25)
expect(result).toBe(recents)
})

it('should only load personal recents when user only has personal space permission', async () => {
await service.getRecents(userWithPermissions([USER_PERMISSION.PERSONAL_SPACE]), 10)

expect(spacesQueries.spaceIds).not.toHaveBeenCalled()
expect(sharesQueries.shareIds).not.toHaveBeenCalled()
expect(spacesQueries.spaceIdentities).not.toHaveBeenCalled()
expect(sharesQueries.shareIdentities).not.toHaveBeenCalled()
expect(filesQueries.getRecentsFromUser).toHaveBeenCalledWith(7, [], [], 10)
})

it('should only load space recents when user only has spaces permission', async () => {
spacesQueries.spaceIds.mockResolvedValueOnce([10])
spacesQueries.spaceIdentities.mockResolvedValueOnce([{ id: 10, alias: 'space', name: 'Space' }])

await service.getRecents(userWithPermissions([USER_PERMISSION.SPACES]), 10)

expect(spacesQueries.spaceIds).toHaveBeenCalledWith(7)
expect(sharesQueries.shareIds).not.toHaveBeenCalled()
expect(spacesQueries.spaceIdentities).toHaveBeenCalledWith(7)
expect(sharesQueries.shareIdentities).not.toHaveBeenCalled()
expect(filesQueries.getRecentsFromUser).toHaveBeenCalledWith(undefined, [10], [], 10)
})

it('should only load share recents when user only has shares permission', async () => {
sharesQueries.shareIds.mockResolvedValueOnce([20])
sharesQueries.shareIdentities.mockResolvedValueOnce([{ id: 20, alias: 'share', name: 'Share' }])

await service.getRecents(userWithPermissions([USER_PERMISSION.SHARES]), 10)

expect(spacesQueries.spaceIds).not.toHaveBeenCalled()
expect(sharesQueries.shareIds).toHaveBeenCalledWith(7, 0)
expect(spacesQueries.spaceIdentities).not.toHaveBeenCalled()
expect(sharesQueries.shareIdentities).toHaveBeenCalledWith(7, 0)
expect(filesQueries.getRecentsFromUser).toHaveBeenCalledWith(undefined, [], [20], 10)
})

it('should not load any recents source without matching permission', async () => {
await service.getRecents(userWithPermissions(), 10)

expect(spacesQueries.spaceIds).not.toHaveBeenCalled()
expect(sharesQueries.shareIds).not.toHaveBeenCalled()
expect(spacesQueries.spaceIdentities).not.toHaveBeenCalled()
expect(sharesQueries.shareIdentities).not.toHaveBeenCalled()
expect(filesQueries.getRecentsFromUser).toHaveBeenCalledWith(undefined, [], [], 10)
})

Expand Down
13 changes: 9 additions & 4 deletions backend/src/applications/files/services/files-recents.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,17 @@ export class FilesRecents {
) {}

async getRecents(user: UserModel, limit: number): Promise<FileRecent[]> {
const [spaceIds, shareIds] = await Promise.all([
user.havePermission(USER_PERMISSION.SPACES) ? this.spacesQueries.spaceIds(user.id) : Promise.resolve([]),
user.havePermission(USER_PERMISSION.SHARES) ? this.sharesQueries.shareIds(user.id, +user.isAdmin) : Promise.resolve([])
const [spaces, shares] = await Promise.all([
user.havePermission(USER_PERMISSION.SPACES) ? this.spacesQueries.spaceIdentities(user.id) : Promise.resolve([]),
user.havePermission(USER_PERMISSION.SHARES) ? this.sharesQueries.shareIdentities(user.id, +user.isAdmin) : Promise.resolve([])
])
const ownerId = user.havePermission(USER_PERMISSION.PERSONAL_SPACE) ? user.id : undefined
return this.filesQueries.getRecentsFromUser(ownerId, spaceIds, shareIds, limit)
return this.filesQueries.getRecentsFromUser(
ownerId,
spaces.map(({ id }) => id),
shares.map(({ id }) => id),
limit
)
}

async updateRecents(user: UserModel, space: SpaceEnv, files: FileProps[]): Promise<void> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@ describe(FilesSearchManager.name, () => {
allPaths: Mock
}
let spacesQueries: {
spaceIds: Mock
spaceIdentities: Mock
}
let sharesQueries: {
shareIds: Mock
shareIdentities: Mock
}
let contentIndexingEnabled: boolean

Expand All @@ -45,10 +45,10 @@ describe(FilesSearchManager.name, () => {
allPaths: vi.fn()
}
spacesQueries = {
spaceIds: vi.fn().mockResolvedValue([])
spaceIdentities: vi.fn().mockResolvedValue([])
}
sharesQueries = {
shareIds: vi.fn().mockResolvedValue([])
shareIdentities: vi.fn().mockResolvedValue([])
}
const module: TestingModule = await Test.createTestingModule({
providers: [
Expand Down Expand Up @@ -88,21 +88,27 @@ describe(FilesSearchManager.name, () => {

it('should route to full-text search with space and share ids', async () => {
configuration.applications.files.contentIndexing.enabled = true
spacesQueries.spaceIds.mockResolvedValueOnce([1, 2])
sharesQueries.shareIds.mockResolvedValueOnce([4])
spacesQueries.spaceIdentities.mockResolvedValueOnce([
{ id: 1, alias: 'first', name: 'First' },
{ id: 2, alias: 'second', name: 'Second' }
])
sharesQueries.shareIdentities.mockResolvedValueOnce([{ id: 4, alias: 'shared', name: 'Shared' }])
const fullTextSpy = vi.spyOn(service as any, 'searchFullText').mockResolvedValueOnce([fileContent('match.md')])

const result = await service.search({ id: 10, isAdmin: true } as any, { content: 'match', fullText: true, limit: 5 } as any)

expect(spacesQueries.spaceIds).toHaveBeenCalledWith(10)
expect(sharesQueries.shareIds).toHaveBeenCalledWith(10, 1)
expect(spacesQueries.spaceIdentities).toHaveBeenCalledWith(10)
expect(sharesQueries.shareIdentities).toHaveBeenCalledWith(10, 1)
expect(fullTextSpy).toHaveBeenCalledWith(10, [1, 2], [4], 'match', 5)
expect(result).toEqual([fileContent('match.md')])
})

it('should route to filename search when fullText is false', async () => {
spacesQueries.spaceIds.mockResolvedValueOnce([6])
sharesQueries.shareIds.mockResolvedValueOnce([8, 9])
spacesQueries.spaceIdentities.mockResolvedValueOnce([{ id: 6, alias: 'reports', name: 'Reports' }])
sharesQueries.shareIdentities.mockResolvedValueOnce([
{ id: 8, alias: 'first-share', name: 'First share' },
{ id: 9, alias: 'second-share', name: 'Second share' }
])
const nameSearchSpy = vi.spyOn(service as any, 'searchFileNames').mockResolvedValueOnce([fileContent('report.pdf')])

const result = await service.search({ id: 3, isAdmin: false } as any, { content: 'report', fullText: false, limit: 2 } as any)
Expand All @@ -111,6 +117,20 @@ describe(FilesSearchManager.name, () => {
expect(result).toEqual([fileContent('report.pdf')])
})

it('adds current space and share names to search results', async () => {
spacesQueries.spaceIdentities.mockResolvedValueOnce([{ id: 6, alias: 'communication', name: 'Communication' }])
sharesQueries.shareIdentities.mockResolvedValueOnce([{ id: 8, alias: 'public-access', name: 'Public access' }])
vi.spyOn(service as any, 'searchFileNames').mockResolvedValueOnce([
{ ...fileContent('space.md'), path: 'files/communication/docs' },
{ ...fileContent('share.md'), path: 'shares/public-access/docs' },
{ ...fileContent('personal.md'), path: 'files/personal/docs' }
])

const result = await service.search({ id: 3, isAdmin: false } as any, { content: 'docs', fullText: false, limit: 3 } as any)

expect(result.map(({ displayRootName }) => displayRootName)).toEqual(['Communication', 'Public access', undefined])
})

it('should normalize search limit before routing', async () => {
const nameSearchSpy = vi.spyOn(service as any, 'searchFileNames').mockResolvedValue([])

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Stats } from 'node:fs'
import path from 'node:path'
import { configuration } from '../../../configuration/config.environment'
import { SharesQueries } from '../../shares/services/shares-queries.service'
import { SPACE_REPOSITORY } from '../../spaces/constants/spaces'
import { SpacesQueries } from '../../spaces/services/spaces-queries.service'
import { UserModel } from '../../users/models/user.model'
import { SearchFilesDto } from '../dto/file-operations.dto'
Expand Down Expand Up @@ -32,12 +33,16 @@ export class FilesSearchManager {
throw new HttpException('Full-text search is disabled', HttpStatus.BAD_REQUEST)
}
const limit = normalizeSearchLimit(search.limit)
const [spaceIds, shareIds] = await Promise.all([this.spacesQueries.spaceIds(user.id), this.sharesQueries.shareIds(user.id, +user.isAdmin)])
if (search.fullText) {
return await this.searchFullText(user.id, spaceIds, shareIds, search.content, limit)
} else {
return await this.searchFileNames(user.id, spaceIds, shareIds, search.content, limit)
}
const [userSpaces, userShares] = await Promise.all([
this.spacesQueries.spaceIdentities(user.id),
this.sharesQueries.shareIdentities(user.id, +user.isAdmin)
])
const spaceIds = userSpaces.map((space) => space.id)
const shareIds = userShares.map((share) => share.id)
const fileContents = await (search.fullText
? this.searchFullText(user.id, spaceIds, shareIds, search.content, limit)
: this.searchFileNames(user.id, spaceIds, shareIds, search.content, limit))
return this.setDisplayRootNames(fileContents, userSpaces, userShares)
}

private async searchFullText(userId: number, spaceIds: number[], shareIds: number[], search: string, limit: number): Promise<FileContent[]> {
Expand Down Expand Up @@ -73,6 +78,7 @@ export class FilesSearchManager {
const f = await this.analyzeFile(p.realPath, p.pathPrefix, regexBasePath, regexpTerms)
if (f !== null) {
fileContents.push(f)
if (fileContents.length >= limit) return fileContents
}
continue
}
Expand Down Expand Up @@ -117,4 +123,25 @@ export class FilesSearchManager {
mtime: stats.mtime.getTime()
}
}

private setDisplayRootNames(
fileContents: FileContent[],
userSpaces: { alias?: string; name?: string }[],
userShares: { alias?: string; name?: string }[]
): FileContent[] {
if (fileContents.length === 0) return fileContents
const displayRootNames = new Map<string, string>()
for (const { alias, name } of userSpaces) {
if (alias && name) displayRootNames.set(`${SPACE_REPOSITORY.FILES}/${alias}`, name)
}
for (const { alias, name } of userShares) {
if (alias && name) displayRootNames.set(`${SPACE_REPOSITORY.SHARES}/${alias}`, name)
}
for (const fileContent of fileContents) {
const [repository, alias] = fileContent.path.split('/', 2)
const displayRootName = displayRootNames.get(`${repository}/${alias}`)
if (displayRootName) fileContent.displayRootName = displayRootName
}
return fileContents
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ describe(SharesManager.name, () => {
updateMembers: vi.fn(),
shareExistsForOwner: vi.fn(),
childExistsForShareOwner: vi.fn(),
clearCacheIdentities: vi.fn().mockResolvedValue(true),
clearCachePermissions: vi.fn().mockResolvedValue(true)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -234,12 +234,14 @@ export class SharesManager {
// asAdmin: true if the user is the owner of the parent share or if the share is requested from the administration
const share: ShareProps = await this.getShareWithMembers(user, shareId, asAdmin)
// check and update share info
let renamedShareAlias: string
const shareDiffProps: Partial<ShareProps> = { modifiedAt: new Date() }
const props: (keyof CreateOrUpdateShareDto)[] = ['name', 'description', 'enabled', 'storageQuota', 'storageIndexing']
for (const prop of props) {
if (createOrUpdateShareDto[prop] !== share[prop]) {
shareDiffProps[prop] = createOrUpdateShareDto[prop]
if (prop === 'name') {
renamedShareAlias = share.alias
shareDiffProps.alias = await this.sharesQueries.uniqueShareAlias(shareDiffProps.name)
} else if (prop === 'enabled') {
shareDiffProps.disabledAt = shareDiffProps[prop] ? null : new Date()
Expand All @@ -250,6 +252,9 @@ export class SharesManager {
if (!(await this.sharesQueries.updateShare(shareId, shareDiffProps))) {
throw new HttpException('Unable to update share', HttpStatus.INTERNAL_SERVER_ERROR)
}
if (renamedShareAlias) {
void this.sharesQueries.clearCachePermissions(renamedShareAlias)
}
// update quota in cache
if ('storageQuota' in shareDiffProps) {
void this.filesQuotaManager.updateStorageQuota(shareId, FILE_REPOSITORY.SHARE, shareDiffProps.storageQuota)
Expand Down Expand Up @@ -683,6 +688,9 @@ export class SharesManager {
}
try {
await this.linksQueries.updateLinkFromSpaceOrShare(link, spaceOrShareId, updateUser, updateLink, updateShare, updateMember)
if ('name' in updateShare) {
void this.sharesQueries.clearCacheIdentities()
}
this.logger.debug({
tag: this.updateLinkFromSpaceOrShare.name,
msg: `link (${linkId}) updated : ${JSON.stringify({
Expand Down
Loading