浏览代码

feat: add procedure for recalculating file quota for user (#980)

* fix(auth): safe guard file upload bytes used to be positive intiger

* feat: add procedure for recalculating file quota for user

* add more meta to logs
Karol Sójko 1 年之前
父节点
当前提交
de4fcf9a4c
共有 30 个文件被更改,包括 732 次插入12 次删除
  1. 45 0
      packages/auth/bin/fix_quota.ts
  2. 11 0
      packages/auth/docker/entrypoint-fix-quota.js
  3. 5 8
      packages/auth/docker/entrypoint.sh
  4. 28 0
      packages/auth/src/Bootstrap/Container.ts
  5. 2 0
      packages/auth/src/Bootstrap/Types.ts
  6. 16 0
      packages/auth/src/Domain/Event/DomainEventFactory.ts
  7. 2 0
      packages/auth/src/Domain/Event/DomainEventFactoryInterface.ts
  8. 38 0
      packages/auth/src/Domain/Handler/FileQuotaRecalculatedEventHandler.ts
  9. 204 0
      packages/auth/src/Domain/UseCase/FixStorageQuotaForUser/FixStorageQuotaForUser.spec.ts
  10. 121 0
      packages/auth/src/Domain/UseCase/FixStorageQuotaForUser/FixStorageQuotaForUser.ts
  11. 3 0
      packages/auth/src/Domain/UseCase/FixStorageQuotaForUser/FixStorageQuotaForUserDTO.ts
  12. 14 0
      packages/auth/src/Domain/UseCase/UpdateStorageQuotaUsedForUser/UpdateStorageQuotaUsedForUser.spec.ts
  13. 4 1
      packages/auth/src/Domain/UseCase/UpdateStorageQuotaUsedForUser/UpdateStorageQuotaUsedForUser.ts
  14. 7 0
      packages/domain-events/src/Domain/Event/FileQuotaRecalculatedEvent.ts
  15. 4 0
      packages/domain-events/src/Domain/Event/FileQuotaRecalculatedEventPayload.ts
  16. 7 0
      packages/domain-events/src/Domain/Event/FileQuotaRecalculationRequestedEvent.ts
  17. 3 0
      packages/domain-events/src/Domain/Event/FileQuotaRecalculationRequestedEventPayload.ts
  18. 4 0
      packages/domain-events/src/Domain/index.ts
  19. 0 2
      packages/files/docker/entrypoint.sh
  20. 25 0
      packages/files/src/Bootstrap/Container.ts
  21. 2 0
      packages/files/src/Bootstrap/Types.ts
  22. 19 0
      packages/files/src/Domain/Event/DomainEventFactory.ts
  23. 2 0
      packages/files/src/Domain/Event/DomainEventFactoryInterface.ts
  24. 33 0
      packages/files/src/Domain/Handler/FileQuotaRecalculationRequestedEventHandler.ts
  25. 1 0
      packages/files/src/Domain/Services/FileDownloaderInterface.ts
  26. 55 0
      packages/files/src/Domain/UseCase/RecalculateQuota/RecalculateQuota.spec.ts
  27. 37 0
      packages/files/src/Domain/UseCase/RecalculateQuota/RecalculateQuota.ts
  28. 3 0
      packages/files/src/Domain/UseCase/RecalculateQuota/RecalculateQuotaDTO.ts
  29. 15 0
      packages/files/src/Infra/FS/FSFileDownloader.ts
  30. 22 1
      packages/files/src/Infra/S3/S3FileDownloader.ts

+ 45 - 0
packages/auth/bin/fix_quota.ts

@@ -0,0 +1,45 @@
+import 'reflect-metadata'
+
+import { Logger } from 'winston'
+
+import { ContainerConfigLoader } from '../src/Bootstrap/Container'
+import TYPES from '../src/Bootstrap/Types'
+import { Env } from '../src/Bootstrap/Env'
+import { FixStorageQuotaForUser } from '../src/Domain/UseCase/FixStorageQuotaForUser/FixStorageQuotaForUser'
+
+const inputArgs = process.argv.slice(2)
+const userEmail = inputArgs[0]
+
+const container = new ContainerConfigLoader('worker')
+void container.load().then((container) => {
+  const env: Env = new Env()
+  env.load()
+
+  const logger: Logger = container.get(TYPES.Auth_Logger)
+
+  logger.info('Starting storage quota fix...', {
+    userId: userEmail,
+  })
+
+  const fixStorageQuota = container.get<FixStorageQuotaForUser>(TYPES.Auth_FixStorageQuotaForUser)
+
+  Promise.resolve(
+    fixStorageQuota.execute({
+      userEmail,
+    }),
+  )
+    .then(() => {
+      logger.info('Storage quota fixed', {
+        userId: userEmail,
+      })
+
+      process.exit(0)
+    })
+    .catch((error) => {
+      logger.error(`Could not fix storage quota: ${error.message}`, {
+        userId: userEmail,
+      })
+
+      process.exit(1)
+    })
+})

+ 11 - 0
packages/auth/docker/entrypoint-fix-quota.js

@@ -0,0 +1,11 @@
+'use strict'
+
+const path = require('path')
+
+const pnp = require(path.normalize(path.resolve(__dirname, '../../..', '.pnp.cjs'))).setup()
+
+const index = require(path.normalize(path.resolve(__dirname, '../dist/bin/fix_quota.js')))
+
+Object.defineProperty(exports, '__esModule', { value: true })
+
+exports.default = index

+ 5 - 8
packages/auth/docker/entrypoint.sh

@@ -5,43 +5,40 @@ COMMAND=$1 && shift 1
 
 case "$COMMAND" in
   'start-web' )
-    echo "[Docker] Starting Web..."
     exec node docker/entrypoint-server.js
     ;;
 
   'start-worker' )
-    echo "[Docker] Starting Worker..."
     exec node docker/entrypoint-worker.js
     ;;
 
   'cleanup' )
-    echo "[Docker] Starting Cleanup..."
     exec node docker/entrypoint-cleanup.js
     ;;
 
   'stats' )
