Bläddra i källkod

fix(syncing-server): fetching items associated with shared vaults (#666)

Karol Sójko 1 år sedan
förälder
incheckning
c030a6b3d8

+ 1 - 0
packages/syncing-server/src/Bootstrap/Container.ts

@@ -517,6 +517,7 @@ export class ContainerConfigLoader {
       .toConstantValue(
         new GetItems(
           container.get(TYPES.Sync_ItemRepository),
+          container.get(TYPES.Sync_SharedVaultUserRepository),
           container.get(TYPES.Sync_CONTENT_SIZE_TRANSFER_LIMIT),
           container.get(TYPES.Sync_ItemTransferCalculator),
           container.get(TYPES.Sync_Timer),

+ 47 - 0
packages/syncing-server/src/Domain/Item/Item.spec.ts

@@ -0,0 +1,47 @@
+import { ContentType, Dates, Timestamps, UniqueEntityId, Uuid } from '@standardnotes/domain-core'
+
+import { Item } from './Item'
+
+describe('Item', () => {
+  it('should create an aggregate', () => {
+    const entityOrError = Item.create({
+      duplicateOf: null,
+      itemsKeyId: 'items-key-id',
+      content: 'content',
+      contentType: ContentType.create(ContentType.TYPES.Note).getValue(),
+      encItemKey: 'enc-item-key',
+      authHash: 'auth-hash',
+      userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+      deleted: false,
+      updatedWithSession: null,
+      dates: Dates.create(new Date(123), new Date(123)).getValue(),
+      timestamps: Timestamps.create(123, 123).getValue(),
+    })
+
+    expect(entityOrError.isFailed()).toBeFalsy()
+    expect(entityOrError.getValue().id).not.toBeNull()
+    expect(entityOrError.getValue().uuid.value).toEqual(entityOrError.getValue().id.toString())
+  })
+
+  it('should throw an error if id cannot be cast to uuid', () => {
+    const entityOrError = Item.create(
+      {
+        duplicateOf: null,
+        itemsKeyId: 'items-key-id',
+        content: 'content',
+        contentType: ContentType.create(ContentType.TYPES.Note).getValue(),
+        encItemKey: 'enc-item-key',
+        authHash: 'auth-hash',
+        userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+        deleted: false,
+        updatedWithSession: null,
+        dates: Dates.create(new Date(123), new Date(123)).getValue(),
+        timestamps: Timestamps.create(123, 123).getValue(),
+      },
+      new UniqueEntityId(1),
+    )
+
+    expect(entityOrError.isFailed()).toBeFalsy()
+    expect(() => entityOrError.getValue().uuid).toThrow()
+  })
+})

+ 10 - 1
packages/syncing-server/src/Domain/Item/Item.ts

@@ -1,8 +1,17 @@
-import { Aggregate, Result, UniqueEntityId } from '@standardnotes/domain-core'
+import { Aggregate, Result, UniqueEntityId, Uuid } from '@standardnotes/domain-core'
 
 import { ItemProps } from './ItemProps'
 
 export class Item extends Aggregate<ItemProps> {
+  get uuid(): Uuid {
+    const uuidOrError = Uuid.create(this._id.toString())
+    if (uuidOrError.isFailed()) {
+      throw new Error(uuidOrError.getError())
+    }
+
+    return uuidOrError.getValue()
+  }
+
   private constructor(props: ItemProps, id?: UniqueEntityId) {
     super(props, id)
   }

+ 2 - 0
packages/syncing-server/src/Domain/Item/ItemQuery.ts

@@ -11,4 +11,6 @@ export type ItemQuery = {
   limit?: number
   createdBetween?: Date[]
   selectString?: string
+  includeSharedVaultUuids?: string[]
+  exclusiveSharedVaultUuids?: string[]
 }

+ 60 - 7
packages/syncing-server/src/Domain/UseCase/Syncing/GetItems/GetItems.spec.ts

@@ -4,6 +4,7 @@ import { ItemTransferCalculatorInterface } from '../../../Item/ItemTransferCalcu
 import { GetItems } from './GetItems'
 import { Item } from '../../../Item/Item'
 import { ContentType, Dates, Timestamps, Uuid } from '@standardnotes/domain-core'
+import { SharedVaultUserRepositoryInterface } from '../../../SharedVault/User/SharedVaultUserRepositoryInterface'
 
 describe('GetItems', () => {
   let itemRepository: ItemRepositoryInterface
@@ -12,9 +13,17 @@ describe('GetItems', () => {
   let timer: TimerInterface
   const maxItemsSyncLimit = 100
   let item: Item
+  let sharedVaultUserRepository: SharedVaultUserRepositoryInterface
 
   const createUseCase = () =>
-    new GetItems(itemRepository, contentSizeTransferLimit, itemTransferCalculator, timer, maxItemsSyncLimit)
+    new GetItems(
+      itemRepository,
+      sharedVaultUserRepository,
+      contentSizeTransferLimit,
+      itemTransferCalculator,
+      timer,
+      maxItemsSyncLimit,
+    )
 
   beforeEach(() => {
     item = Item.create({
@@ -41,13 +50,16 @@ describe('GetItems', () => {
     timer = {} as jest.Mocked<TimerInterface>
     timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(123)
     timer.convertStringDateToMicroseconds = jest.fn().mockReturnValue(123)
+
+    sharedVaultUserRepository = {} as jest.Mocked<SharedVaultUserRepositoryInterface>
+    sharedVaultUserRepository.findByUserUuid = jest.fn().mockResolvedValue([])
   })
 
   it('returns items', async () => {
     const useCase = createUseCase()
 
     const result = await useCase.execute({
-      userUuid: 'user-uuid',
+      userUuid: '00000000-0000-0000-0000-000000000000',
       cursorToken: undefined,
       contentType: undefined,
       limit: 10,
@@ -67,7 +79,7 @@ describe('GetItems', () => {
     const useCase = createUseCase()
 
     const result = await useCase.execute({
-      userUuid: 'user-uuid',
+      userUuid: '00000000-0000-0000-0000-000000000000',
       cursorToken: undefined,
       contentType: undefined,
       limit: undefined,
@@ -85,7 +97,7 @@ describe('GetItems', () => {
     const useCase = createUseCase()
 
     const result = await useCase.execute({
-      userUuid: 'user-uuid',
+      userUuid: '00000000-0000-0000-0000-000000000000',
       cursorToken: 'MjowLjAwMDEyMw==',
       contentType: undefined,
       limit: undefined,
@@ -106,7 +118,7 @@ describe('GetItems', () => {
     const syncToken = Buffer.from(syncTokenData, 'utf-8').toString('base64')
 
     const result = await useCase.execute({
-      userUuid: 'user-uuid',
+      userUuid: '00000000-0000-0000-0000-000000000000',
       syncToken,
       contentType: undefined,
       limit: undefined,
@@ -127,7 +139,7 @@ describe('GetItems', () => {
     const syncToken = Buffer.from(syncTokenData, 'utf-8').toString('base64')
 
     const result = await useCase.execute({
-      userUuid: 'user-uuid',
+      userUuid: '00000000-0000-0000-0000-000000000000',
       syncToken,
       contentType: undefined,
       limit: undefined,
@@ -141,7 +153,7 @@ describe('GetItems', () => {
     const useCase = createUseCase()
 
     const result = await useCase.execute({
-      userUuid: 'user-uuid',
+      userUuid: '00000000-0000-0000-0000-000000000000',
       cursorToken: undefined,
       contentType: undefined,
       limit: 200,
@@ -154,4 +166,45 @@ describe('GetItems', () => {
       lastSyncTime: null,
     })
   })
+
+  it('should return error for invalid user uuid', async () => {
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      userUuid: 'invalid',
+      cursorToken: undefined,
+      contentType: undefined,
+      limit: undefined,
+    })
+
+    expect(result.isFailed()).toBeTruthy()
+    expect(result.getError()).toEqual('Given value is not a valid uuid: invalid')
+  })
+
+  it('should filter shared vault uuids user wants to sync with the ones it has access to', async () => {
+    sharedVaultUserRepository.findByUserUuid = jest.fn().mockResolvedValue([
+      {
+        props: {
+          sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+        },
+      },
+    ])
+
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      userUuid: '00000000-0000-0000-0000-000000000000',
+      cursorToken: undefined,
+      contentType: undefined,
+      limit: undefined,
+      sharedVaultUuids: ['00000000-0000-0000-0000-000000000000', '11111111-1111-1111-1111-111111111111'],
+    })
+
+    expect(result.isFailed()).toBeFalsy()
+    expect(result.getValue()).toEqual({
+      items: [item],
+      cursorToken: undefined,
+      lastSyncTime: null,
+    })
+  })
 })

+ 19 - 2
packages/syncing-server/src/Domain/UseCase/Syncing/GetItems/GetItems.ts

@@ -1,4 +1,4 @@
-import { Result, UseCaseInterface } from '@standardnotes/domain-core'
+import { Result, UseCaseInterface, Uuid } from '@standardnotes/domain-core'
 import { Time, TimerInterface } from '@standardnotes/time'
 
 import { Item } from '../../../Item/Item'
@@ -7,6 +7,7 @@ import { ItemQuery } from '../../../Item/ItemQuery'
 import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
 import { ItemTransferCalculatorInterface } from '../../../Item/ItemTransferCalculatorInterface'
 import { GetItemsDTO } from './GetItemsDTO'
+import { SharedVaultUserRepositoryInterface } from '../../../SharedVault/User/SharedVaultUserRepositoryInterface'
 
 export class GetItems implements UseCaseInterface<GetItemsResult> {
   private readonly DEFAULT_ITEMS_LIMIT = 150
@@ -14,6 +15,7 @@ export class GetItems implements UseCaseInterface<GetItemsResult> {
 
   constructor(
     private itemRepository: ItemRepositoryInterface,
+    private sharedVaultUserRepository: SharedVaultUserRepositoryInterface,
     private contentSizeTransferLimit: number,
     private itemTransferCalculator: ItemTransferCalculatorInterface,
     private timer: TimerInterface,
@@ -27,12 +29,25 @@ export class GetItems implements UseCaseInterface<GetItemsResult> {
     }
     const lastSyncTime = lastSyncTimeOrError.getValue()
 
+    const userUuidOrError = Uuid.create(dto.userUuid)
+    if (userUuidOrError.isFailed()) {
+      return Result.fail(userUuidOrError.getError())
+    }
+    const userUuid = userUuidOrError.getValue()
+
     const syncTimeComparison = dto.cursorToken ? '>=' : '>'
     const limit = dto.limit === undefined || dto.limit < 1 ? this.DEFAULT_ITEMS_LIMIT : dto.limit
     const upperBoundLimit = limit < this.maxItemsSyncLimit ? limit : this.maxItemsSyncLimit
 
+    const sharedVaultUsers = await this.sharedVaultUserRepository.findByUserUuid(userUuid)
+    const userSharedVaultUuids = sharedVaultUsers.map((sharedVaultUser) => sharedVaultUser.props.sharedVaultUuid.value)
+
+    const exclusiveSharedVaultUuids = dto.sharedVaultUuids
+      ? dto.sharedVaultUuids.filter((sharedVaultUuid) => userSharedVaultUuids.includes(sharedVaultUuid))
+      : undefined
+
     const itemQuery: ItemQuery = {
-      userUuid: dto.userUuid,
+      userUuid: userUuid.value,
       lastSyncTime: lastSyncTime ?? undefined,
       syncTimeComparison,
       contentType: dto.contentType,
@@ -40,6 +55,8 @@ export class GetItems implements UseCaseInterface<GetItemsResult> {
       sortBy: 'updated_at_timestamp',
       sortOrder: 'ASC',
       limit: upperBoundLimit,
+      includeSharedVaultUuids: !dto.sharedVaultUuids ? userSharedVaultUuids : undefined,
+      exclusiveSharedVaultUuids,
     }
 
     const itemUuidsToFetch = await this.itemTransferCalculator.computeItemUuidsToFetch(

+ 1 - 0
packages/syncing-server/src/Domain/UseCase/Syncing/GetItems/GetItemsDTO.ts

@@ -4,4 +4,5 @@ export interface GetItemsDTO {
   cursorToken?: string | null
   limit?: number
   contentType?: string
+  sharedVaultUuids?: string[]
 }

+ 9 - 7
packages/syncing-server/src/Domain/UseCase/Syncing/SaveItems/SaveItems.ts

@@ -31,27 +31,29 @@ export class SaveItems implements UseCaseInterface<SaveItemsResult> {
     const lastUpdatedTimestamp = this.timer.getTimestampInMicroseconds()
 
     for (const itemHash of dto.itemHashes) {
-      if (dto.readOnlyAccess) {
+      const itemUuidOrError = Uuid.create(itemHash.props.uuid)
+      if (itemUuidOrError.isFailed()) {
         conflicts.push({
           unsavedItem: itemHash,
-          type: ConflictType.ReadOnlyError,
+          type: ConflictType.UuidConflict,
         })
 
         continue
       }
+      const itemUuid = itemUuidOrError.getValue()
 
-      const itemUuidOrError = Uuid.create(itemHash.props.uuid)
-      if (itemUuidOrError.isFailed()) {
+      const existingItem = await this.itemRepository.findByUuid(itemUuid)
+
+      if (dto.readOnlyAccess) {
         conflicts.push({
           unsavedItem: itemHash,
-          type: ConflictType.UuidConflict,
+          serverItem: existingItem ?? undefined,
+          type: ConflictType.ReadOnlyError,
         })
 
         continue
       }
-      const itemUuid = itemUuidOrError.getValue()
 
-      const existingItem = await this.itemRepository.findByUuid(itemUuid)
       const processingResult = await this.itemSaveValidator.validate({
         userUuid: dto.userUuid,
         apiVersion: dto.apiVersion,

+ 1 - 0
packages/syncing-server/src/Domain/UseCase/Syncing/SyncItems/SyncItems.ts

@@ -30,6 +30,7 @@ export class SyncItems implements UseCaseInterface<SyncItemsResponse> {
       cursorToken: dto.cursorToken,
       limit: dto.limit,
       contentType: dto.contentType,
+      sharedVaultUuids: dto.sharedVaultUuids,
     })
     if (getItemsResultOrError.isFailed()) {
       return Result.fail(getItemsResultOrError.getError())

+ 1 - 1
packages/syncing-server/src/Domain/UseCase/Syncing/SyncItems/SyncItemsDTO.ts

@@ -5,7 +5,7 @@ export type SyncItemsDTO = {
   itemHashes: Array<ItemHash>
   computeIntegrityHash: boolean
   limit: number
-  sharedVaultUuids?: string[] | null
+  sharedVaultUuids?: string[]
   syncToken?: string | null
   cursorToken?: string | null
   contentType?: string

+ 16 - 3
packages/syncing-server/src/Infra/InversifyExpressUtils/HomeServer/HomeServerItemsController.ts

@@ -1,4 +1,4 @@
-import { ControllerContainerInterface, MapperInterface } from '@standardnotes/domain-core'
+import { ControllerContainerInterface, MapperInterface, Validator } from '@standardnotes/domain-core'
 import { BaseHttpController, results } from 'inversify-express-utils'
 import { Request, Response } from 'express'
 
@@ -49,19 +49,27 @@ export class HomeServerItemsController extends BaseHttpController {
       }
     }
 
+    let sharedVaultUuids: string[] | undefined = undefined
+    if ('shared_vault_uuids' in request.body) {
+      const sharedVaultUuidsValidation = Validator.isNotEmpty(sharedVaultUuids)
+      if (!sharedVaultUuidsValidation.isFailed()) {
+        sharedVaultUuids = request.body.shared_vault_uuids
+      }
+    }
+
     const syncResult = await this.syncItems.execute({
       userUuid: response.locals.user.uuid,
       itemHashes,
       computeIntegrityHash: request.body.compute_integrity === true,
       syncToken: request.body.sync_token,
       cursorToken: request.body.cursor_token,
-      sharedVaultUuids: request.body.shared_vault_uuids,
       limit: request.body.limit,
       contentType: request.body.content_type,
       apiVersion: request.body.api ?? ApiVersion.v20161215,
       snjsVersion: <string>request.headers['x-snjs-version'],
       readOnlyAccess: response.locals.readOnlyAccess,
       sessionUuid: response.locals.session ? response.locals.session.uuid : null,
+      sharedVaultUuids,
     })
     if (syncResult.isFailed()) {
       return this.json({ error: { message: syncResult.getError() } }, HttpStatusCode.BadRequest)
@@ -102,7 +110,12 @@ export class HomeServerItemsController extends BaseHttpController {
     })
 
     if (result.isFailed()) {
-      return this.json({ error: { message: result.getError() } }, 404)
+      return this.json(
+        {
+          error: { message: 'Item not found' },
+        },
+        404,
+      )
     }
 
     return this.json({ item: this.itemHttpMapper.toProjection(result.getValue()) })

+ 61 - 14
packages/syncing-server/src/Infra/TypeORM/TypeORMItemRepository.ts

@@ -9,6 +9,7 @@ import { ExtendedIntegrityPayload } from '../../Domain/Item/ExtendedIntegrityPay
 import { TypeORMItem } from './TypeORMItem'
 import { KeySystemAssociationRepositoryInterface } from '../../Domain/KeySystem/KeySystemAssociationRepositoryInterface'
 import { SharedVaultAssociationRepositoryInterface } from '../../Domain/SharedVault/SharedVaultAssociationRepositoryInterface'
+import { TypeORMSharedVaultAssociation } from './TypeORMSharedVaultAssociation'
 
 export class TypeORMItemRepository implements ItemRepositoryInterface {
   constructor(
@@ -92,15 +93,7 @@ export class TypeORMItemRepository implements ItemRepositoryInterface {
 
     const item = this.mapper.toDomain(persistence)
 
-    const keySystemAssociation = await this.keySystemAssociationRepository.findByItemUuid(uuid)
-    if (keySystemAssociation) {
-      item.props.keySystemAssociation = keySystemAssociation
-    }
-
-    const sharedVaultAssociation = await this.sharedVaultAssociationRepository.findByItemUuid(uuid)
-    if (sharedVaultAssociation) {
-      item.props.sharedVaultAssociation = sharedVaultAssociation
-    }
+    await this.decorateItemWithAssociations(item)
 
     return item
   }
@@ -142,13 +135,21 @@ export class TypeORMItemRepository implements ItemRepositoryInterface {
       return null
     }
 
-    return this.mapper.toDomain(persistence)
+    const item = this.mapper.toDomain(persistence)
+
+    await this.decorateItemWithAssociations(item)
+
+    return item
   }
 
   async findAll(query: ItemQuery): Promise<Item[]> {
     const persistence = await this.createFindAllQueryBuilder(query).getMany()
 
-    return persistence.map((p) => this.mapper.toDomain(p))
+    const domainItems = persistence.map((p) => this.mapper.toDomain(p))
+
+    await Promise.all(domainItems.map((item) => this.decorateItemWithAssociations(item)))
+
+    return domainItems
   }
 
   async findAllRaw<T>(query: ItemQuery): Promise<T[]> {
@@ -187,12 +188,37 @@ export class TypeORMItemRepository implements ItemRepositoryInterface {
       queryBuilder.orderBy(`item.${query.sortBy}`, query.sortOrder)
     }
 
+    if (query.includeSharedVaultUuids !== undefined && query.includeSharedVaultUuids.length > 0) {
+      queryBuilder
+        .leftJoin(
+          TypeORMSharedVaultAssociation,
+          'sharedVaultAssociation',
+          'sharedVaultAssociation.itemUuid = item.uuid',
+        )
+        .where('sharedVaultAssociation.sharedVaultUuid IN (:...sharedVaultUuids)', {
+          sharedVaultUuids: query.includeSharedVaultUuids,
+        })
+
+      if (query.userUuid) {
+        queryBuilder.orWhere('item.user_uuid = :userUuid', { userUuid: query.userUuid })
+      }
+    } else if (query.exclusiveSharedVaultUuids !== undefined && query.exclusiveSharedVaultUuids.length > 0) {
+      queryBuilder
+        .innerJoin(
+          TypeORMSharedVaultAssociation,
+          'sharedVaultAssociation',
+          'sharedVaultAssociation.itemUuid = item.uuid',
+        )
+        .where('sharedVaultAssociation.sharedVaultUuid IN (:...sharedVaultUuids)', {
+          sharedVaultUuids: query.exclusiveSharedVaultUuids,
+        })
+    } else if (query.userUuid !== undefined) {
+      queryBuilder.where('item.user_uuid = :userUuid', { userUuid: query.userUuid })
+    }
+
     if (query.selectString !== undefined) {
       queryBuilder.select(query.selectString)
     }
-    if (query.userUuid !== undefined) {
-      queryBuilder.where('item.user_uuid = :userUuid', { userUuid: query.userUuid })
-    }
     if (query.uuids && query.uuids.length > 0) {
       queryBuilder.andWhere('item.uuid IN (:...uuids)', { uuids: query.uuids })
     }
@@ -226,4 +252,25 @@ export class TypeORMItemRepository implements ItemRepositoryInterface {
 
     return queryBuilder
   }
+
+  private async decorateItemWithAssociations(item: Item): Promise<void> {
+    await Promise.all([
+      this.decorateItemWithKeySystemAssociation(item),
+      this.decorateItemWithSharedVaultAssociation(item),
+    ])
+  }
+
+  private async decorateItemWithKeySystemAssociation(item: Item): Promise<void> {
+    const keySystemAssociation = await this.keySystemAssociationRepository.findByItemUuid(item.uuid)
+    if (keySystemAssociation) {
+      item.props.keySystemAssociation = keySystemAssociation
+    }
+  }
+
+  private async decorateItemWithSharedVaultAssociation(item: Item): Promise<void> {
+    const sharedVaultAssociation = await this.sharedVaultAssociationRepository.findByItemUuid(item.uuid)
+    if (sharedVaultAssociation) {
+      item.props.sharedVaultAssociation = sharedVaultAssociation
+    }
+  }
 }