Compare commits

...

10 Commits

Author SHA1 Message Date
standardci
4ff78452f9 chore(release): publish new version
- @standardnotes/auth-server@1.167.2
 - @standardnotes/home-server@1.18.28
 - @standardnotes/syncing-server@1.120.4
2023-11-08 15:38:47 +00:00
Karol Sójko
9465f2ecd8 fix: add logs about sending websocket events to clients 2023-11-08 16:10:44 +01:00
standardci
93c2f1f12f chore(release): publish new version
- @standardnotes/auth-server@1.167.1
 - @standardnotes/home-server@1.18.27
2023-11-08 12:15:35 +00:00
Karol Sójko
ca8a3fc77d fix(auth): path to delete accounts script 2023-11-08 12:30:12 +01:00
standardci
00936e06bc chore(release): publish new version
- @standardnotes/auth-server@1.167.0
 - @standardnotes/home-server@1.18.26
2023-11-08 10:48:51 +00:00
Karol Sójko
a6dea50d74 feat: script to mass delete accounts from CSV source (#913) 2023-11-08 11:21:00 +01:00
standardci
28b04e6a4a chore(release): publish new version
- @standardnotes/auth-server@1.166.0
 - @standardnotes/home-server@1.18.25
2023-11-07 14:49:54 +00:00
Karol Sójko
d228a86f48 feat(auth): add triggering post setting update actions (#905)
* feat(auth): add triggering post setting update actions

* feat(auth): refactor email backups

* fix: add extra logs for backups

* fix: specs
2023-11-07 15:14:51 +01:00
standardci
0cb234aa47 chore(release): publish new version
- @standardnotes/api-gateway@1.81.11
 - @standardnotes/home-server@1.18.24
2023-11-07 11:28:36 +00:00
Karol Sójko
6b554c28b7 fix(api-gateway): remove calling both auth and payments on account deletion request 2023-11-07 12:02:40 +01:00
110 changed files with 2739 additions and 440 deletions

904
.pnp.cjs generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -3,6 +3,12 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.81.11](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.81.10...@standardnotes/api-gateway@1.81.11) (2023-11-07)
### Bug Fixes
* **api-gateway:** remove calling both auth and payments on account deletion request ([6b554c2](https://github.com/standardnotes/api-gateway/commit/6b554c28b731a9080d7ad2942d3fa05c01dcabf2))
## [1.81.10](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.81.9...@standardnotes/api-gateway@1.81.10) (2023-11-07)
**Note:** Version bump only for package @standardnotes/api-gateway

View File

@@ -1,6 +1,6 @@
{
"name": "@standardnotes/api-gateway",
"version": "1.81.10",
"version": "1.81.11",
"engines": {
"node": ">=18.0.0 <21.0.0"
},

View File

@@ -23,7 +23,6 @@ export class UsersController extends BaseHttpController {
@inject(TYPES.ApiGateway_ServiceProxy) private httpService: ServiceProxyInterface,
@inject(TYPES.ApiGateway_EndpointResolver) private endpointResolver: EndpointResolverInterface,
@inject(TYPES.ApiGateway_Logger) private logger: Logger,
@inject(TYPES.ApiGateway_IS_CONFIGURED_FOR_HOME_SERVER) private isConfiguredForHomeServer: boolean,
) {
super()
}
@@ -238,10 +237,6 @@ export class UsersController extends BaseHttpController {
@httpDelete('/:userUuid', TYPES.ApiGateway_RequiredCrossServiceTokenMiddleware)
async deleteUser(request: Request, response: Response): Promise<void> {
if (!this.isConfiguredForHomeServer) {
await this.httpService.callPaymentsServer(request, response, 'api/account', request.body, true)
}
await this.httpService.callAuthServer(
request,
response,

View File

@@ -151,7 +151,6 @@ export class HttpServiceProxy implements ServiceProxyInterface {
response: Response,
endpointOrMethodIdentifier: string,
payload?: Record<string, unknown> | string,
returnRawResponse?: boolean,
): Promise<void | Response<unknown, Record<string, unknown>>> {
if (!this.paymentsServerUrl) {
this.logger.debug('Payments Server URL not defined. Skipped request to Payments API.')
@@ -159,18 +158,13 @@ export class HttpServiceProxy implements ServiceProxyInterface {
return
}
const rawResponse = await this.callServerWithLegacyFormat(
await this.callServerWithLegacyFormat(
this.paymentsServerUrl,
request,
response,
endpointOrMethodIdentifier,
payload,
returnRawResponse,
)
if (returnRawResponse) {
return rawResponse
}
}
async callAuthServerWithLegacyFormat(
@@ -345,7 +339,6 @@ export class HttpServiceProxy implements ServiceProxyInterface {
response: Response,
endpointOrMethodIdentifier: string,
payload?: Record<string, unknown> | string,
returnRawResponse?: boolean,
): Promise<void | Response<unknown, Record<string, unknown>>> {
const serviceResponse = await this.getServerResponse(
serverUrl,
@@ -364,18 +357,10 @@ export class HttpServiceProxy implements ServiceProxyInterface {
if (serviceResponse.request._redirectable._redirectCount > 0) {
response.status(302)
if (returnRawResponse) {
return response
}
response.redirect(serviceResponse.request.res.responseUrl)
} else {
response.status(serviceResponse.status)
if (returnRawResponse) {
return response
}
response.send(serviceResponse.data)
}
}

View File

@@ -42,7 +42,6 @@ export interface ServiceProxyInterface {
response: Response,
endpointOrMethodIdentifier: string,
payload?: Record<string, unknown> | string,
returnRawResponse?: boolean,
): Promise<void | Response<unknown, Record<string, unknown>>>
callWebSocketServer(
request: Request,

View File

@@ -3,6 +3,30 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.167.2](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.167.1...@standardnotes/auth-server@1.167.2) (2023-11-08)
### Bug Fixes
* add logs about sending websocket events to clients ([9465f2e](https://github.com/standardnotes/server/commit/9465f2ecd8e8f0bf3ebeeb3976227b1b105aded0))
## [1.167.1](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.167.0...@standardnotes/auth-server@1.167.1) (2023-11-08)
### Bug Fixes
* **auth:** path to delete accounts script ([ca8a3fc](https://github.com/standardnotes/server/commit/ca8a3fc77d91410f0dee8c3ddef29c09947c9cf5))
# [1.167.0](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.166.0...@standardnotes/auth-server@1.167.0) (2023-11-08)
### Features
* script to mass delete accounts from CSV source ([#913](https://github.com/standardnotes/server/issues/913)) ([a6dea50](https://github.com/standardnotes/server/commit/a6dea50d745ff6f051fd9ede168aef27036159c3))
# [1.166.0](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.165.4...@standardnotes/auth-server@1.166.0) (2023-11-07)
### Features
* **auth:** add triggering post setting update actions ([#905](https://github.com/standardnotes/server/issues/905)) ([d228a86](https://github.com/standardnotes/server/commit/d228a86f48c9ff62b7810244c347abf7770e2b9f))
## [1.165.4](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.165.3...@standardnotes/auth-server@1.165.4) (2023-11-07)
### Bug Fixes

View File

@@ -1,9 +1,5 @@
import 'reflect-metadata'
import { SettingName } from '@standardnotes/domain-core'
import { Stream } from 'stream'
import { Logger } from 'winston'
import * as dayjs from 'dayjs'
import * as utc from 'dayjs/plugin/utc'
@@ -11,78 +7,13 @@ import * as utc from 'dayjs/plugin/utc'
import { ContainerConfigLoader } from '../src/Bootstrap/Container'
import TYPES from '../src/Bootstrap/Types'
import { Env } from '../src/Bootstrap/Env'
import { DomainEventPublisherInterface } from '@standardnotes/domain-events'
import { DomainEventFactoryInterface } from '../src/Domain/Event/DomainEventFactoryInterface'
import { SettingRepositoryInterface } from '../src/Domain/Setting/SettingRepositoryInterface'
import { MuteFailedBackupsEmailsOption } from '@standardnotes/settings'
import { RoleServiceInterface } from '../src/Domain/Role/RoleServiceInterface'
import { PermissionName } from '@standardnotes/features'
import { GetUserKeyParams } from '../src/Domain/UseCase/GetUserKeyParams/GetUserKeyParams'
import { TriggerEmailBackupForAllUsers } from '../src/Domain/UseCase/TriggerEmailBackupForAllUsers/TriggerEmailBackupForAllUsers'
const inputArgs = process.argv.slice(2)
const backupProvider = inputArgs[0]
const backupFrequency = inputArgs[1]
const backupFrequency = inputArgs[0]
const requestBackups = async (
settingRepository: SettingRepositoryInterface,
roleService: RoleServiceInterface,
domainEventFactory: DomainEventFactoryInterface,
domainEventPublisher: DomainEventPublisherInterface,
getUserKeyParamsUseCase: GetUserKeyParams,
): Promise<void> => {
const settingName = SettingName.create(SettingName.NAMES.EmailBackupFrequency).getValue()
const permissionName = PermissionName.DailyEmailBackup
const muteEmailsSettingName = SettingName.NAMES.MuteFailedBackupsEmails
const muteEmailsSettingValue = MuteFailedBackupsEmailsOption.Muted
const stream = await settingRepository.streamAllByNameAndValue(settingName, backupFrequency)
return new Promise((resolve, reject) => {
stream
.pipe(
new Stream.Transform({
objectMode: true,
transform: async (setting, _encoding, callback) => {
const userIsPermittedForEmailBackups = await roleService.userHasPermission(
setting.setting_user_uuid,
permissionName,
)
if (!userIsPermittedForEmailBackups) {
callback()
return
}
let userHasEmailsMuted = false
const emailsMutedSetting = await settingRepository.findOneByNameAndUserUuid(
muteEmailsSettingName,
setting.setting_user_uuid,
)
if (emailsMutedSetting !== null && emailsMutedSetting.props.value !== null) {
userHasEmailsMuted = emailsMutedSetting.props.value === muteEmailsSettingValue
}
const keyParamsResponse = await getUserKeyParamsUseCase.execute({
userUuid: setting.setting_user_uuid,
authenticated: false,
})
await domainEventPublisher.publish(
domainEventFactory.createEmailBackupRequestedEvent(
setting.setting_user_uuid,
emailsMutedSetting?.id.toString() as string,
userHasEmailsMuted,
keyParamsResponse.keyParams,
),
)
callback()
},
}),
)
.on('finish', resolve)
.on('error', reject)
})
const requestBackups = async (triggerEmailBackupForAllUsers: TriggerEmailBackupForAllUsers): Promise<void> => {
await triggerEmailBackupForAllUsers.execute({ backupFrequency })
}
const container = new ContainerConfigLoader('worker')
@@ -94,24 +25,20 @@ void container.load().then((container) => {
const logger: Logger = container.get(TYPES.Auth_Logger)
logger.info(`Starting ${backupFrequency} ${backupProvider} backup requesting...`)
logger.info(`Starting ${backupFrequency} email backup requesting...`)
const settingRepository: SettingRepositoryInterface = container.get(TYPES.Auth_SettingRepository)
const roleService: RoleServiceInterface = container.get(TYPES.Auth_RoleService)
const domainEventFactory: DomainEventFactoryInterface = container.get(TYPES.Auth_DomainEventFactory)
const domainEventPublisher: DomainEventPublisherInterface = container.get(TYPES.Auth_DomainEventPublisher)
const getUserKeyParamsUseCase: GetUserKeyParams = container.get(TYPES.Auth_GetUserKeyParams)
Promise.resolve(
requestBackups(settingRepository, roleService, domainEventFactory, domainEventPublisher, getUserKeyParamsUseCase),
const triggerEmailBackupForAllUsers: TriggerEmailBackupForAllUsers = container.get(
TYPES.Auth_TriggerEmailBackupForAllUsers,
)
Promise.resolve(requestBackups(triggerEmailBackupForAllUsers))
.then(() => {
logger.info(`${backupFrequency} ${backupProvider} backup requesting complete`)
logger.info(`${backupFrequency} email backup requesting complete`)
process.exit(0)
})
.catch((error) => {
logger.error(`Could not finish ${backupFrequency} ${backupProvider} backup requesting: ${error.message}`)
logger.error(`Could not finish ${backupFrequency} email backup requesting: ${error.message}`)
process.exit(1)
})

View File

@@ -0,0 +1,43 @@
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 { DeleteAccountsFromCSVFile } from '../src/Domain/UseCase/DeleteAccountsFromCSVFile/DeleteAccountsFromCSVFile'
const inputArgs = process.argv.slice(2)
const fileName = inputArgs[0]
const mode = inputArgs[1]
const deleteAccounts = async (deleteAccountsFromCSVFile: DeleteAccountsFromCSVFile): Promise<void> => {
await deleteAccountsFromCSVFile.execute({
fileName,
dryRun: mode !== 'delete',
})
}
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 mass accounts deletion from CSV file')
const deleteAccountsFromCSVFile = container.get<DeleteAccountsFromCSVFile>(TYPES.Auth_DeleteAccountsFromCSVFile)
Promise.resolve(deleteAccounts(deleteAccountsFromCSVFile))
.then(() => {
logger.info('Accounts deleted.')
process.exit(0)
})
.catch((error) => {
logger.error(`Could not delete accounts: ${error.message}`)
process.exit(1)
})
})

View File

@@ -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/delete_accounts.js')))
Object.defineProperty(exports, '__esModule', { value: true })
exports.default = index

View File

@@ -26,12 +26,12 @@ case "$COMMAND" in
'email-daily-backup' )
echo "[Docker] Starting Email Daily Backup..."
node docker/entrypoint-backup.js email daily
node docker/entrypoint-backup.js daily
;;
'email-weekly-backup' )
echo "[Docker] Starting Email Weekly Backup..."
node docker/entrypoint-backup.js email weekly
node docker/entrypoint-backup.js weekly
;;
'email-backup' )
@@ -40,19 +40,11 @@ case "$COMMAND" in
node docker/entrypoint-user-email-backup.js $EMAIL
;;
'dropbox-daily-backup' )
echo "[Docker] Starting Dropbox Daily Backup..."
node docker/entrypoint-backup.js dropbox daily
;;
'google-drive-daily-backup' )
echo "[Docker] Starting Google Drive Daily Backup..."
node docker/entrypoint-backup.js google_drive daily
;;
'one-drive-daily-backup' )
echo "[Docker] Starting One Drive Daily Backup..."
node docker/entrypoint-backup.js one_drive daily
'delete-accounts' )
echo "[Docker] Starting Accounts Deleting from CSV..."
FILE_NAME=$1 && shift 1
MODE=$1 && shift 1
node docker/entrypoint-delete-accounts.js $FILE_NAME $MODE
;;
* )

View File

@@ -1,6 +1,6 @@
{
"name": "@standardnotes/auth-server",
"version": "1.165.4",
"version": "1.167.2",
"engines": {
"node": ">=18.0.0 <21.0.0"
},
@@ -24,17 +24,15 @@
"worker": "yarn node dist/bin/worker.js",
"cleanup": "yarn node dist/bin/cleanup.js",
"stats": "yarn node dist/bin/stats.js",
"daily-backup:email": "yarn node dist/bin/backup.js email daily",
"daily-backup:email": "yarn node dist/bin/backup.js daily",
"user-email-backup": "yarn node dist/bin/user_email_backup.js",
"daily-backup:dropbox": "yarn node dist/bin/backup.js dropbox daily",
"daily-backup:google_drive": "yarn node dist/bin/backup.js google_drive daily",
"daily-backup:one_drive": "yarn node dist/bin/backup.js one_drive daily",
"weekly-backup:email": "yarn node dist/bin/backup.js email weekly",
"weekly-backup:email": "yarn node dist/bin/backup.js weekly",
"content-recalculation": "yarn node dist/bin/content.js",
"typeorm": "typeorm-ts-node-commonjs",
"migrate": "yarn build && yarn typeorm migration:run -d dist/src/Bootstrap/DataSource.js"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.445.0",
"@aws-sdk/client-sns": "^3.427.0",
"@aws-sdk/client-sqs": "^3.427.0",
"@cbor-extract/cbor-extract-linux-arm64": "^2.1.1",

View File

@@ -2,6 +2,7 @@ import * as winston from 'winston'
import Redis from 'ioredis'
import { SNSClient, SNSClientConfig } from '@aws-sdk/client-sns'
import { SQSClient, SQSClientConfig } from '@aws-sdk/client-sqs'
import { S3Client } from '@aws-sdk/client-s3'
import { Container } from 'inversify'
import {
DomainEventHandlerInterface,
@@ -130,8 +131,6 @@ import { ListedAccountCreatedEventHandler } from '../Domain/Handler/ListedAccoun
import { ListedAccountDeletedEventHandler } from '../Domain/Handler/ListedAccountDeletedEventHandler'
import { FileRemovedEventHandler } from '../Domain/Handler/FileRemovedEventHandler'
import { UserDisabledSessionUserAgentLoggingEventHandler } from '../Domain/Handler/UserDisabledSessionUserAgentLoggingEventHandler'
import { SettingInterpreterInterface } from '../Domain/Setting/SettingInterpreterInterface'
import { SettingInterpreter } from '../Domain/Setting/SettingInterpreter'
import { SettingCrypterInterface } from '../Domain/Setting/SettingCrypterInterface'
import { SettingCrypter } from '../Domain/Setting/SettingCrypter'
import { SharedSubscriptionInvitationRepositoryInterface } from '../Domain/SharedSubscription/SharedSubscriptionInvitationRepositoryInterface'
@@ -275,6 +274,12 @@ import { SubscriptionSettingPersistenceMapper } from '../Mapping/Persistence/Sub
import { ApplyDefaultSettings } from '../Domain/UseCase/ApplyDefaultSettings/ApplyDefaultSettings'
import { AuthResponseFactoryResolverInterface } from '../Domain/Auth/AuthResponseFactoryResolverInterface'
import { UserInvitedToSharedVaultEventHandler } from '../Domain/Handler/UserInvitedToSharedVaultEventHandler'
import { TriggerPostSettingUpdateActions } from '../Domain/UseCase/TriggerPostSettingUpdateActions/TriggerPostSettingUpdateActions'
import { TriggerEmailBackupForUser } from '../Domain/UseCase/TriggerEmailBackupForUser/TriggerEmailBackupForUser'
import { TriggerEmailBackupForAllUsers } from '../Domain/UseCase/TriggerEmailBackupForAllUsers/TriggerEmailBackupForAllUsers'
import { CSVFileReaderInterface } from '../Domain/CSV/CSVFileReaderInterface'
import { S3CsvFileReader } from '../Infra/S3/S3CsvFileReader'
import { DeleteAccountsFromCSVFile } from '../Domain/UseCase/DeleteAccountsFromCSVFile/DeleteAccountsFromCSVFile'
export class ContainerConfigLoader {
constructor(private mode: 'server' | 'worker' = 'server') {}
@@ -369,6 +374,19 @@ export class ContainerConfigLoader {
}
const sqsClient = new SQSClient(sqsConfig)
container.bind<SQSClient>(TYPES.Auth_SQS).toConstantValue(sqsClient)
container.bind<S3Client>(TYPES.Auth_S3).toConstantValue(
new S3Client({
apiVersion: 'latest',
region: env.get('S3_AWS_REGION', true),
}),
)
container
.bind<CSVFileReaderInterface>(TYPES.Auth_CSVFileReader)
.toConstantValue(
new S3CsvFileReader(env.get('S3_AUTH_SCRIPTS_DATA_BUCKET', true), container.get<S3Client>(TYPES.Auth_S3)),
)
}
container.bind(TYPES.Auth_SNS_TOPIC_ARN).toConstantValue(env.get('SNS_TOPIC_ARN', true))
@@ -772,16 +790,6 @@ export class ContainerConfigLoader {
container.get<winston.Logger>(TYPES.Auth_Logger),
),
)
container
.bind<SettingInterpreterInterface>(TYPES.Auth_SettingInterpreter)
.toConstantValue(
new SettingInterpreter(
container.get<DomainEventPublisherInterface>(TYPES.Auth_DomainEventPublisher),
container.get<DomainEventFactoryInterface>(TYPES.Auth_DomainEventFactory),
container.get<SettingRepositoryInterface>(TYPES.Auth_SettingRepository),
container.get<GetUserKeyParams>(TYPES.Auth_GetUserKeyParams),
),
)
container.bind<OfflineSettingServiceInterface>(TYPES.Auth_OfflineSettingService).to(OfflineSettingService)
container.bind<ContentDecoderInterface>(TYPES.Auth_ContenDecoder).toConstantValue(new ContentDecoder())
@@ -1231,6 +1239,46 @@ export class ContainerConfigLoader {
container.get<GetSharedOrRegularSubscriptionForUser>(TYPES.Auth_GetSharedOrRegularSubscriptionForUser),
),
)
container
.bind<TriggerEmailBackupForUser>(TYPES.Auth_TriggerEmailBackupForUser)
.toConstantValue(
new TriggerEmailBackupForUser(
container.get<RoleServiceInterface>(TYPES.Auth_RoleService),
container.get<GetSetting>(TYPES.Auth_GetSetting),
container.get<GetUserKeyParams>(TYPES.Auth_GetUserKeyParams),
container.get<DomainEventPublisherInterface>(TYPES.Auth_DomainEventPublisher),
container.get<DomainEventFactoryInterface>(TYPES.Auth_DomainEventFactory),
),
)
container
.bind<TriggerEmailBackupForAllUsers>(TYPES.Auth_TriggerEmailBackupForAllUsers)
.toConstantValue(
new TriggerEmailBackupForAllUsers(
container.get<SettingRepositoryInterface>(TYPES.Auth_SettingRepository),
container.get<TriggerEmailBackupForUser>(TYPES.Auth_TriggerEmailBackupForUser),
container.get<winston.Logger>(TYPES.Auth_Logger),
),
)
container
.bind<TriggerPostSettingUpdateActions>(TYPES.Auth_TriggerPostSettingUpdateActions)
.toConstantValue(
new TriggerPostSettingUpdateActions(
container.get<DomainEventPublisherInterface>(TYPES.Auth_DomainEventPublisher),
container.get<DomainEventFactoryInterface>(TYPES.Auth_DomainEventFactory),
container.get<TriggerEmailBackupForUser>(TYPES.Auth_TriggerEmailBackupForUser),
),
)
if (!isConfiguredForHomeServer) {
container
.bind<DeleteAccountsFromCSVFile>(TYPES.Auth_DeleteAccountsFromCSVFile)
.toConstantValue(
new DeleteAccountsFromCSVFile(
container.get<CSVFileReaderInterface>(TYPES.Auth_CSVFileReader),
container.get<DeleteAccount>(TYPES.Auth_DeleteAccount),
container.get<winston.Logger>(TYPES.Auth_Logger),
),
)
}
// Controller
container
@@ -1655,11 +1703,13 @@ export class ContainerConfigLoader {
container.get<GetAllSettingsForUser>(TYPES.Auth_GetAllSettingsForUser),
container.get<GetSetting>(TYPES.Auth_GetSetting),
container.get<SetSettingValue>(TYPES.Auth_SetSettingValue),
container.get<TriggerPostSettingUpdateActions>(TYPES.Auth_TriggerPostSettingUpdateActions),
container.get<DeleteSetting>(TYPES.Auth_DeleteSetting),
container.get<MapperInterface<Setting, SettingHttpRepresentation>>(TYPES.Auth_SettingHttpMapper),
container.get<MapperInterface<SubscriptionSetting, SubscriptionSettingHttpRepresentation>>(
TYPES.Auth_SubscriptionSettingHttpMapper,
),
container.get<winston.Logger>(TYPES.Auth_Logger),
container.get<ControllerContainerInterface>(TYPES.Auth_ControllerContainer),
),
)

View File

@@ -3,6 +3,7 @@ const TYPES = {
Auth_Redis: Symbol.for('Auth_Redis'),
Auth_SNS: Symbol.for('Auth_SNS'),
Auth_SQS: Symbol.for('Auth_SQS'),
Auth_S3: Symbol.for('Auth_S3'),
// Mapping
Auth_SessionTracePersistenceMapper: Symbol.for('Auth_SessionTracePersistenceMapper'),
Auth_AuthenticatorChallengePersistenceMapper: Symbol.for('Auth_AuthenticatorChallengePersistenceMapper'),
@@ -164,6 +165,10 @@ const TYPES = {
Auth_DesignateSurvivor: Symbol.for('Auth_DesignateSurvivor'),
Auth_GetSharedOrRegularSubscriptionForUser: Symbol.for('Auth_GetSharedOrRegularSubscriptionForUser'),
Auth_DisableEmailSettingBasedOnEmailSubscription: Symbol.for('Auth_DisableEmailSettingBasedOnEmailSubscription'),
Auth_TriggerPostSettingUpdateActions: Symbol.for('Auth_TriggerPostSettingUpdateActions'),
Auth_TriggerEmailBackupForUser: Symbol.for('Auth_TriggerEmailBackupForUser'),
Auth_TriggerEmailBackupForAllUsers: Symbol.for('Auth_TriggerEmailBackupForAllUsers'),
Auth_DeleteAccountsFromCSVFile: Symbol.for('Auth_DeleteAccountsFromCSVFile'),
// Handlers
Auth_AccountDeletionRequestedEventHandler: Symbol.for('Auth_AccountDeletionRequestedEventHandler'),
Auth_SubscriptionPurchasedEventHandler: Symbol.for('Auth_SubscriptionPurchasedEventHandler'),
@@ -230,7 +235,6 @@ const TYPES = {
Auth_SubscriptionSettingsAssociationService: Symbol.for('Auth_SubscriptionSettingsAssociationService'),
Auth_FeatureService: Symbol.for('Auth_FeatureService'),
Auth_SettingCrypter: Symbol.for('Auth_SettingCrypter'),
Auth_SettingInterpreter: Symbol.for('Auth_SettingInterpreter'),
Auth_ProtocolVersionSelector: Symbol.for('Auth_ProtocolVersionSelector'),
Auth_BooleanSelector: Symbol.for('Auth_BooleanSelector'),
Auth_BaseAuthController: Symbol.for('Auth_BaseAuthController'),
@@ -249,6 +253,7 @@ const TYPES = {
Auth_BaseOfflineController: Symbol.for('Auth_BaseOfflineController'),
Auth_BaseListedController: Symbol.for('Auth_BaseListedController'),
Auth_BaseFeaturesController: Symbol.for('Auth_BaseFeaturesController'),
Auth_CSVFileReader: Symbol.for('Auth_CSVFileReader'),
}
export default TYPES

View File

@@ -0,0 +1,5 @@
import { Result } from '@standardnotes/domain-core'
export interface CSVFileReaderInterface {
getValues(fileName: string): Promise<Result<string[]>>
}

View File

@@ -1,153 +0,0 @@
import {
DomainEventPublisherInterface,
EmailBackupRequestedEvent,
MuteEmailsSettingChangedEvent,
UserDisabledSessionUserAgentLoggingEvent,
} from '@standardnotes/domain-events'
import { EmailBackupFrequency, LogSessionUserAgentOption, MuteMarketingEmailsOption } from '@standardnotes/settings'
import 'reflect-metadata'
import { Logger } from 'winston'
import { DomainEventFactoryInterface } from '../Event/DomainEventFactoryInterface'
import { User } from '../User/User'
import { Setting } from './Setting'
import { SettingCrypterInterface } from './SettingCrypterInterface'
import { SettingInterpreter } from './SettingInterpreter'
import { SettingRepositoryInterface } from './SettingRepositoryInterface'
import { GetUserKeyParams } from '../UseCase/GetUserKeyParams/GetUserKeyParams'
import { KeyParamsData } from '@standardnotes/responses'
import { Uuid, Timestamps, UniqueEntityId, SettingName } from '@standardnotes/domain-core'
describe('SettingInterpreter', () => {
let user: User
let domainEventPublisher: DomainEventPublisherInterface
let domainEventFactory: DomainEventFactoryInterface
let settingRepository: SettingRepositoryInterface
let settingCrypter: SettingCrypterInterface
let logger: Logger
let getUserKeyParams: GetUserKeyParams
const createInterpreter = () =>
new SettingInterpreter(domainEventPublisher, domainEventFactory, settingRepository, getUserKeyParams)
beforeEach(() => {
user = {
uuid: '4-5-6',
email: 'test@test.te',
} as jest.Mocked<User>
settingRepository = {} as jest.Mocked<SettingRepositoryInterface>
settingRepository.findLastByNameAndUserUuid = jest.fn().mockReturnValue(null)
settingRepository.findOneByNameAndUserUuid = jest.fn().mockReturnValue(null)
settingCrypter = {} as jest.Mocked<SettingCrypterInterface>
settingCrypter.decryptSettingValue = jest.fn().mockReturnValue('decrypted')
domainEventPublisher = {} as jest.Mocked<DomainEventPublisherInterface>
domainEventPublisher.publish = jest.fn()
domainEventFactory = {} as jest.Mocked<DomainEventFactoryInterface>
domainEventFactory.createEmailBackupRequestedEvent = jest
.fn()
.mockReturnValue({} as jest.Mocked<EmailBackupRequestedEvent>)
domainEventFactory.createUserDisabledSessionUserAgentLoggingEvent = jest
.fn()
.mockReturnValue({} as jest.Mocked<UserDisabledSessionUserAgentLoggingEvent>)
domainEventFactory.createMuteEmailsSettingChangedEvent = jest
.fn()
.mockReturnValue({} as jest.Mocked<MuteEmailsSettingChangedEvent>)
logger = {} as jest.Mocked<Logger>
logger.debug = jest.fn()
logger.warn = jest.fn()
logger.error = jest.fn()
getUserKeyParams = {} as jest.Mocked<GetUserKeyParams>
getUserKeyParams.execute = jest.fn().mockReturnValue({ keyParams: {} as jest.Mocked<KeyParamsData> })
})
it('should trigger session cleanup if user is disabling session user agent logging', async () => {
await createInterpreter().interpretSettingUpdated(
SettingName.NAMES.LogSessionUserAgent,
user,
LogSessionUserAgentOption.Disabled,
)
expect(domainEventPublisher.publish).toHaveBeenCalled()
expect(domainEventFactory.createUserDisabledSessionUserAgentLoggingEvent).toHaveBeenCalledWith({
userUuid: '4-5-6',
email: 'test@test.te',
})
})
it('should trigger backup if email backup setting is created - emails not muted', async () => {
await createInterpreter().interpretSettingUpdated(
SettingName.NAMES.EmailBackupFrequency,
user,
EmailBackupFrequency.Daily,
)
expect(domainEventPublisher.publish).toHaveBeenCalled()
expect(domainEventFactory.createEmailBackupRequestedEvent).toHaveBeenCalledWith('4-5-6', '', false, {})
})
it('should trigger backup if email backup setting is created - emails muted', async () => {
const setting = Setting.create(
{
name: SettingName.NAMES.MuteFailedBackupsEmails,
value: 'muted',
serverEncryptionVersion: 0,
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
sensitive: false,
timestamps: Timestamps.create(123, 123).getValue(),
},
new UniqueEntityId('7fb54003-1dd2-40bd-8900-2bacd6cf629c'),
).getValue()
settingRepository.findOneByNameAndUserUuid = jest.fn().mockReturnValue(setting)
await createInterpreter().interpretSettingUpdated(
SettingName.NAMES.EmailBackupFrequency,
user,
EmailBackupFrequency.Daily,
)
expect(domainEventPublisher.publish).toHaveBeenCalled()
expect(domainEventFactory.createEmailBackupRequestedEvent).toHaveBeenCalledWith(
'4-5-6',
'7fb54003-1dd2-40bd-8900-2bacd6cf629c',
true,
{},
)
})
it('should not trigger backup if email backup setting is disabled', async () => {
settingRepository.findOneByNameAndUserUuid = jest.fn().mockReturnValue(null)
await createInterpreter().interpretSettingUpdated(
SettingName.NAMES.EmailBackupFrequency,
user,
EmailBackupFrequency.Disabled,
)
expect(domainEventPublisher.publish).not.toHaveBeenCalled()
expect(domainEventFactory.createEmailBackupRequestedEvent).not.toHaveBeenCalled()
})
it('should trigger mute subscription emails rejection if mute setting changed', async () => {
settingRepository.findOneByNameAndUserUuid = jest.fn().mockReturnValue(null)
await createInterpreter().interpretSettingUpdated(
SettingName.NAMES.MuteMarketingEmails,
user,
MuteMarketingEmailsOption.Muted,
)
expect(domainEventPublisher.publish).toHaveBeenCalled()
expect(domainEventFactory.createMuteEmailsSettingChangedEvent).toHaveBeenCalledWith({
emailSubscriptionRejectionLevel: 'MARKETING',
mute: true,
username: 'test@test.te',
})
})
})

View File

@@ -1,113 +0,0 @@
import { DomainEventPublisherInterface } from '@standardnotes/domain-events'
import { EmailLevel, SettingName } from '@standardnotes/domain-core'
import { EmailBackupFrequency, LogSessionUserAgentOption, MuteFailedBackupsEmailsOption } from '@standardnotes/settings'
import { DomainEventFactoryInterface } from '../Event/DomainEventFactoryInterface'
import { User } from '../User/User'
import { SettingInterpreterInterface } from './SettingInterpreterInterface'
import { SettingRepositoryInterface } from './SettingRepositoryInterface'
import { GetUserKeyParams } from '../UseCase/GetUserKeyParams/GetUserKeyParams'
export class SettingInterpreter implements SettingInterpreterInterface {
private readonly emailSettingToSubscriptionRejectionLevelMap: Map<string, string> = new Map([
[SettingName.NAMES.MuteFailedBackupsEmails, EmailLevel.LEVELS.FailedEmailBackup],
[SettingName.NAMES.MuteFailedCloudBackupsEmails, EmailLevel.LEVELS.FailedCloudBackup],
[SettingName.NAMES.MuteMarketingEmails, EmailLevel.LEVELS.Marketing],
[SettingName.NAMES.MuteSignInEmails, EmailLevel.LEVELS.SignIn],
])
constructor(
private domainEventPublisher: DomainEventPublisherInterface,
private domainEventFactory: DomainEventFactoryInterface,
private settingRepository: SettingRepositoryInterface,
private getUserKeyParams: GetUserKeyParams,
) {}
async interpretSettingUpdated(
updatedSettingName: string,
user: User,
unencryptedValue: string | null,
): Promise<void> {
if (this.isChangingMuteEmailsSetting(updatedSettingName)) {
await this.triggerEmailSubscriptionChange(user, updatedSettingName, unencryptedValue)
}
if (this.isEnablingEmailBackupSetting(updatedSettingName, unencryptedValue)) {
await this.triggerEmailBackup(user.uuid)
}
if (this.isDisablingSessionUserAgentLogging(updatedSettingName, unencryptedValue)) {
await this.triggerSessionUserAgentCleanup(user)
}
}
private async triggerEmailBackup(userUuid: string): Promise<void> {
let userHasEmailsMuted = false
let muteEmailsSettingUuid = ''
const muteFailedEmailsBackupSetting = await this.settingRepository.findOneByNameAndUserUuid(
SettingName.NAMES.MuteFailedBackupsEmails,
userUuid,
)
if (muteFailedEmailsBackupSetting !== null) {
userHasEmailsMuted = muteFailedEmailsBackupSetting.props.value === MuteFailedBackupsEmailsOption.Muted
muteEmailsSettingUuid = muteFailedEmailsBackupSetting.id.toString()
}
const keyParamsResponse = await this.getUserKeyParams.execute({
authenticated: false,
userUuid,
})
await this.domainEventPublisher.publish(
this.domainEventFactory.createEmailBackupRequestedEvent(
userUuid,
muteEmailsSettingUuid,
userHasEmailsMuted,
keyParamsResponse.keyParams,
),
)
}
private isChangingMuteEmailsSetting(settingName: string): boolean {
return [
SettingName.NAMES.MuteFailedBackupsEmails,
SettingName.NAMES.MuteFailedCloudBackupsEmails,
SettingName.NAMES.MuteMarketingEmails,
SettingName.NAMES.MuteSignInEmails,
].includes(settingName)
}
private isEnablingEmailBackupSetting(settingName: string, newValue: string | null): boolean {
return (
settingName === SettingName.NAMES.EmailBackupFrequency &&
[EmailBackupFrequency.Daily, EmailBackupFrequency.Weekly].includes(newValue as EmailBackupFrequency)
)
}
private isDisablingSessionUserAgentLogging(settingName: string, newValue: string | null): boolean {
return SettingName.NAMES.LogSessionUserAgent === settingName && LogSessionUserAgentOption.Disabled === newValue
}
private async triggerEmailSubscriptionChange(
user: User,
settingName: string,
unencryptedValue: string | null,
): Promise<void> {
await this.domainEventPublisher.publish(
this.domainEventFactory.createMuteEmailsSettingChangedEvent({
username: user.email,
mute: unencryptedValue === 'muted',
emailSubscriptionRejectionLevel: this.emailSettingToSubscriptionRejectionLevelMap.get(settingName) as string,
}),
)
}
private async triggerSessionUserAgentCleanup(user: User) {
await this.domainEventPublisher.publish(
this.domainEventFactory.createUserDisabledSessionUserAgentLoggingEvent({
userUuid: user.uuid,
email: user.email,
}),
)
}
}

View File

@@ -1,5 +0,0 @@
import { User } from '../User/User'
export interface SettingInterpreterInterface {
interpretSettingUpdated(updatedSettingName: string, user: User, newUnencryptedValue: string | null): Promise<void>
}

View File

@@ -1,4 +1,3 @@
import { ReadStream } from 'fs'
import { SettingName } from '@standardnotes/domain-core'
import { DeleteSettingDto } from '../UseCase/DeleteSetting/DeleteSettingDto'
@@ -10,8 +9,8 @@ export interface SettingRepositoryInterface {
findOneByNameAndUserUuid(name: string, userUuid: string): Promise<Setting | null>
findLastByNameAndUserUuid(name: string, userUuid: string): Promise<Setting | null>
findAllByUserUuid(userUuid: string): Promise<Setting[]>
streamAllByNameAndValue(name: SettingName, value: string): Promise<ReadStream>
streamAllByName(name: SettingName): Promise<ReadStream>
countAllByNameAndValue(dto: { name: SettingName; value: string }): Promise<number>
findAllByNameAndValue(dto: { name: SettingName; value: string; offset: number; limit: number }): Promise<Setting[]>
deleteByUserUuid(dto: DeleteSettingDto): Promise<void>
insert(setting: Setting): Promise<void>
update(setting: Setting): Promise<void>

View File

@@ -0,0 +1,72 @@
import { Logger } from 'winston'
import { Result } from '@standardnotes/domain-core'
import { CSVFileReaderInterface } from '../../CSV/CSVFileReaderInterface'
import { DeleteAccount } from '../DeleteAccount/DeleteAccount'
import { DeleteAccountsFromCSVFile } from './DeleteAccountsFromCSVFile'
describe('DeleteAccountsFromCSVFile', () => {
let csvFileReader: CSVFileReaderInterface
let deleteAccount: DeleteAccount
let logger: Logger
const createUseCase = () => new DeleteAccountsFromCSVFile(csvFileReader, deleteAccount, logger)
beforeEach(() => {
csvFileReader = {} as jest.Mocked<CSVFileReaderInterface>
csvFileReader.getValues = jest.fn().mockResolvedValue(Result.ok(['email1']))
deleteAccount = {} as jest.Mocked<DeleteAccount>
deleteAccount.execute = jest.fn().mockResolvedValue(Result.ok(''))
logger = {} as jest.Mocked<Logger>
logger.info = jest.fn()
})
it('should delete accounts', async () => {
const useCase = createUseCase()
const result = await useCase.execute({ fileName: 'test.csv', dryRun: false })
expect(result.isFailed()).toBeFalsy()
})
it('should return error if csv file is invalid', async () => {
csvFileReader.getValues = jest.fn().mockResolvedValue(Result.fail('Oops'))
const useCase = createUseCase()
const result = await useCase.execute({ fileName: 'test.csv', dryRun: false })
expect(result.isFailed()).toBeTruthy()
})
it('should return error if csv file is empty', async () => {
csvFileReader.getValues = jest.fn().mockResolvedValue(Result.ok([]))
const useCase = createUseCase()
const result = await useCase.execute({ fileName: 'test.csv', dryRun: false })
expect(result.isFailed()).toBeTruthy()
})
it('should do nothing on a dry run', async () => {
const useCase = createUseCase()
const result = await useCase.execute({ fileName: 'test.csv', dryRun: true })
expect(deleteAccount.execute).not.toHaveBeenCalled()
expect(result.isFailed()).toBeFalsy()
})
it('should return error if delete account fails', async () => {
deleteAccount.execute = jest.fn().mockResolvedValue(Result.fail('Oops'))
const useCase = createUseCase()
const result = await useCase.execute({ fileName: 'test.csv', dryRun: false })
expect(result.isFailed()).toBeTruthy()
})
})

View File

@@ -0,0 +1,47 @@
import { Result, UseCaseInterface } from '@standardnotes/domain-core'
import { Logger } from 'winston'
import { DeleteAccount } from '../DeleteAccount/DeleteAccount'
import { CSVFileReaderInterface } from '../../CSV/CSVFileReaderInterface'
import { DeleteAccountsFromCSVFileDTO } from './DeleteAccountsFromCSVFileDTO'
export class DeleteAccountsFromCSVFile implements UseCaseInterface<void> {
constructor(
private csvFileReader: CSVFileReaderInterface,
private deleteAccount: DeleteAccount,
private logger: Logger,
) {}
async execute(dto: DeleteAccountsFromCSVFileDTO): Promise<Result<void>> {
const emailsOrError = await this.csvFileReader.getValues(dto.fileName)
if (emailsOrError.isFailed()) {
return Result.fail(emailsOrError.getError())
}
const emails = emailsOrError.getValue()
if (emails.length === 0) {
return Result.fail(`No emails found in CSV file ${dto.fileName}`)
}
if (dto.dryRun) {
const firstTenEmails = emails.slice(0, 10)
this.logger.info(
`Dry run mode enabled. Would delete ${emails.length} accounts. First 10 emails: ${firstTenEmails}`,
)
return Result.ok()
}
for (const email of emails) {
const deleteAccountOrError = await this.deleteAccount.execute({
username: email,
})
if (deleteAccountOrError.isFailed()) {
return Result.fail(deleteAccountOrError.getError())
}
}
return Result.ok()
}
}

View File

@@ -0,0 +1,4 @@
export interface DeleteAccountsFromCSVFileDTO {
fileName: string
dryRun: boolean
}

View File

@@ -0,0 +1,47 @@
import { Logger } from 'winston'
import { SettingRepositoryInterface } from '../../Setting/SettingRepositoryInterface'
import { TriggerEmailBackupForUser } from '../TriggerEmailBackupForUser/TriggerEmailBackupForUser'
import { TriggerEmailBackupForAllUsers } from './TriggerEmailBackupForAllUsers'
import { EncryptionVersion } from '../../Encryption/EncryptionVersion'
import { Setting } from '../../Setting/Setting'
import { Result, SettingName, Timestamps, Uuid } from '@standardnotes/domain-core'
describe('TriggerEmailBackupForAllUsers', () => {
let settingRepository: SettingRepositoryInterface
let triggerEmailBackupForUserUseCase: TriggerEmailBackupForUser
let logger: Logger
const createUseCase = () =>
new TriggerEmailBackupForAllUsers(settingRepository, triggerEmailBackupForUserUseCase, logger)
beforeEach(() => {
const setting = Setting.create({
name: SettingName.NAMES.EmailBackupFrequency,
value: null,
serverEncryptionVersion: EncryptionVersion.Default,
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
sensitive: false,
timestamps: Timestamps.create(123, 123).getValue(),
}).getValue()
settingRepository = {} as jest.Mocked<SettingRepositoryInterface>
settingRepository.countAllByNameAndValue = jest.fn().mockResolvedValue(1)
settingRepository.findAllByNameAndValue = jest.fn().mockResolvedValue([setting])
triggerEmailBackupForUserUseCase = {} as jest.Mocked<TriggerEmailBackupForUser>
triggerEmailBackupForUserUseCase.execute = jest.fn().mockResolvedValue(Result.ok())
logger = {} as jest.Mocked<Logger>
logger.error = jest.fn()
logger.info = jest.fn()
})
it('triggers email backup for all users', async () => {
const useCase = createUseCase()
const result = await useCase.execute({ backupFrequency: 'daily' })
expect(result.isFailed()).toBeFalsy()
})
})

View File

@@ -0,0 +1,55 @@
import { Result, SettingName, UseCaseInterface } from '@standardnotes/domain-core'
import { TriggerEmailBackupForUser } from '../TriggerEmailBackupForUser/TriggerEmailBackupForUser'
import { SettingRepositoryInterface } from '../../Setting/SettingRepositoryInterface'
import { TriggerEmailBackupForAllUsersDTO } from './TriggerEmailBackupForAllUsersDTO'
import { Logger } from 'winston'
export class TriggerEmailBackupForAllUsers implements UseCaseInterface<void> {
private PAGING_LIMIT = 100
constructor(
private settingRepository: SettingRepositoryInterface,
private triggerEmailBackupForUserUseCase: TriggerEmailBackupForUser,
private logger: Logger,
) {}
async execute(dto: TriggerEmailBackupForAllUsersDTO): Promise<Result<void>> {
const emailBackupFrequencySettingName = SettingName.create(SettingName.NAMES.EmailBackupFrequency).getValue()
const allSettingsCount = await this.settingRepository.countAllByNameAndValue({
name: emailBackupFrequencySettingName,
value: dto.backupFrequency,
})
this.logger.info(`Found ${allSettingsCount} users with email backup frequency set to ${dto.backupFrequency}`)
let failedUsers = 0
const numberOfPages = Math.ceil(allSettingsCount / this.PAGING_LIMIT)
for (let i = 0; i < numberOfPages; i++) {
const settings = await this.settingRepository.findAllByNameAndValue({
name: emailBackupFrequencySettingName,
value: dto.backupFrequency,
offset: i * this.PAGING_LIMIT,
limit: this.PAGING_LIMIT,
})
for (const setting of settings) {
const result = await this.triggerEmailBackupForUserUseCase.execute({
userUuid: setting.props.userUuid.value,
})
/* istanbul ignore next */
if (result.isFailed()) {
this.logger.error(`Failed to trigger email backup for user ${setting.props.userUuid.value}`)
failedUsers++
}
}
}
/* istanbul ignore next */
if (failedUsers > 0) {
this.logger.error(`Failed to trigger email backup for ${failedUsers} users`)
}
return Result.ok()
}
}

View File

@@ -0,0 +1,3 @@
export interface TriggerEmailBackupForAllUsersDTO {
backupFrequency: string
}

View File

@@ -0,0 +1,78 @@
import { DomainEventPublisherInterface, EmailBackupRequestedEvent } from '@standardnotes/domain-events'
import { Result, SettingName, Timestamps, Uuid } from '@standardnotes/domain-core'
import { RoleServiceInterface } from '../../Role/RoleServiceInterface'
import { GetSetting } from '../GetSetting/GetSetting'
import { GetUserKeyParams } from '../GetUserKeyParams/GetUserKeyParams'
import { TriggerEmailBackupForUser } from './TriggerEmailBackupForUser'
import { DomainEventFactoryInterface } from '../../Event/DomainEventFactoryInterface'
import { Setting } from '../../Setting/Setting'
import { EncryptionVersion } from '../../Encryption/EncryptionVersion'
describe('TriggerEmailBackupForUser', () => {
let roleService: RoleServiceInterface
let getSetting: GetSetting
let getUserKeyParamsUseCase: GetUserKeyParams
let domainEventPublisher: DomainEventPublisherInterface
let domainEventFactory: DomainEventFactoryInterface
const createUseCase = () =>
new TriggerEmailBackupForUser(
roleService,
getSetting,
getUserKeyParamsUseCase,
domainEventPublisher,
domainEventFactory,
)
beforeEach(() => {
roleService = {} as jest.Mocked<RoleServiceInterface>
roleService.userHasPermission = jest.fn().mockResolvedValue(true)
const setting = Setting.create({
name: SettingName.NAMES.ListedAuthorSecrets,
value: null,
serverEncryptionVersion: EncryptionVersion.Default,
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
sensitive: false,
timestamps: Timestamps.create(123, 123).getValue(),
}).getValue()
getSetting = {} as jest.Mocked<GetSetting>
getSetting.execute = jest.fn().mockResolvedValue(Result.ok({ setting, decryptedValue: 'not_muted' }))
getUserKeyParamsUseCase = {} as jest.Mocked<GetUserKeyParams>
getUserKeyParamsUseCase.execute = jest.fn().mockResolvedValue({ keyParams: {} })
domainEventPublisher = {} as jest.Mocked<DomainEventPublisherInterface>
domainEventPublisher.publish = jest.fn()
domainEventFactory = {} as jest.Mocked<DomainEventFactoryInterface>
domainEventFactory.createEmailBackupRequestedEvent = jest.fn().mockReturnValue({} as EmailBackupRequestedEvent)
})
it('publishes EmailBackupRequestedEvent', async () => {
const useCase = createUseCase()
const result = await useCase.execute({ userUuid: '00000000-0000-0000-0000-000000000000' })
expect(result.isFailed()).toBeFalsy()
})
it('returns error if user uuid is invalid', async () => {
const useCase = createUseCase()
const result = await useCase.execute({ userUuid: 'invalid-uuid' })
expect(result.isFailed()).toBe(true)
})
it('returns error if user is not permitted for email backups', async () => {
roleService.userHasPermission = jest.fn().mockResolvedValue(false)
const useCase = createUseCase()
const result = await useCase.execute({ userUuid: '00000000-0000-0000-0000-000000000000' })
expect(result.isFailed()).toBe(true)
})
})

View File

@@ -0,0 +1,66 @@
import { Result, SettingName, UseCaseInterface, Uuid } from '@standardnotes/domain-core'
import { PermissionName } from '@standardnotes/features'
import { TriggerEmailBackupForUserDTO } from './TriggerEmailBackupForUserDTO'
import { RoleServiceInterface } from '../../Role/RoleServiceInterface'
import { GetSetting } from '../GetSetting/GetSetting'
import { MuteFailedBackupsEmailsOption } from '@standardnotes/settings'
import { GetUserKeyParams } from '../GetUserKeyParams/GetUserKeyParams'
import { DomainEventPublisherInterface } from '@standardnotes/domain-events'
import { DomainEventFactoryInterface } from '../../Event/DomainEventFactoryInterface'
export class TriggerEmailBackupForUser implements UseCaseInterface<void> {
constructor(
private roleService: RoleServiceInterface,
private getSetting: GetSetting,
private getUserKeyParamsUseCase: GetUserKeyParams,
private domainEventPublisher: DomainEventPublisherInterface,
private domainEventFactory: DomainEventFactoryInterface,
) {}
async execute(dto: TriggerEmailBackupForUserDTO): Promise<Result<void>> {
const userUuidOrError = Uuid.create(dto.userUuid)
if (userUuidOrError.isFailed()) {
return Result.fail(userUuidOrError.getError())
}
const userUuid = userUuidOrError.getValue()
const userIsPermittedForEmailBackups = await this.roleService.userHasPermission(
userUuid.value,
PermissionName.DailyEmailBackup,
)
if (!userIsPermittedForEmailBackups) {
return Result.fail(`User ${userUuid.value} is not permitted for email backups`)
}
let userHasEmailsMuted = false
const emailsMutedSettingOrError = await this.getSetting.execute({
allowSensitiveRetrieval: true,
decrypted: true,
settingName: SettingName.NAMES.MuteFailedBackupsEmails,
userUuid: userUuid.value,
})
let emailsMutedSetting = null
if (!emailsMutedSettingOrError.isFailed()) {
emailsMutedSetting = emailsMutedSettingOrError.getValue()
userHasEmailsMuted = emailsMutedSetting.decryptedValue === MuteFailedBackupsEmailsOption.Muted
}
const keyParamsResponse = await this.getUserKeyParamsUseCase.execute({
userUuid: userUuid.value,
authenticated: false,
})
await this.domainEventPublisher.publish(
this.domainEventFactory.createEmailBackupRequestedEvent(
userUuid.value,
emailsMutedSetting?.setting.id.toString() as string,
userHasEmailsMuted,
keyParamsResponse.keyParams,
),
)
return Result.ok()
}
}

View File

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

View File

@@ -0,0 +1,104 @@
import {
DomainEventPublisherInterface,
EmailBackupRequestedEvent,
MuteEmailsSettingChangedEvent,
UserDisabledSessionUserAgentLoggingEvent,
} from '@standardnotes/domain-events'
import { EmailBackupFrequency, LogSessionUserAgentOption, MuteMarketingEmailsOption } from '@standardnotes/settings'
import { SettingName, Result } from '@standardnotes/domain-core'
import { TriggerPostSettingUpdateActions } from './TriggerPostSettingUpdateActions'
import { DomainEventFactoryInterface } from '../../Event/DomainEventFactoryInterface'
import { TriggerEmailBackupForUser } from '../TriggerEmailBackupForUser/TriggerEmailBackupForUser'
describe('TriggerPostSettingUpdateActions', () => {
let domainEventPublisher: DomainEventPublisherInterface
let domainEventFactory: DomainEventFactoryInterface
let triggerEmailBackupForUser: TriggerEmailBackupForUser
const createUseCase = () =>
new TriggerPostSettingUpdateActions(domainEventPublisher, domainEventFactory, triggerEmailBackupForUser)
beforeEach(() => {
triggerEmailBackupForUser = {} as jest.Mocked<TriggerEmailBackupForUser>
triggerEmailBackupForUser.execute = jest.fn().mockReturnValue(Result.ok())
domainEventPublisher = {} as jest.Mocked<DomainEventPublisherInterface>
domainEventPublisher.publish = jest.fn()
domainEventFactory = {} as jest.Mocked<DomainEventFactoryInterface>
domainEventFactory.createEmailBackupRequestedEvent = jest
.fn()
.mockReturnValue({} as jest.Mocked<EmailBackupRequestedEvent>)
domainEventFactory.createUserDisabledSessionUserAgentLoggingEvent = jest
.fn()
.mockReturnValue({} as jest.Mocked<UserDisabledSessionUserAgentLoggingEvent>)
domainEventFactory.createMuteEmailsSettingChangedEvent = jest
.fn()
.mockReturnValue({} as jest.Mocked<MuteEmailsSettingChangedEvent>)
})
it('should trigger session cleanup if user is disabling session user agent logging', async () => {
await createUseCase().execute({
updatedSettingName: SettingName.NAMES.LogSessionUserAgent,
userUuid: '4-5-6',
userEmail: 'test@test.te',
unencryptedValue: LogSessionUserAgentOption.Disabled,
})
expect(domainEventPublisher.publish).toHaveBeenCalled()
expect(domainEventFactory.createUserDisabledSessionUserAgentLoggingEvent).toHaveBeenCalledWith({
userUuid: '4-5-6',
email: 'test@test.te',
})
})
it('should trigger backup if email backup setting is created - emails not muted', async () => {
await createUseCase().execute({
updatedSettingName: SettingName.NAMES.EmailBackupFrequency,
userUuid: '4-5-6',
userEmail: 'test@test.te',
unencryptedValue: EmailBackupFrequency.Daily,
})
expect(triggerEmailBackupForUser.execute).toHaveBeenCalled()
})
it('should trigger backup if email backup setting is created - emails muted', async () => {
await createUseCase().execute({
updatedSettingName: SettingName.NAMES.EmailBackupFrequency,
userUuid: '4-5-6',
userEmail: 'test@test.te',
unencryptedValue: EmailBackupFrequency.Daily,
})
expect(triggerEmailBackupForUser.execute).toHaveBeenCalled()
})
it('should not trigger backup if email backup setting is disabled', async () => {
await createUseCase().execute({
updatedSettingName: SettingName.NAMES.EmailBackupFrequency,
userUuid: '4-5-6',
userEmail: 'test@test.te',
unencryptedValue: EmailBackupFrequency.Disabled,
})
expect(triggerEmailBackupForUser.execute).not.toHaveBeenCalled()
})
it('should trigger mute subscription emails rejection if mute setting changed', async () => {
await createUseCase().execute({
updatedSettingName: SettingName.NAMES.MuteMarketingEmails,
userUuid: '4-5-6',
userEmail: 'test@test.te',
unencryptedValue: MuteMarketingEmailsOption.Muted,
})
expect(domainEventPublisher.publish).toHaveBeenCalled()
expect(domainEventFactory.createMuteEmailsSettingChangedEvent).toHaveBeenCalledWith({
emailSubscriptionRejectionLevel: 'MARKETING',
mute: true,
username: 'test@test.te',
})
})
})

View File

@@ -0,0 +1,83 @@
import { DomainEventPublisherInterface } from '@standardnotes/domain-events'
import { EmailLevel, Result, SettingName, UseCaseInterface } from '@standardnotes/domain-core'
import { EmailBackupFrequency, LogSessionUserAgentOption } from '@standardnotes/settings'
import { TriggerPostSettingUpdateActionsDTO } from './TriggerPostSettingUpdateActionsDTO'
import { DomainEventFactoryInterface } from '../../Event/DomainEventFactoryInterface'
import { TriggerEmailBackupForUser } from '../TriggerEmailBackupForUser/TriggerEmailBackupForUser'
export class TriggerPostSettingUpdateActions implements UseCaseInterface<void> {
private readonly emailSettingToSubscriptionRejectionLevelMap: Map<string, string> = new Map([
[SettingName.NAMES.MuteFailedBackupsEmails, EmailLevel.LEVELS.FailedEmailBackup],
[SettingName.NAMES.MuteFailedCloudBackupsEmails, EmailLevel.LEVELS.FailedCloudBackup],
[SettingName.NAMES.MuteMarketingEmails, EmailLevel.LEVELS.Marketing],
[SettingName.NAMES.MuteSignInEmails, EmailLevel.LEVELS.SignIn],
])
constructor(
private domainEventPublisher: DomainEventPublisherInterface,
private domainEventFactory: DomainEventFactoryInterface,
private triggerEmailBackupForUser: TriggerEmailBackupForUser,
) {}
async execute(dto: TriggerPostSettingUpdateActionsDTO): Promise<Result<void>> {
if (this.isChangingMuteEmailsSetting(dto.updatedSettingName)) {
await this.triggerEmailSubscriptionChange(dto.userEmail, dto.updatedSettingName, dto.unencryptedValue)
}
if (this.isEnablingEmailBackupSetting(dto.updatedSettingName, dto.unencryptedValue)) {
await this.triggerEmailBackupForUser.execute({
userUuid: dto.userUuid,
})
}
if (this.isDisablingSessionUserAgentLogging(dto.updatedSettingName, dto.unencryptedValue)) {
await this.triggerSessionUserAgentCleanup(dto.userEmail, dto.userUuid)
}
return Result.ok()
}
private isChangingMuteEmailsSetting(settingName: string): boolean {
return [
SettingName.NAMES.MuteFailedBackupsEmails,
SettingName.NAMES.MuteFailedCloudBackupsEmails,
SettingName.NAMES.MuteMarketingEmails,
SettingName.NAMES.MuteSignInEmails,
].includes(settingName)
}
private isEnablingEmailBackupSetting(settingName: string, newValue: string | null): boolean {
return (
settingName === SettingName.NAMES.EmailBackupFrequency &&
[EmailBackupFrequency.Daily, EmailBackupFrequency.Weekly].includes(newValue as EmailBackupFrequency)
)
}
private isDisablingSessionUserAgentLogging(settingName: string, newValue: string | null): boolean {
return SettingName.NAMES.LogSessionUserAgent === settingName && LogSessionUserAgentOption.Disabled === newValue
}
private async triggerEmailSubscriptionChange(
userEmail: string,
settingName: string,
unencryptedValue: string | null,
): Promise<void> {
await this.domainEventPublisher.publish(
this.domainEventFactory.createMuteEmailsSettingChangedEvent({
username: userEmail,
mute: unencryptedValue === 'muted',
emailSubscriptionRejectionLevel: this.emailSettingToSubscriptionRejectionLevelMap.get(settingName) as string,
}),
)
}
private async triggerSessionUserAgentCleanup(userEmail: string, userUuid: string) {
await this.domainEventPublisher.publish(
this.domainEventFactory.createUserDisabledSessionUserAgentLoggingEvent({
userUuid,
email: userEmail,
}),
)
}
}

View File

@@ -0,0 +1,6 @@
export interface TriggerPostSettingUpdateActionsDTO {
updatedSettingName: string
userUuid: string
userEmail: string
unencryptedValue: string | null
}

View File

@@ -19,6 +19,8 @@ import { SubscriptionSetting } from '../../Domain/Setting/SubscriptionSetting'
import { SettingHttpRepresentation } from '../../Mapping/Http/SettingHttpRepresentation'
import { SubscriptionSettingHttpRepresentation } from '../../Mapping/Http/SubscriptionSettingHttpRepresentation'
import { GetAllSettingsForUser } from '../../Domain/UseCase/GetAllSettingsForUser/GetAllSettingsForUser'
import { TriggerPostSettingUpdateActions } from '../../Domain/UseCase/TriggerPostSettingUpdateActions/TriggerPostSettingUpdateActions'
import { Logger } from 'winston'
@controller('/users/:userUuid')
export class AnnotatedSettingsController extends BaseSettingsController {
@@ -26,18 +28,23 @@ export class AnnotatedSettingsController extends BaseSettingsController {
@inject(TYPES.Auth_GetAllSettingsForUser) override doGetSettings: GetAllSettingsForUser,
@inject(TYPES.Auth_GetSetting) override doGetSetting: GetSetting,
@inject(TYPES.Auth_SetSettingValue) override setSettingValue: SetSettingValue,
@inject(TYPES.Auth_TriggerPostSettingUpdateActions)
override triggerPostSettingUpdateActions: TriggerPostSettingUpdateActions,
@inject(TYPES.Auth_DeleteSetting) override doDeleteSetting: DeleteSetting,
@inject(TYPES.Auth_SettingHttpMapper) settingHttMapper: MapperInterface<Setting, SettingHttpRepresentation>,
@inject(TYPES.Auth_SubscriptionSettingHttpMapper)
subscriptionSettingHttpMapper: MapperInterface<SubscriptionSetting, SubscriptionSettingHttpRepresentation>,
@inject(TYPES.Auth_Logger) logger: Logger,
) {
super(
doGetSettings,
doGetSetting,
setSettingValue,
triggerPostSettingUpdateActions,
doDeleteSetting,
settingHttMapper,
subscriptionSettingHttpMapper,
logger,
)
}

View File

@@ -2,6 +2,7 @@ import { ControllerContainerInterface, MapperInterface } from '@standardnotes/do
import { BaseHttpController, results } from 'inversify-express-utils'
import { ErrorTag } from '@standardnotes/responses'
import { Request, Response } from 'express'
import { Logger } from 'winston'
import { DeleteSetting } from '../../../Domain/UseCase/DeleteSetting/DeleteSetting'
import { GetSetting } from '../../../Domain/UseCase/GetSetting/GetSetting'
@@ -11,18 +12,21 @@ import { Setting } from '../../../Domain/Setting/Setting'
import { SubscriptionSetting } from '../../../Domain/Setting/SubscriptionSetting'
import { SubscriptionSettingHttpRepresentation } from '../../../Mapping/Http/SubscriptionSettingHttpRepresentation'
import { SettingHttpRepresentation } from '../../../Mapping/Http/SettingHttpRepresentation'
import { TriggerPostSettingUpdateActions } from '../../../Domain/UseCase/TriggerPostSettingUpdateActions/TriggerPostSettingUpdateActions'
export class BaseSettingsController extends BaseHttpController {
constructor(
protected doGetSettings: GetAllSettingsForUser,
protected doGetSetting: GetSetting,
protected setSettingValue: SetSettingValue,
protected triggerPostSettingUpdateActions: TriggerPostSettingUpdateActions,
protected doDeleteSetting: DeleteSetting,
protected settingHttMapper: MapperInterface<Setting, SettingHttpRepresentation>,
protected subscriptionSettingHttpMapper: MapperInterface<
SubscriptionSetting,
SubscriptionSettingHttpRepresentation
>,
protected logger: Logger,
private controllerContainer?: ControllerContainerInterface,
) {
super()
@@ -175,6 +179,16 @@ export class BaseSettingsController extends BaseHttpController {
}
const setting = result.getValue()
const triggerResult = await this.triggerPostSettingUpdateActions.execute({
updatedSettingName: setting.props.name,
userUuid: response.locals.user.uuid,
userEmail: response.locals.user.email,
unencryptedValue: value,
})
if (triggerResult.isFailed()) {
this.logger.error(`Failed to trigger post setting update actions: ${triggerResult.getError()}`)
}
return this.json({
success: true,
setting: setting.props.sensitive ? undefined : this.settingHttMapper.toProjection(setting),

View File

@@ -0,0 +1,32 @@
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3'
import { Result } from '@standardnotes/domain-core'
import { CSVFileReaderInterface } from '../../Domain/CSV/CSVFileReaderInterface'
export class S3CsvFileReader implements CSVFileReaderInterface {
constructor(
private s3BucketName: string,
private s3Client: S3Client,
) {}
async getValues(fileName: string): Promise<Result<string[]>> {
if (this.s3BucketName.length === 0) {
return Result.fail('S3 bucket name is not set')
}
const response = await this.s3Client.send(
new GetObjectCommand({
Bucket: this.s3BucketName,
Key: fileName,
}),
)
if (response.Body === undefined) {
return Result.fail(`Could not find CSV file at path: ${fileName}`)
}
const csvContent = await response.Body.transformToString()
return Result.ok(csvContent.split('\n').filter((line) => line.length > 0))
}
}

Some files were not shown because too many files have changed in this diff Show More