-    echo "[Docker] Starting Persisting Stats..."
     exec node docker/entrypoint-stats.js
     ;;
 
   'email-daily-backup' )
-    echo "[Docker] Starting Email Daily Backup..."
     exec node docker/entrypoint-backup.js daily
     ;;
 
   'email-weekly-backup' )
-    echo "[Docker] Starting Email Weekly Backup..."
     exec node docker/entrypoint-backup.js weekly
     ;;
 
   'email-backup' )
-    echo "[Docker] Starting Email Backup For Single User..."
     EMAIL=$1 && shift 1
     exec node docker/entrypoint-user-email-backup.js $EMAIL
     ;;
 
+  'fix-quota' )
+    EMAIL=$1 && shift 1
+    exec node docker/entrypoint-fix-quota.js $EMAIL
+    ;;
+
   'delete-accounts' )
-    echo "[Docker] Starting Accounts Deleting from CSV..."
     FILE_NAME=$1 && shift 1
     MODE=$1 && shift 1
     exec node docker/entrypoint-delete-accounts.js $FILE_NAME $MODE

+ 28 - 0
packages/auth/src/Bootstrap/Container.ts

@@ -282,6 +282,8 @@ import { S3CsvFileReader } from '../Infra/S3/S3CsvFileReader'
 import { DeleteAccountsFromCSVFile } from '../Domain/UseCase/DeleteAccountsFromCSVFile/DeleteAccountsFromCSVFile'
 import { AccountDeletionVerificationPassedEventHandler } from '../Domain/Handler/AccountDeletionVerificationPassedEventHandler'
 import { RenewSharedSubscriptions } from '../Domain/UseCase/RenewSharedSubscriptions/RenewSharedSubscriptions'
+import { FixStorageQuotaForUser } from '../Domain/UseCase/FixStorageQuotaForUser/FixStorageQuotaForUser'
+import { FileQuotaRecalculatedEventHandler } from '../Domain/Handler/FileQuotaRecalculatedEventHandler'
 
 export class ContainerConfigLoader {
   constructor(private mode: 'server' | 'worker' = 'server') {}
@@ -1285,6 +1287,20 @@ export class ContainerConfigLoader {
           container.get<winston.Logger>(TYPES.Auth_Logger),
         ),
       )
+    container
+      .bind<FixStorageQuotaForUser>(TYPES.Auth_FixStorageQuotaForUser)
+      .toConstantValue(
+        new FixStorageQuotaForUser(
+          container.get<UserRepositoryInterface>(TYPES.Auth_UserRepository),
+          container.get<GetRegularSubscriptionForUser>(TYPES.Auth_GetRegularSubscriptionForUser),
+          container.get<GetSharedSubscriptionForUser>(TYPES.Auth_GetSharedSubscriptionForUser),
+          container.get<SetSubscriptionSettingValue>(TYPES.Auth_SetSubscriptionSettingValue),
+          container.get<ListSharedSubscriptionInvitations>(TYPES.Auth_ListSharedSubscriptionInvitations),
+          container.get<DomainEventFactoryInterface>(TYPES.Auth_DomainEventFactory),
+          container.get<DomainEventPublisherInterface>(TYPES.Auth_DomainEventPublisher),
+          container.get<winston.Logger>(TYPES.Auth_Logger),
+        ),
+      )
     if (!isConfiguredForHomeServer) {
       container
         .bind<DeleteAccountsFromCSVFile>(TYPES.Auth_DeleteAccountsFromCSVFile)
@@ -1541,6 +1557,14 @@ export class ContainerConfigLoader {
           container.get<DomainEventPublisherInterface>(TYPES.Auth_DomainEventPublisher),
         ),
       )
+    container
+      .bind<FileQuotaRecalculatedEventHandler>(TYPES.Auth_FileQuotaRecalculatedEventHandler)
+      .toConstantValue(
+        new FileQuotaRecalculatedEventHandler(
+          container.get<UpdateStorageQuotaUsedForUser>(TYPES.Auth_UpdateStorageQuotaUsedForUser),
+          container.get<winston.Logger>(TYPES.Auth_Logger),
+        ),
+      )
 
     const eventHandlers: Map<string, DomainEventHandlerInterface> = new Map([
       ['ACCOUNT_DELETION_REQUESTED', container.get(TYPES.Auth_AccountDeletionRequestedEventHandler)],
@@ -1578,6 +1602,10 @@ export class ContainerConfigLoader {
         container.get(TYPES.Auth_UserDesignatedAsSurvivorInSharedVaultEventHandler),
       ],
       ['USER_INVITED_TO_SHARED_VAULT', container.get(TYPES.Auth_UserInvitedToSharedVaultEventHandler)],
+      [
+        'FILE_QUOTA_RECALCULATED',
+        container.get<FileQuotaRecalculatedEventHandler>(TYPES.Auth_FileQuotaRecalculatedEventHandler),
+      ],
     ])
 
     if (isConfiguredForHomeServer) {

+ 2 - 0
packages/auth/src/Bootstrap/Types.ts

@@ -170,6 +170,7 @@ const TYPES = {
   Auth_TriggerEmailBackupForAllUsers: Symbol.for('Auth_TriggerEmailBackupForAllUsers'),
   Auth_DeleteAccountsFromCSVFile: Symbol.for('Auth_DeleteAccountsFromCSVFile'),
   Auth_RenewSharedSubscriptions: Symbol.for('Auth_RenewSharedSubscriptions'),
+  Auth_FixStorageQuotaForUser: Symbol.for('Auth_FixStorageQuotaForUser'),
   // Handlers
   Auth_AccountDeletionRequestedEventHandler: Symbol.for('Auth_AccountDeletionRequestedEventHandler'),
   Auth_AccountDeletionVerificationPassedEventHandler: Symbol.for('Auth_AccountDeletionVerificationPassedEventHandler'),
@@ -203,6 +204,7 @@ const TYPES = {
     'Auth_UserDesignatedAsSurvivorInSharedVaultEventHandler',
   ),
   Auth_UserInvitedToSharedVaultEventHandler: Symbol.for('Auth_UserInvitedToSharedVaultEventHandler'),
+  Auth_FileQuotaRecalculatedEventHandler: Symbol.for('Auth_FileQuotaRecalculatedEventHandler'),
   // Services
   Auth_DeviceDetector: Symbol.for('Auth_DeviceDetector'),
   Auth_SessionService: Symbol.for('Auth_SessionService'),

+ 16 - 0
packages/auth/src/Domain/Event/DomainEventFactory.ts

@@ -21,6 +21,7 @@ import {
   SessionCreatedEvent,
   SessionRefreshedEvent,
   AccountDeletionVerificationRequestedEvent,
+  FileQuotaRecalculationRequestedEvent,
 } from '@standardnotes/domain-events'
 import { Predicate, PredicateVerificationResult } from '@standardnotes/predicates'
 import { TimerInterface } from '@standardnotes/time'
@@ -34,6 +35,21 @@ import { KeyParamsData } from '@standardnotes/responses'
 export class DomainEventFactory implements DomainEventFactoryInterface {
   constructor(@inject(TYPES.Auth_Timer) private timer: TimerInterface) {}
 
+  createFileQuotaRecalculationRequestedEvent(dto: { userUuid: string }): FileQuotaRecalculationRequestedEvent {
+    return {
+      type: 'FILE_QUOTA_RECALCULATION_REQUESTED',
+      createdAt: this.timer.getUTCDate(),
+      meta: {
+        correlation: {
+          userIdentifier: dto.userUuid,
+          userIdentifierType: 'uuid',
+        },
+        origin: DomainEventService.Auth,
+      },
+      payload: dto,
+    }
+  }
+
   createAccountDeletionVerificationRequestedEvent(dto: {
     userUuid: string
     email: string

+ 2 - 0
packages/auth/src/Domain/Event/DomainEventFactoryInterface.ts

@@ -19,11 +19,13 @@ import {
   SessionCreatedEvent,
   SessionRefreshedEvent,
   AccountDeletionVerificationRequestedEvent,
+  FileQuotaRecalculationRequestedEvent,
 } from '@standardnotes/domain-events'
 import { InviteeIdentifierType } from '../SharedSubscription/InviteeIdentifierType'
 import { KeyParamsData } from '@standardnotes/responses'
 
 export interface DomainEventFactoryInterface {
+  createFileQuotaRecalculationRequestedEvent(dto: { userUuid: string }): FileQuotaRecalculationRequestedEvent
   createWebSocketMessageRequestedEvent(dto: { userUuid: string; message: JSONString }): WebSocketMessageRequestedEvent
   createEmailRequestedEvent(dto: {
     userEmail: string

+ 38 - 0
packages/auth/src/Domain/Handler/FileQuotaRecalculatedEventHandler.ts

@@ -0,0 +1,38 @@
+import { DomainEventHandlerInterface, FileQuotaRecalculatedEvent } from '@standardnotes/domain-events'
+import { UpdateStorageQuotaUsedForUser } from '../UseCase/UpdateStorageQuotaUsedForUser/UpdateStorageQuotaUsedForUser'
+import { Logger } from 'winston'
+
+export class FileQuotaRecalculatedEventHandler implements DomainEventHandlerInterface {
+  constructor(
+    private updateStorageQuota: UpdateStorageQuotaUsedForUser,
+    private logger: Logger,
+  ) {}
+
+  async handle(event: FileQuotaRecalculatedEvent): Promise<void> {
+    this.logger.info('Updating storage quota for user...', {
+      userId: event.payload.userUuid,
+      totalFileByteSize: event.payload.totalFileByteSize,
+      codeTag: 'FileQuotaRecalculatedEventHandler',
+    })
+
+    const result = await this.updateStorageQuota.execute({
+      userUuid: event.payload.userUuid,
+      bytesUsed: event.payload.totalFileByteSize,
+    })
+
+    if (result.isFailed()) {
+      this.logger.error('Could not update storage quota', {
+        userId: event.payload.userUuid,
+        codeTag: 'FileQuotaRecalculatedEventHandler',
+      })
+
+      return
+    }
+
+    this.logger.info('Storage quota updated', {
+      userId: event.payload.userUuid,
+      totalFileByteSize: event.payload.totalFileByteSize,
+      codeTag: 'FileQuotaRecalculatedEventHandler',
+    })
+  }
+}

+ 204 - 0
packages/auth/src/Domain/UseCase/FixStorageQuotaForUser/FixStorageQuotaForUser.spec.ts

@@ -0,0 +1,204 @@
+import { DomainEventPublisherInterface, FileQuotaRecalculationRequestedEvent } from '@standardnotes/domain-events'
+import { Logger } from 'winston'
+import { DomainEventFactoryInterface } from '../../Event/DomainEventFactoryInterface'
+import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
+import { GetRegularSubscriptionForUser } from '../GetRegularSubscriptionForUser/GetRegularSubscriptionForUser'
+import { GetSharedSubscriptionForUser } from '../GetSharedSubscriptionForUser/GetSharedSubscriptionForUser'
+import { ListSharedSubscriptionInvitations } from '../ListSharedSubscriptionInvitations/ListSharedSubscriptionInvitations'
+import { SetSubscriptionSettingValue } from '../SetSubscriptionSettingValue/SetSubscriptionSettingValue'
+import { FixStorageQuotaForUser } from './FixStorageQuotaForUser'
+import { User } from '../../User/User'
+import { Result } from '@standardnotes/domain-core'
+import { UserSubscription } from '../../Subscription/UserSubscription'
+import { InvitationStatus } from '../../SharedSubscription/InvitationStatus'
+import { SharedSubscriptionInvitation } from '../../SharedSubscription/SharedSubscriptionInvitation'
+
+describe('FixStorageQuotaForUser', () => {
+  let userRepository: UserRepositoryInterface
+  let getRegularSubscription: GetRegularSubscriptionForUser
+  let getSharedSubscriptionForUser: GetSharedSubscriptionForUser
+  let setSubscriptonSettingValue: SetSubscriptionSettingValue
+  let listSharedSubscriptionInvitations: ListSharedSubscriptionInvitations
+  let domainEventFactory: DomainEventFactoryInterface
+  let domainEventPublisher: DomainEventPublisherInterface
+  let logger: Logger
+
+  const createUseCase = () =>
+    new FixStorageQuotaForUser(
+      userRepository,
+      getRegularSubscription,
+      getSharedSubscriptionForUser,
+      setSubscriptonSettingValue,
+      listSharedSubscriptionInvitations,
+      domainEventFactory,
+      domainEventPublisher,
+      logger,
+    )
+
+  beforeEach(() => {
+    userRepository = {} as jest.Mocked<UserRepositoryInterface>
+    userRepository.findOneByUsernameOrEmail = jest.fn().mockReturnValue({
+      uuid: '00000000-0000-0000-0000-000000000000',
+    } as jest.Mocked<User>)
+
+    getRegularSubscription = {} as jest.Mocked<GetRegularSubscriptionForUser>
+    getRegularSubscription.execute = jest.fn().mockReturnValue(
+      Result.ok({
+        uuid: '00000000-0000-0000-0000-000000000000',
+      } as jest.Mocked<UserSubscription>),
+    )
+
+    getSharedSubscriptionForUser = {} as jest.Mocked<GetSharedSubscriptionForUser>
+    getSharedSubscriptionForUser.execute = jest.fn().mockReturnValue(
+      Result.ok({
+        uuid: '00000000-0000-0000-0000-000000000000',
+      } as jest.Mocked<UserSubscription>),
+    )
+
+    setSubscriptonSettingValue = {} as jest.Mocked<SetSubscriptionSettingValue>
+    setSubscriptonSettingValue.execute = jest.fn().mockReturnValue(Result.ok(Result.ok()))
+
+    listSharedSubscriptionInvitations = {} as jest.Mocked<ListSharedSubscriptionInvitations>
+    listSharedSubscriptionInvitations.execute = jest.fn().mockReturnValue({
+      invitations: [
+        {
+          uuid: '00000000-0000-0000-0000-000000000000',
+          status: InvitationStatus.Accepted,
+          inviteeIdentifier: 'test2@test.te',
+        } as jest.Mocked<SharedSubscriptionInvitation>,
+      ],
+    })
+
+    domainEventFactory = {} as jest.Mocked<DomainEventFactoryInterface>
+    domainEventFactory.createFileQuotaRecalculationRequestedEvent = jest
+      .fn()
+      .mockReturnValue({} as jest.Mocked<FileQuotaRecalculationRequestedEvent>)
+
+    domainEventPublisher = {} as jest.Mocked<DomainEventPublisherInterface>
+    domainEventPublisher.publish = jest.fn()
+
+    logger = {} as jest.Mocked<Logger>
+    logger.info = jest.fn()
+  })
+
+  it('should return error result if user cannot be found', async () => {
+    userRepository.findOneByUsernameOrEmail = jest.fn().mockReturnValue(null)
+
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      userEmail: 'test@test.te',
+    })
+
+    expect(result.isFailed()).toBeTruthy()
+  })
+
+  it('should return error result if regular subscription cannot be found', async () => {
+    getRegularSubscription.execute = jest.fn().mockReturnValue(Result.fail('test'))
+
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      userEmail: 'test@test.te',
+    })
+
+    expect(result.isFailed()).toBeTruthy()
+  })
+
+  it('should return error result if shared subscription cannot be found', async () => {
+    getSharedSubscriptionForUser.execute = jest.fn().mockReturnValue(Result.fail('test'))
+
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      userEmail: 'test@test.te',
+    })
+
+    expect(result.isFailed()).toBeTruthy()
+  })
+
+  it('should return error result if setting value cannot be set', async () => {
+    setSubscriptonSettingValue.execute = jest.fn().mockReturnValue(Result.fail('test'))
+
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      userEmail: 'test@test.te',
+    })
+
+    expect(result.isFailed()).toBeTruthy()
+  })
+
+  it('should reset storage quota and ask for recalculation for user and all its shared subscriptions', async () => {
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      userEmail: 'test@test.te',
+    })
+
+    expect(result.isFailed()).toBeFalsy()
+    expect(domainEventPublisher.publish).toHaveBeenCalledTimes(2)
+  })
+
+  it('should return error if the username is invalid', async () => {
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      userEmail: '',
+    })
+
+    expect(result.isFailed()).toBeTruthy()
+  })
+
+  it('should return error if the invitee username is invalid', async () => {
+    listSharedSubscriptionInvitations.execute = jest.fn().mockReturnValue({
+      invitations: [
+        {
+          uuid: '00000000-0000-0000-0000-000000000000',
+          status: InvitationStatus.Accepted,
+          inviteeIdentifier: '',
+        } as jest.Mocked<SharedSubscriptionInvitation>,
+      ],
+    })
+
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      userEmail: 'test@test.te',
+    })
+
+    expect(result.isFailed()).toBeTruthy()
+  })
+
+  it('should return error if the invitee cannot be found', async () => {
+    userRepository.findOneByUsernameOrEmail = jest
+      .fn()
+      .mockReturnValueOnce({
+        uuid: '00000000-0000-0000-0000-000000000000',
+      } as jest.Mocked<User>)
+      .mockReturnValueOnce(null)
+
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      userEmail: 'test@test.te',
+    })
+
+    expect(result.isFailed()).toBeTruthy()
+  })
+
+  it('should return error if fails to reset storage quota for the invitee', async () => {
+    setSubscriptonSettingValue.execute = jest
+      .fn()
+      .mockReturnValueOnce(Result.ok())
+      .mockReturnValueOnce(Result.fail('test'))
+
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      userEmail: 'test@test.te',
+    })
+
+    expect(result.isFailed()).toBeTruthy()
+  })
+})

+ 121 - 0
packages/auth/src/Domain/UseCase/FixStorageQuotaForUser/FixStorageQuotaForUser.ts

@@ -0,0 +1,121 @@
+import { Result, SettingName, UseCaseInterface, Username } from '@standardnotes/domain-core'
+
+import { FixStorageQuotaForUserDTO } from './FixStorageQuotaForUserDTO'
+import { GetRegularSubscriptionForUser } from '../GetRegularSubscriptionForUser/GetRegularSubscriptionForUser'
+import { SetSubscriptionSettingValue } from '../SetSubscriptionSettingValue/SetSubscriptionSettingValue'
+import { ListSharedSubscriptionInvitations } from '../ListSharedSubscriptionInvitations/ListSharedSubscriptionInvitations'
+import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
+import { InvitationStatus } from '../../SharedSubscription/InvitationStatus'
+import { GetSharedSubscriptionForUser } from '../GetSharedSubscriptionForUser/GetSharedSubscriptionForUser'
+import { DomainEventFactoryInterface } from '../../Event/DomainEventFactoryInterface'
+import { DomainEventPublisherInterface } from '@standardnotes/domain-events'
+import { Logger } from 'winston'
+
+export class FixStorageQuotaForUser implements UseCaseInterface<void> {
+  constructor(
+    private userRepository: UserRepositoryInterface,
+    private getRegularSubscription: GetRegularSubscriptionForUser,
+    private getSharedSubscriptionForUser: GetSharedSubscriptionForUser,
+    private setSubscriptonSettingValue: SetSubscriptionSettingValue,
+    private listSharedSubscriptionInvitations: ListSharedSubscriptionInvitations,
+    private domainEventFactory: DomainEventFactoryInterface,
+    private domainEventPublisher: DomainEventPublisherInterface,
+    private logger: Logger,
+  ) {}
+
+  async execute(dto: FixStorageQuotaForUserDTO): Promise<Result<void>> {
+    const usernameOrError = Username.create(dto.userEmail)
+    if (usernameOrError.isFailed()) {
+      return Result.fail(usernameOrError.getError())
+    }
+    const username = usernameOrError.getValue()
+
+    const user = await this.userRepository.findOneByUsernameOrEmail(username)
+    if (user === null) {
+      return Result.fail(`Could not find user with email: ${username.value}`)
+    }
+
+    const regularSubscriptionOrError = await this.getRegularSubscription.execute({
+      userUuid: user.uuid,
+    })
+    if (regularSubscriptionOrError.isFailed()) {
+      return Result.fail(`Could not find regular user subscription for user with uuid: ${user.uuid}`)
+    }
+    const regularSubscription = regularSubscriptionOrError.getValue()
+
+    const result = await this.setSubscriptonSettingValue.execute({
+      userSubscriptionUuid: regularSubscription.uuid,
+      settingName: SettingName.NAMES.FileUploadBytesUsed,
+      value: '0',
+    })
+    if (result.isFailed()) {
+      return Result.fail(result.getError())
+    }
+
+    this.logger.info('Resetted storage quota for user', {
+      userId: user.uuid,
+    })
+
+    await this.domainEventPublisher.publish(
+      this.domainEventFactory.createFileQuotaRecalculationRequestedEvent({
+        userUuid: user.uuid,
+      }),
+    )
+
+    this.logger.info('Requested storage quota recalculation for user', {
+      userId: user.uuid,
+    })
+
+    const invitationsResult = await this.listSharedSubscriptionInvitations.execute({
+      inviterEmail: user.email,
+    })
+    const acceptedInvitations = invitationsResult.invitations.filter(
+      (invitation) => invitation.status === InvitationStatus.Accepted,
+    )
+    for (const invitation of acceptedInvitations) {
+      const inviteeUsernameOrError = Username.create(invitation.inviteeIdentifier)
+      if (inviteeUsernameOrError.isFailed()) {
+        return Result.fail(inviteeUsernameOrError.getError())
+      }
+      const inviteeUsername = inviteeUsernameOrError.getValue()
+
+      const invitee = await this.userRepository.findOneByUsernameOrEmail(inviteeUsername)
+      if (invitee === null) {
+        return Result.fail(`Could not find user with email: ${inviteeUsername.value}`)
+      }
+
+      const invitationSubscriptionOrError = await this.getSharedSubscriptionForUser.execute({
+        userUuid: invitee.uuid,
+      })
+      if (invitationSubscriptionOrError.isFailed()) {
+        return Result.fail(`Could not find shared subscription for user with email: ${invitation.inviteeIdentifier}`)
+      }
+      const invitationSubscription = invitationSubscriptionOrError.getValue()
+
+      const result = await this.setSubscriptonSettingValue.execute({
+        userSubscriptionUuid: invitationSubscription.uuid,
+        settingName: SettingName.NAMES.FileUploadBytesUsed,
+        value: '0',
+      })
+      if (result.isFailed()) {
+        return Result.fail(result.getError())
+      }
+
+      this.logger.info('Resetted storage quota for user', {
+        userId: invitee.uuid,
+      })
+
+      await this.domainEventPublisher.publish(
+        this.domainEventFactory.createFileQuotaRecalculationRequestedEvent({
+          userUuid: invitee.uuid,
+        }),
+      )
+
+      this.logger.info('Requested storage quota recalculation for user', {
+        userId: invitee.uuid,
+      })
+    }
+
+    return Result.ok()
+  }
+}

+ 3 - 0
packages/auth/src/Domain/UseCase/FixStorageQuotaForUser/FixStorageQuotaForUserDTO.ts

@@ -0,0 +1,3 @@
+export interface FixStorageQuotaForUserDTO {
+  userEmail: string
+}

+ 14 - 0
packages/auth/src/Domain/UseCase/UpdateStorageQuotaUsedForUser/UpdateStorageQuotaUsedForUser.spec.ts

@@ -163,6 +163,20 @@ describe('UpdateStorageQuotaUsedForUser', () => {
       })
     })
 
+    it('should not subtract below 0', async () => {
+      const result = await createUseCase().execute({
+        userUuid: '00000000-0000-0000-0000-000000000000',
+        bytesUsed: -1234,
+      })
+      expect(result.isFailed()).toBeFalsy()
+
+      expect(setSubscriptonSettingValue.execute).toHaveBeenCalledWith({
+        settingName: 'FILE_UPLOAD_BYTES_USED',
+        value: '0',
+        userSubscriptionUuid: '00000000-0000-0000-0000-000000000000',
+      })
+    })
+
     it('should update a bytes used setting on both regular and shared subscription', async () => {
       const result = await createUseCase().execute({
         userUuid: '00000000-0000-0000-0000-000000000000',

+ 4 - 1
packages/auth/src/Domain/UseCase/UpdateStorageQuotaUsedForUser/UpdateStorageQuotaUsedForUser.ts

@@ -68,10 +68,13 @@ export class UpdateStorageQuotaUsedForUser implements UseCaseInterface<void> {
       bytesAlreadyUsed = bytesUsedSetting.setting.props.value as string
     }
 
+    const bytesUsedNewTotal = +bytesAlreadyUsed + bytesUsed
+    const bytesUsedValue = bytesUsedNewTotal < 0 ? 0 : bytesUsedNewTotal
+
     const result = await this.setSubscriptonSettingValue.execute({
       userSubscriptionUuid: subscription.uuid,
       settingName: SettingName.NAMES.FileUploadBytesUsed,
-      value: (+bytesAlreadyUsed + bytesUsed).toString(),
+      value: bytesUsedValue.toString(),
     })
 
     /* istanbul ignore next */

+ 7 - 0
packages/domain-events/src/Domain/Event/FileQuotaRecalculatedEvent.ts

@@ -0,0 +1,7 @@
+import { DomainEventInterface } from './DomainEventInterface'
+import { FileQuotaRecalculatedEventPayload } from './FileQuotaRecalculatedEventPayload'
+
+export interface FileQuotaRecalculatedEvent extends DomainEventInterface {
+  type: 'FILE_QUOTA_RECALCULATED'
+  payload: FileQuotaRecalculatedEventPayload
+}

+ 4 - 0
packages/domain-events/src/Domain/Event/FileQuotaRecalculatedEventPayload.ts

@@ -0,0 +1,4 @@
+export interface FileQuotaRecalculatedEventPayload {
+  userUuid: string
+  totalFileByteSize: number
+}

+ 7 - 0
packages/domain-events/src/Domain/Event/FileQuotaRecalculationRequestedEvent.ts

@@ -0,0 +1,7 @@
+import { DomainEventInterface } from './DomainEventInterface'
+import { FileQuotaRecalculationRequestedEventPayload } from './FileQuotaRecalculationRequestedEventPayload'
+
+export interface FileQuotaRecalculationRequestedEvent extends DomainEventInterface {
+  type: 'FILE_QUOTA_RECALCULATION_REQUESTED'
+  payload: FileQuotaRecalculationRequestedEventPayload
+}

+ 3 - 0
packages/domain-events/src/Domain/Event/FileQuotaRecalculationRequestedEventPayload.ts

@@ -0,0 +1,3 @@
+export interface FileQuotaRecalculationRequestedEventPayload {
+  userUuid: string
+}

+ 4 - 0
packages/domain-events/src/Domain/index.ts

@@ -30,6 +30,10 @@ export * from './Event/ExitDiscountWithdrawRequestedEvent'
 export * from './Event/ExitDiscountWithdrawRequestedEventPayload'
 export * from './Event/ExtensionKeyGrantedEvent'
 export * from './Event/ExtensionKeyGrantedEventPayload'
+export * from './Event/FileQuotaRecalculatedEvent'
+export * from './Event/FileQuotaRecalculatedEventPayload'
+export * from './Event/FileQuotaRecalculationRequestedEvent'
+export * from './Event/FileQuotaRecalculationRequestedEventPayload'
 export * from './Event/FileRemovedEvent'
 export * from './Event/FileRemovedEventPayload'
 export * from './Event/FileUploadedEvent'

+ 0 - 2
packages/files/docker/entrypoint.sh

@@ -5,12 +5,10 @@ COMMAND=$1 && shift 1
 
 case "$COMMAND" in
   'start-web' )
-    echo "Starting Web..."
     exec node docker/entrypoint-server.js
     ;;
 
   'start-worker' )
-    echo "Starting Worker..."
     exec node docker/entrypoint-worker.js
     ;;
 

+ 25 - 0
packages/files/src/Bootstrap/Container.ts

@@ -52,6 +52,8 @@ import { S3FileMover } from '../Infra/S3/S3FileMover'
 import { FSFileMover } from '../Infra/FS/FSFileMover'
 import { MoveFile } from '../Domain/UseCase/MoveFile/MoveFile'
 import { SharedVaultValetTokenAuthMiddleware } from '../Infra/InversifyExpress/Middleware/SharedVaultValetTokenAuthMiddleware'
+import { RecalculateQuota } from '../Domain/UseCase/RecalculateQuota/RecalculateQuota'
+import { FileQuotaRecalculationRequestedEventHandler } from '../Domain/Handler/FileQuotaRecalculationRequestedEventHandler'
 
 export class ContainerConfigLoader {
   constructor(private mode: 'server' | 'worker' = 'server') {}
@@ -244,6 +246,15 @@ export class ContainerConfigLoader {
         ),
       )
     container.bind<MarkFilesToBeRemoved>(TYPES.Files_MarkFilesToBeRemoved).to(MarkFilesToBeRemoved)
+    container
+      .bind<RecalculateQuota>(TYPES.Files_RecalculateQuota)
+      .toConstantValue(
+        new RecalculateQuota(
+          container.get<FileDownloaderInterface>(TYPES.Files_FileDownloader),
+          container.get<DomainEventPublisherInterface>(TYPES.Files_DomainEventPublisher),
+          container.get<DomainEventFactoryInterface>(TYPES.Files_DomainEventFactory),
+        ),
+      )
 
     // middleware
     container.bind<ValetTokenAuthMiddleware>(TYPES.Files_ValetTokenAuthMiddleware).to(ValetTokenAuthMiddleware)
@@ -274,6 +285,14 @@ export class ContainerConfigLoader {
           container.get<winston.Logger>(TYPES.Files_Logger),
         ),
       )
+    container
+      .bind<FileQuotaRecalculationRequestedEventHandler>(TYPES.Files_FileQuotaRecalculationRequestedEventHandler)
+      .toConstantValue(
+        new FileQuotaRecalculationRequestedEventHandler(
+          container.get<RecalculateQuota>(TYPES.Files_RecalculateQuota),
+          container.get<winston.Logger>(TYPES.Files_Logger),
+        ),
+      )
 
     const eventHandlers: Map<string, DomainEventHandlerInterface> = new Map([
       ['ACCOUNT_DELETION_REQUESTED', container.get(TYPES.Files_AccountDeletionRequestedEventHandler)],
@@ -281,6 +300,12 @@ export class ContainerConfigLoader {
         'SHARED_SUBSCRIPTION_INVITATION_CANCELED',
         container.get(TYPES.Files_SharedSubscriptionInvitationCanceledEventHandler),
       ],
+      [
+        'FILE_QUOTA_RECALCULATION_REQUESTED',
+        container.get<FileQuotaRecalculationRequestedEventHandler>(
+          TYPES.Files_FileQuotaRecalculationRequestedEventHandler,
+        ),
+      ],
     ])
 
     if (isConfiguredForHomeServer) {

+ 2 - 0
packages/files/src/Bootstrap/Types.ts

@@ -15,6 +15,7 @@ const TYPES = {
   Files_RemoveFile: Symbol.for('Files_RemoveFile'),
   Files_MoveFile: Symbol.for('Files_MoveFile'),
   Files_MarkFilesToBeRemoved: Symbol.for('Files_MarkFilesToBeRemoved'),
+  Files_RecalculateQuota: Symbol.for('Files_RecalculateQuota'),
 
   // services
   Files_ValetTokenDecoder: Symbol.for('Files_ValetTokenDecoder'),
@@ -57,6 +58,7 @@ const TYPES = {
   Files_SharedSubscriptionInvitationCanceledEventHandler: Symbol.for(
     'Files_SharedSubscriptionInvitationCanceledEventHandler',
   ),
+  Files_FileQuotaRecalculationRequestedEventHandler: Symbol.for('Files_FileQuotaRecalculationRequestedEventHandler'),
 }
 
 export default TYPES

+ 19 - 0
packages/files/src/Domain/Event/DomainEventFactory.ts

@@ -5,6 +5,7 @@ import {
   SharedVaultFileUploadedEvent,
   SharedVaultFileRemovedEvent,
   SharedVaultFileMovedEvent,
+  FileQuotaRecalculatedEvent,
 } from '@standardnotes/domain-events'
 import { TimerInterface } from '@standardnotes/time'
 
@@ -13,6 +14,24 @@ import { DomainEventFactoryInterface } from './DomainEventFactoryInterface'
 export class DomainEventFactory implements DomainEventFactoryInterface {
   constructor(private timer: TimerInterface) {}
 
+  createFileQuotaRecalculatedEvent(payload: {
+    userUuid: string
+    totalFileByteSize: number
+  }): FileQuotaRecalculatedEvent {
+    return {
+      type: 'FILE_QUOTA_RECALCULATED',
+      createdAt: this.timer.getUTCDate(),
+      meta: {
+        correlation: {
+          userIdentifier: payload.userUuid,
+          userIdentifierType: 'uuid',
+        },
+        origin: DomainEventService.Files,
+      },
+      payload,
+    }
+  }
+
   createFileRemovedEvent(payload: {
     userUuid: string
     filePath: string

+ 2 - 0
packages/files/src/Domain/Event/DomainEventFactoryInterface.ts

@@ -4,9 +4,11 @@ import {
   SharedVaultFileRemovedEvent,
   SharedVaultFileUploadedEvent,
   SharedVaultFileMovedEvent,
+  FileQuotaRecalculatedEvent,
 } from '@standardnotes/domain-events'
 
 export interface DomainEventFactoryInterface {
+  createFileQuotaRecalculatedEvent(payload: { userUuid: string; totalFileByteSize: number }): FileQuotaRecalculatedEvent
   createFileUploadedEvent(payload: {
     userUuid: string
     filePath: string

+ 33 - 0
packages/files/src/Domain/Handler/FileQuotaRecalculationRequestedEventHandler.ts

@@ -0,0 +1,33 @@
+import { DomainEventHandlerInterface, FileQuotaRecalculationRequestedEvent } from '@standardnotes/domain-events'
+import { Logger } from 'winston'
+
+import { RecalculateQuota } from '../UseCase/RecalculateQuota/RecalculateQuota'
+
+export class FileQuotaRecalculationRequestedEventHandler implements DomainEventHandlerInterface {
+  constructor(
+    private recalculateQuota: RecalculateQuota,
+    private logger: Logger,
+  ) {}
+
+  async handle(event: FileQuotaRecalculationRequestedEvent): Promise<void> {
+    this.logger.info('Recalculating quota for user...', {
+      userId: event.payload.userUuid,
+    })
+
+    const result = await this.recalculateQuota.execute({
+      userUuid: event.payload.userUuid,
+    })
+
+    if (result.isFailed()) {
+      this.logger.error('Could not recalculate quota', {
+        userId: event.payload.userUuid,
+      })
+
+      return
+    }
+
+    this.logger.info('Quota recalculated', {
+      userId: event.payload.userUuid,
+    })
+  }
+}

+ 1 - 0
packages/files/src/Domain/Services/FileDownloaderInterface.ts

@@ -3,4 +3,5 @@ import { Readable } from 'stream'
 export interface FileDownloaderInterface {
   createDownloadStream(filePath: string, startRange: number, endRange: number): Promise<Readable>
   getFileSize(filePath: string): Promise<number>
+  listFiles(userUuid: string): Promise<{ name: string; size: number }[]>
 }

+ 55 - 0
packages/files/src/Domain/UseCase/RecalculateQuota/RecalculateQuota.spec.ts

@@ -0,0 +1,55 @@
+import { DomainEventPublisherInterface, FileQuotaRecalculatedEvent } from '@standardnotes/domain-events'
+import { FileDownloaderInterface } from '../../Services/FileDownloaderInterface'
+import { RecalculateQuota } from './RecalculateQuota'
+import { DomainEventFactoryInterface } from '../../Event/DomainEventFactoryInterface'
+
+describe('RecalculateQuota', () => {
+  let fileDownloader: FileDownloaderInterface
+  let domainEventPublisher: DomainEventPublisherInterface
+  let domainEventFactory: DomainEventFactoryInterface
+
+  const createUseCase = () => new RecalculateQuota(fileDownloader, domainEventPublisher, domainEventFactory)
+
+  beforeEach(() => {
+    fileDownloader = {} as jest.Mocked<FileDownloaderInterface>
+    fileDownloader.listFiles = jest.fn().mockResolvedValue([
+      {
+        name: 'test-file',
+        size: 123,
+      },
+    ])
+
+    domainEventPublisher = {} as jest.Mocked<DomainEventPublisherInterface>
+    domainEventPublisher.publish = jest.fn()
+
+    domainEventFactory = {} as jest.Mocked<DomainEventFactoryInterface>
+    domainEventFactory.createFileQuotaRecalculatedEvent = jest
+      .fn()
+      .mockReturnValue({} as jest.Mocked<FileQuotaRecalculatedEvent>)
+  })
+
+  it('publishes a file quota recalculated event', async () => {
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      userUuid: '00000000-0000-0000-0000-000000000000',
+    })
+
+    expect(result.isFailed()).toBeFalsy()
+    expect(domainEventFactory.createFileQuotaRecalculatedEvent).toHaveBeenCalledWith({
+      userUuid: '00000000-0000-0000-0000-000000000000',
+      totalFileByteSize: 123,
+    })
+    expect(domainEventPublisher.publish).toHaveBeenCalled()
+  })
+
+  it('returns a failure result if user uuid is invalid', async () => {
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      userUuid: 'invalid-user-uuid',
+    })
+
+    expect(result.isFailed()).toBeTruthy()
+  })
+})

+ 37 - 0
packages/files/src/Domain/UseCase/RecalculateQuota/RecalculateQuota.ts

@@ -0,0 +1,37 @@
+import { Result, UseCaseInterface, Uuid } from '@standardnotes/domain-core'
+import { DomainEventPublisherInterface } from '@standardnotes/domain-events'
+
+import { RecalculateQuotaDTO } from './RecalculateQuotaDTO'
+import { DomainEventFactoryInterface } from '../../Event/DomainEventFactoryInterface'
+import { FileDownloaderInterface } from '../../Services/FileDownloaderInterface'
+
+export class RecalculateQuota implements UseCaseInterface<void> {
+  constructor(
+    private fileDownloader: FileDownloaderInterface,
+    private domainEventPublisher: DomainEventPublisherInterface,
+    private domainEventFactory: DomainEventFactoryInterface,
+  ) {}
+
+  async execute(dto: RecalculateQuotaDTO): Promise<Result<void>> {
+    const userUuidOrError = Uuid.create(dto.userUuid)
+    if (userUuidOrError.isFailed()) {
+      return Result.fail(userUuidOrError.getError())
+    }
+    const userUuid = userUuidOrError.getValue()
+
+    const filesList = await this.fileDownloader.listFiles(userUuid.value)
+    let totalFileByteSize = 0
+    for (const file of filesList) {
+      totalFileByteSize += file.size
+    }
+
+    const event = this.domainEventFactory.createFileQuotaRecalculatedEvent({
+      userUuid: dto.userUuid,
+      totalFileByteSize,
+    })
+
+    await this.domainEventPublisher.publish(event)
+
+    return Result.ok()
+  }
+}

+ 3 - 0
packages/files/src/Domain/UseCase/RecalculateQuota/RecalculateQuotaDTO.ts

@@ -0,0 +1,3 @@
+export interface RecalculateQuotaDTO {
+  userUuid: string
+}

+ 15 - 0
packages/files/src/Infra/FS/FSFileDownloader.ts

@@ -9,6 +9,21 @@ import TYPES from '../../Bootstrap/Types'
 export class FSFileDownloader implements FileDownloaderInterface {
   constructor(@inject(TYPES.Files_FILE_UPLOAD_PATH) private fileUploadPath: string) {}
 
+  async listFiles(userUuid: string): Promise<{ name: string; size: number }[]> {
+    const filesList = []
+
+    const files = await promises.readdir(`${this.fileUploadPath}/${userUuid}`)
+    for (const file of files) {
+      const fileStat = await promises.stat(`${this.fileUploadPath}/${userUuid}/${file}`)
+      filesList.push({
+        name: file,
+        size: fileStat.size,
+      })
+    }
+
+    return filesList
+  }
+
   async getFileSize(filePath: string): Promise<number> {
     return (await promises.stat(`${this.fileUploadPath}/${filePath}`)).size
   }

+ 22 - 1
packages/files/src/Infra/S3/S3FileDownloader.ts

@@ -1,4 +1,4 @@
-import { GetObjectCommand, HeadObjectCommand, S3Client } from '@aws-sdk/client-s3'
+import { GetObjectCommand, HeadObjectCommand, ListObjectsV2Command, S3Client } from '@aws-sdk/client-s3'
 import { inject, injectable } from 'inversify'
 import { Readable } from 'stream'
 
@@ -34,4 +34,25 @@ export class S3FileDownloader implements FileDownloaderInterface {
 
     return head.ContentLength as number
   }
+
+  async listFiles(userUuid: string): Promise<{ name: string; size: number }[]> {
+    const objectsList = await this.s3Client.send(
+      new ListObjectsV2Command({
+        Bucket: `${this.s3BuckeName}/${userUuid}/`,
+      }),
+    )
+
+    const filesList = []
+    for (const object of objectsList.Contents ?? []) {
+      if (!object.Key) {
+        continue
+      }
+      filesList.push({
+        name: object.Key,
+        size: object.Size ?? 0,
+      })
+    }
+
+    return filesList
+  }
 }