Compare commits

..

49 Commits

Author SHA1 Message Date
standardci
d7b02c4da9 chore(release): publish new version
- @standardnotes/auth-server@1.32.11
2022-09-28 13:36:17 +00:00
Karol Sójko
40e673379b fix(auth): prevent replacing files bytes used subscription setting upon renewal 2022-09-28 15:34:43 +02:00
standardci
6ce9a4e834 chore(release): publish new version
- @standardnotes/auth-server@1.32.10
2022-09-28 12:05:17 +00:00
Karol Sójko
c5a07a888a fix(auth): exclude legacy 5 year plans from subscription length statistics 2022-09-28 14:03:49 +02:00
standardci
55587f6207 chore(release): publish new version
- @standardnotes/api-gateway@1.22.2
 - @standardnotes/auth-server@1.32.9
 - @standardnotes/domain-events-infra@1.8.13
 - @standardnotes/domain-events@2.60.7
 - @standardnotes/event-store@1.3.18
 - @standardnotes/files-server@1.6.4
 - @standardnotes/scheduler-server@1.10.32
 - @standardnotes/syncing-server@1.8.11
2022-09-28 11:33:44 +00:00
Karol Sójko
0d6b45c795 chore(deps): upgrade @standardnotes/features 2022-09-28 13:31:15 +02:00
standardci
95f64d9952 chore(release): publish new version
- @standardnotes/auth-server@1.32.8
2022-09-27 13:22:13 +00:00
Karol Sójko
54da5def4b fix(auth): ttl for lock counter on login lockout 2022-09-27 15:20:42 +02:00
standardci
d2fc1e057d chore(release): publish new version
- @standardnotes/api-gateway@1.22.1
2022-09-27 10:35:15 +00:00
Karol Sójko
0a90d98c71 fix(api-gateway): remove admin graphql endpoint from being publicly available 2022-09-27 12:33:29 +02:00
standardci
cc269e3b35 chore(release): publish new version
- @standardnotes/auth-server@1.32.7
2022-09-27 08:29:51 +00:00
Karol Sójko
b19093179b fix(auth): allow resending canceled subscription invites 2022-09-27 10:28:13 +02:00
standardci
e2cc0bc003 chore(release): publish new version
- @standardnotes/auth-server@1.32.6
2022-09-22 18:50:24 +00:00
Karol Sójko
644c52ae36 Revert "fix(auth): subscription token ttl"
This reverts commit 6efd336f34.
2022-09-22 20:48:51 +02:00
Karol Sójko
2554273a3f Revert "fix(auth): increase subscription token ttl"
This reverts commit 07def20f6b.
2022-09-22 20:48:51 +02:00
Karol Sójko
a8ee149d7a Revert "tmp(auth): disable expiring of subscription tokens"
This reverts commit 053092031c.
2022-09-22 20:48:51 +02:00
standardci
dcf92d58f9 chore(release): publish new version
- @standardnotes/auth-server@1.32.5
2022-09-22 18:00:23 +00:00
Karol Sójko
053092031c tmp(auth): disable expiring of subscription tokens 2022-09-22 19:58:35 +02:00
standardci
c12e3eb3ec chore(release): publish new version
- @standardnotes/auth-server@1.32.4
2022-09-22 15:30:14 +00:00
Karol Sójko
07def20f6b fix(auth): increase subscription token ttl 2022-09-22 17:28:28 +02:00
standardci
6c2cca66bd chore(release): publish new version
- @standardnotes/auth-server@1.32.3
2022-09-22 14:26:30 +00:00
Karol Sójko
6efd336f34 fix(auth): subscription token ttl 2022-09-22 16:24:33 +02:00
standardci
81eb4be200 chore(release): publish new version
- @standardnotes/auth-server@1.32.2
2022-09-22 13:48:33 +00:00
Karol Sójko
76cee6dbad fix(auth): add throwing an error if the subscription token was not persisted 2022-09-22 15:46:23 +02:00
standardci
dcc35a5738 chore(release): publish new version
- @standardnotes/syncing-server@1.8.10
2022-09-22 12:38:18 +00:00
Karol Sójko
5628de6445 fix(syncing-server-js): binding of sync limit 2022-09-22 14:36:47 +02:00
standardci
53bea47727 chore(release): publish new version
- @standardnotes/auth-server@1.32.1
2022-09-22 12:36:39 +00:00
Karol Sójko
d6cf8d400a fix(auth): settings and subscription settings projection 2022-09-22 14:34:56 +02:00
standardci
b58cc335f2 chore(release): publish new version
- @standardnotes/syncing-server@1.8.9
2022-09-22 11:56:22 +00:00
Karol Sójko
03d1bc611c fix(syncing-server): introduce upper bound for sync items limit as an env var 2022-09-22 13:54:26 +02:00
standardci
a48b09cefe chore(release): publish new version
- @standardnotes/api-gateway@1.22.0
 - @standardnotes/auth-server@1.32.0
2022-09-22 11:27:42 +00:00
Karol Sójko
d3f36c05df feat(auth): remove muting emails by use case in favor of updating user settings 2022-09-22 13:25:31 +02:00
standardci
488ade25ab chore(release): publish new version
- @standardnotes/auth-server@1.31.2
2022-09-21 14:40:45 +00:00
Karol Sójko
413a276d20 fix(auth): response wrapping on web socket connection token creation 2022-09-21 16:39:17 +02:00
standardci
65675a21d6 chore(release): publish new version
- @standardnotes/api-gateway@1.21.1
2022-09-21 13:56:25 +00:00
Karol Sójko
d35de38289 fix(api-gateway): web socket connection routing 2022-09-21 15:54:57 +02:00
standardci
83e1baa978 chore(release): publish new version
- @standardnotes/auth-server@1.31.1
2022-09-21 13:53:16 +00:00
Karol Sójko
875edce5b1 fix(auth): web sockets routes 2022-09-21 15:51:46 +02:00
standardci
1baa504728 chore(release): publish new version
- @standardnotes/api-gateway@1.21.0
 - @standardnotes/auth-server@1.31.0
2022-09-21 11:57:48 +00:00
Karol Sójko
965ae79414 feat(auth): add creating cross service token in exchange for web socket connection token 2022-09-21 13:56:17 +02:00
standardci
7a8448c116 chore(release): publish new version
- @standardnotes/auth-server@1.30.1
2022-09-21 09:15:22 +00:00
Karol Sójko
d935157ee8 fix(auth): missing injectable annotation 2022-09-21 11:13:24 +02:00
standardci
9313e6b568 chore(release): publish new version
- @standardnotes/api-gateway@1.20.0
 - @standardnotes/auth-server@1.30.0
 - @standardnotes/domain-events-infra@1.8.12
 - @standardnotes/domain-events@2.60.6
 - @standardnotes/event-store@1.3.17
 - @standardnotes/files-server@1.6.3
 - @standardnotes/scheduler-server@1.10.31
 - @standardnotes/security@1.4.0
 - @standardnotes/syncing-server@1.8.8
2022-09-21 09:00:32 +00:00
Karol Sójko
8033177f48 feat(auth): add creating web socket connection tokens 2022-09-21 10:58:39 +02:00
standardci
11011fa15d chore(release): publish new version
- @standardnotes/syncing-server@1.8.7
2022-09-20 08:01:52 +00:00
Karol Sójko
c2e9f3e72b fix(syncing-server): content size calculation and add syncing upper bound for limit paramter 2022-09-20 09:59:40 +02:00
standardci
f0fb7fd1cd chore(release): publish new version
- @standardnotes/files-server@1.6.2
2022-09-19 11:55:08 +00:00
Karol Sójko
15e342fd51 Merge pull request #224 from standardnotes/fs_dos
fix: add upper bound for FS file chunk upload
2022-09-19 13:53:39 +02:00
Karol Sójko
dfa7e06f87 fix: add upper bound for FS file chunk upload 2022-09-19 13:44:37 +02:00
98 changed files with 1319 additions and 839 deletions

53
.pnp.cjs generated
View File

@@ -2484,14 +2484,14 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
}]\
]],\
["@standardnotes/api", [\
["npm:1.7.2", {\
"packageLocation": "./.yarn/cache/@standardnotes-api-npm-1.7.2-e68e7d4e63-bdfc414e6d.zip/node_modules/@standardnotes/api/",\
["npm:1.8.1", {\
"packageLocation": "./.yarn/cache/@standardnotes-api-npm-1.8.1-15c2e051d4-76c5d1a2d2.zip/node_modules/@standardnotes/api/",\
"packageDependencies": [\
["@standardnotes/api", "npm:1.7.2"],\
["@standardnotes/api", "npm:1.8.1"],\
["@standardnotes/common", "workspace:packages/common"],\
["@standardnotes/encryption", "npm:1.15.2"],\
["@standardnotes/models", "npm:1.18.2"],\
["@standardnotes/responses", "npm:1.10.1"],\
["@standardnotes/encryption", "npm:1.15.3"],\
["@standardnotes/models", "npm:1.18.3"],\
["@standardnotes/responses", "npm:1.10.2"],\
["@standardnotes/security", "workspace:packages/security"],\
["@standardnotes/utils", "npm:1.9.0"],\
["reflect-metadata", "npm:0.1.13"]\
@@ -2563,11 +2563,11 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
["@newrelic/winston-enricher", "virtual:04783e12400851b8a3d76e71495851cc94959db6e62f04cb0a31190080629440b182d8c8eb4d7f2b04e281912f2783a5fd4d2c3c6ab68d38b7097246c93f4c19#npm:4.0.0"],\
["@sentry/node", "npm:7.5.0"],\
["@standardnotes/analytics", "workspace:packages/analytics"],\
["@standardnotes/api", "npm:1.7.2"],\
["@standardnotes/api", "npm:1.8.1"],\
["@standardnotes/common", "workspace:packages/common"],\
["@standardnotes/domain-events", "workspace:packages/domain-events"],\
["@standardnotes/domain-events-infra", "workspace:packages/domain-events-infra"],\
["@standardnotes/features", "npm:1.50.0"],\
["@standardnotes/features", "npm:1.52.1"],\
["@standardnotes/predicates", "workspace:packages/predicates"],\
["@standardnotes/responses", "npm:1.6.39"],\
["@standardnotes/security", "workspace:packages/security"],\
@@ -2651,7 +2651,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
"packageDependencies": [\
["@standardnotes/domain-events", "workspace:packages/domain-events"],\
["@standardnotes/common", "workspace:packages/common"],\
["@standardnotes/features", "npm:1.50.0"],\
["@standardnotes/features", "npm:1.52.1"],\
["@standardnotes/predicates", "workspace:packages/predicates"],\
["@standardnotes/security", "workspace:packages/security"],\
["@types/jest", "npm:28.1.4"],\
@@ -2688,13 +2688,13 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
}]\
]],\
["@standardnotes/encryption", [\
["npm:1.15.2", {\
"packageLocation": "./.yarn/cache/@standardnotes-encryption-npm-1.15.2-ef86a8281d-6e8336f1e7.zip/node_modules/@standardnotes/encryption/",\
["npm:1.15.3", {\
"packageLocation": "./.yarn/cache/@standardnotes-encryption-npm-1.15.3-3580c52c1f-1a7863299f.zip/node_modules/@standardnotes/encryption/",\
"packageDependencies": [\
["@standardnotes/encryption", "npm:1.15.2"],\
["@standardnotes/encryption", "npm:1.15.3"],\
["@standardnotes/common", "workspace:packages/common"],\
["@standardnotes/models", "npm:1.18.2"],\
["@standardnotes/responses", "npm:1.10.1"],\
["@standardnotes/models", "npm:1.18.3"],\
["@standardnotes/responses", "npm:1.10.2"],\
["@standardnotes/sncrypto-common", "npm:1.11.1"],\
["@standardnotes/utils", "npm:1.9.0"],\
["reflect-metadata", "npm:0.1.13"]\
@@ -2753,6 +2753,17 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
["reflect-metadata", "npm:0.1.13"]\
],\
"linkType": "HARD"\
}],\
["npm:1.52.1", {\
"packageLocation": "./.yarn/cache/@standardnotes-features-npm-1.52.1-1fee85cf4e-ff3684399e.zip/node_modules/@standardnotes/features/",\
"packageDependencies": [\
["@standardnotes/features", "npm:1.52.1"],\
["@standardnotes/auth", "npm:3.19.4"],\
["@standardnotes/common", "workspace:packages/common"],\
["@standardnotes/security", "workspace:packages/security"],\
["reflect-metadata", "npm:0.1.13"]\
],\
"linkType": "HARD"\
}]\
]],\
["@standardnotes/files-server", [\
@@ -2808,13 +2819,13 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
}]\
]],\
["@standardnotes/models", [\
["npm:1.18.2", {\
"packageLocation": "./.yarn/cache/@standardnotes-models-npm-1.18.2-56f35bb72d-88180a93e5.zip/node_modules/@standardnotes/models/",\
["npm:1.18.3", {\
"packageLocation": "./.yarn/cache/@standardnotes-models-npm-1.18.3-6c65a62f30-21830c805f.zip/node_modules/@standardnotes/models/",\
"packageDependencies": [\
["@standardnotes/models", "npm:1.18.2"],\
["@standardnotes/models", "npm:1.18.3"],\
["@standardnotes/common", "workspace:packages/common"],\
["@standardnotes/features", "npm:1.52.0"],\
["@standardnotes/responses", "npm:1.10.1"],\
["@standardnotes/responses", "npm:1.10.2"],\
["@standardnotes/utils", "npm:1.9.0"],\
["lodash", "npm:4.17.21"],\
["reflect-metadata", "npm:0.1.13"]\
@@ -2851,10 +2862,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
}]\
]],\
["@standardnotes/responses", [\
["npm:1.10.1", {\
"packageLocation": "./.yarn/cache/@standardnotes-responses-npm-1.10.1-9f82fff6c1-b84fb3f71c.zip/node_modules/@standardnotes/responses/",\
["npm:1.10.2", {\
"packageLocation": "./.yarn/cache/@standardnotes-responses-npm-1.10.2-39d2d1f9b5-364724b5c7.zip/node_modules/@standardnotes/responses/",\
"packageDependencies": [\
["@standardnotes/responses", "npm:1.10.1"],\
["@standardnotes/responses", "npm:1.10.2"],\
["@standardnotes/common", "workspace:packages/common"],\
["@standardnotes/features", "npm:1.52.0"],\
["@standardnotes/security", "workspace:packages/security"],\

View File

@@ -3,6 +3,40 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.22.2](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.22.1...@standardnotes/api-gateway@1.22.2) (2022-09-28)
**Note:** Version bump only for package @standardnotes/api-gateway
## [1.22.1](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.22.0...@standardnotes/api-gateway@1.22.1) (2022-09-27)
### Bug Fixes
* **api-gateway:** remove admin graphql endpoint from being publicly available ([0a90d98](https://github.com/standardnotes/api-gateway/commit/0a90d98c71c6023b700f852c91aedfe1ad23af55))
# [1.22.0](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.21.1...@standardnotes/api-gateway@1.22.0) (2022-09-22)
### Features
* **auth:** remove muting emails by use case in favor of updating user settings ([d3f36c0](https://github.com/standardnotes/api-gateway/commit/d3f36c05dfc114098a6c231d81149ebd1a959b74))
## [1.21.1](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.21.0...@standardnotes/api-gateway@1.21.1) (2022-09-21)
### Bug Fixes
* **api-gateway:** web socket connection routing ([d35de38](https://github.com/standardnotes/api-gateway/commit/d35de38289e70d707d57a859b8bf39833fa825dd))
# [1.21.0](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.20.0...@standardnotes/api-gateway@1.21.0) (2022-09-21)
### Features
* **auth:** add creating cross service token in exchange for web socket connection token ([965ae79](https://github.com/standardnotes/api-gateway/commit/965ae79414e25d0959f67e16dcbb054229013e1c))
# [1.20.0](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.19.6...@standardnotes/api-gateway@1.20.0) (2022-09-21)
### Features
* **auth:** add creating web socket connection tokens ([8033177](https://github.com/standardnotes/api-gateway/commit/8033177f48dc961194f24fb7daa1073b8b697b74))
## [1.19.6](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.19.5...@standardnotes/api-gateway@1.19.6) (2022-09-19)
**Note:** Version bump only for package @standardnotes/api-gateway

View File

@@ -1,6 +1,6 @@
{
"name": "@standardnotes/api-gateway",
"version": "1.19.6",
"version": "1.22.2",
"engines": {
"node": ">=16.0.0 <17.0.0"
},

View File

@@ -23,6 +23,7 @@ import { SubscriptionTokenAuthMiddleware } from '../Controller/SubscriptionToken
import { StatisticsMiddleware } from '../Controller/StatisticsMiddleware'
import { CrossServiceTokenCacheInterface } from '../Service/Cache/CrossServiceTokenCacheInterface'
import { RedisCrossServiceTokenCache } from '../Infra/Redis/RedisCrossServiceTokenCache'
import { WebSocketAuthMiddleware } from '../Controller/WebSocketAuthMiddleware'
// eslint-disable-next-line @typescript-eslint/no-var-requires
const newrelicFormatter = require('@newrelic/winston-enricher')
@@ -85,6 +86,7 @@ export class ContainerConfigLoader {
// Middleware
container.bind<AuthMiddleware>(TYPES.AuthMiddleware).to(AuthMiddleware)
container.bind<WebSocketAuthMiddleware>(TYPES.WebSocketAuthMiddleware).to(WebSocketAuthMiddleware)
container
.bind<SubscriptionTokenAuthMiddleware>(TYPES.SubscriptionTokenAuthMiddleware)
.to(SubscriptionTokenAuthMiddleware)

View File

@@ -18,6 +18,7 @@ const TYPES = {
// Middleware
StatisticsMiddleware: Symbol.for('StatisticsMiddleware'),
AuthMiddleware: Symbol.for('AuthMiddleware'),
WebSocketAuthMiddleware: Symbol.for('WebSocketAuthMiddleware'),
SubscriptionTokenAuthMiddleware: Symbol.for('SubscriptionTokenAuthMiddleware'),
// Services
HTTPService: Symbol.for('HTTPService'),

View File

@@ -0,0 +1,95 @@
import { CrossServiceTokenData } from '@standardnotes/security'
import { RoleName } from '@standardnotes/common'
import { NextFunction, Request, Response } from 'express'
import { inject, injectable } from 'inversify'
import { BaseMiddleware } from 'inversify-express-utils'
import { verify } from 'jsonwebtoken'
import { AxiosError, AxiosInstance } from 'axios'
import { Logger } from 'winston'
import TYPES from '../Bootstrap/Types'
@injectable()
export class WebSocketAuthMiddleware extends BaseMiddleware {
constructor(
@inject(TYPES.HTTPClient) private httpClient: AxiosInstance,
@inject(TYPES.AUTH_SERVER_URL) private authServerUrl: string,
@inject(TYPES.AUTH_JWT_SECRET) private jwtSecret: string,
@inject(TYPES.Logger) private logger: Logger,
) {
super()
}
async handler(request: Request, response: Response, next: NextFunction): Promise<void> {
const authHeaderValue = request.headers.authorization as string
if (!authHeaderValue) {
response.status(401).send({
error: {
tag: 'invalid-auth',
message: 'Invalid login credentials.',
},
})
return
}
try {
const authResponse = await this.httpClient.request({
method: 'POST',
headers: {
Authorization: authHeaderValue,
Accept: 'application/json',
},
validateStatus: (status: number) => {
return status >= 200 && status < 500
},
url: `${this.authServerUrl}/sockets/tokens/validate`,
})
if (authResponse.status > 200) {
response.setHeader('content-type', authResponse.headers['content-type'])
response.status(authResponse.status).send(authResponse.data)
return
}
const crossServiceToken = authResponse.data.authToken
response.locals.authToken = crossServiceToken
const decodedToken = <CrossServiceTokenData>verify(crossServiceToken, this.jwtSecret, { algorithms: ['HS256'] })
response.locals.freeUser =
decodedToken.roles.length === 1 &&
decodedToken.roles.find((role) => role.name === RoleName.CoreUser) !== undefined
response.locals.userUuid = decodedToken.user.uuid
response.locals.roles = decodedToken.roles
} catch (error) {
const errorMessage = (error as AxiosError).isAxiosError
? JSON.stringify((error as AxiosError).response?.data)
: (error as Error).message
this.logger.error(
`Could not pass the request to ${this.authServerUrl}/sockets/tokens/validate on underlying service: ${errorMessage}`,
)
this.logger.debug('Response error: %O', (error as AxiosError).response ?? error)
if ((error as AxiosError).response?.headers['content-type']) {
response.setHeader('content-type', (error as AxiosError).response?.headers['content-type'] as string)
}
const errorCode =
(error as AxiosError).isAxiosError && !isNaN(+((error as AxiosError).code as string))
? +((error as AxiosError).code as string)
: 500
response.status(errorCode).send(errorMessage)
return
}
return next()
}
}

View File

@@ -29,34 +29,4 @@ export class ActionsController extends BaseHttpController {
async methods(request: Request, response: Response): Promise<void> {
await this.httpService.callAuthServer(request, response, 'auth/methods', request.body)
}
@httpGet('/failed-backups-emails/mute/:settingUuid')
async muteFailedBackupsEmails(request: Request, response: Response): Promise<void> {
await this.httpService.callAuthServer(
request,
response,
`internal/settings/email_backup/${request.params.settingUuid}/mute`,
request.body,
)
}
@httpGet('/sign-in-emails/mute/:settingUuid')
async muteSignInEmails(request: Request, response: Response): Promise<void> {
await this.httpService.callAuthServer(
request,
response,
`internal/settings/sign_in/${request.params.settingUuid}/mute`,
request.body,
)
}
@httpGet('/marketing-emails/mute/:settingUuid')
async muteMarketingEmails(request: Request, response: Response): Promise<void> {
await this.httpService.callAuthServer(
request,
response,
`internal/settings/marketing-emails/${request.params.settingUuid}/mute`,
request.body,
)
}
}

View File

@@ -70,11 +70,6 @@ export class PaymentsController extends BaseHttpController {
await this.httpService.callPaymentsServer(request, response, 'admin/events/registration', request.body)
}
@httpPost('/admin/graphql')
async adminGraphql(request: Request, response: Response): Promise<void> {
await this.httpService.callPaymentsServer(request, response, 'admin/graphql', request.body)
}
@httpPost('/admin/auth/login')
async adminLogin(request: Request, response: Response): Promise<void> {
await this.httpService.callPaymentsServer(request, response, 'admin/auth/login', request.body)

View File

@@ -15,7 +15,12 @@ export class WebSocketsController extends BaseHttpController {
super()
}
@httpPost('/', TYPES.AuthMiddleware)
@httpPost('/tokens', TYPES.AuthMiddleware)
async createWebSocketConnectionToken(request: Request, response: Response): Promise<void> {
await this.httpService.callAuthServer(request, response, 'sockets/tokens', request.body)
}
@httpPost('/connections', TYPES.WebSocketAuthMiddleware)
async createWebSocketConnection(request: Request, response: Response): Promise<void> {
if (!request.headers.connectionid) {
this.logger.error('Could not create a websocket connection. Missing connection id header.')
@@ -25,10 +30,15 @@ export class WebSocketsController extends BaseHttpController {
return
}
await this.httpService.callAuthServer(request, response, `sockets/${request.headers.connectionid}`, request.body)
await this.httpService.callAuthServer(
request,
response,
`sockets/connections/${request.headers.connectionid}`,
request.body,
)
}
@httpDelete('/')
@httpDelete('/connections')
async deleteWebSocketConnection(request: Request, response: Response): Promise<void> {
if (!request.headers.connectionid) {
this.logger.error('Could not delete a websocket connection. Missing connection id header.')
@@ -38,6 +48,11 @@ export class WebSocketsController extends BaseHttpController {
return
}
await this.httpService.callAuthServer(request, response, `sockets/${request.headers.connectionid}`, request.body)
await this.httpService.callAuthServer(
request,
response,
`sockets/connections/${request.headers.connectionid}`,
request.body,
)
}
}

View File

@@ -66,5 +66,8 @@ SENTRY_ENVIRONMENT=
VALET_TOKEN_SECRET=
VALET_TOKEN_TTL=
WEB_SOCKET_CONNECTION_TOKEN_SECRET=
WEB_SOCKET_CONNECTION_TOKEN_TTL=
# (Optional) Analytics
ANALYTICS_ENABLED=false

View File

@@ -3,6 +3,106 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.32.11](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.32.10...@standardnotes/auth-server@1.32.11) (2022-09-28)
### Bug Fixes
* **auth:** prevent replacing files bytes used subscription setting upon renewal ([40e6733](https://github.com/standardnotes/server/commit/40e673379bb84bd21bcc8dbcb1aa36caaa2adbf8))
## [1.32.10](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.32.9...@standardnotes/auth-server@1.32.10) (2022-09-28)
### Bug Fixes
* **auth:** exclude legacy 5 year plans from subscription length statistics ([c5a07a8](https://github.com/standardnotes/server/commit/c5a07a888aadc22f62a92a236977c266f8d8e1c0))
## [1.32.9](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.32.8...@standardnotes/auth-server@1.32.9) (2022-09-28)
**Note:** Version bump only for package @standardnotes/auth-server
## [1.32.8](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.32.7...@standardnotes/auth-server@1.32.8) (2022-09-27)
### Bug Fixes
* **auth:** ttl for lock counter on login lockout ([54da5de](https://github.com/standardnotes/server/commit/54da5def4bbfbb4f74cbf02ae23e45103d250dd9))
## [1.32.7](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.32.6...@standardnotes/auth-server@1.32.7) (2022-09-27)
### Bug Fixes
* **auth:** allow resending canceled subscription invites ([b190931](https://github.com/standardnotes/server/commit/b19093179baaa1fb8cdf3f9d9bee20e625ed0b9b))
## [1.32.6](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.32.5...@standardnotes/auth-server@1.32.6) (2022-09-22)
### Reverts
* Revert "fix(auth): subscription token ttl" ([644c52a](https://github.com/standardnotes/server/commit/644c52ae36d3720dee0712e2cb826c7e617ab7b7))
* Revert "fix(auth): increase subscription token ttl" ([2554273](https://github.com/standardnotes/server/commit/2554273a3f85a968fed4286d109bed5413ef9908))
* Revert "tmp(auth): disable expiring of subscription tokens" ([a8ee149](https://github.com/standardnotes/server/commit/a8ee149d7ac78775bf447ab924458b116414a15e))
## [1.32.5](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.32.4...@standardnotes/auth-server@1.32.5) (2022-09-22)
**Note:** Version bump only for package @standardnotes/auth-server
## [1.32.4](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.32.3...@standardnotes/auth-server@1.32.4) (2022-09-22)
### Bug Fixes
* **auth:** increase subscription token ttl ([07def20](https://github.com/standardnotes/server/commit/07def20f6b47f9d1c678cfe5206b924dd5e6014a))
## [1.32.3](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.32.2...@standardnotes/auth-server@1.32.3) (2022-09-22)
### Bug Fixes
* **auth:** subscription token ttl ([6efd336](https://github.com/standardnotes/server/commit/6efd336f3407e7204a0c5d385ea9df5c02c7e5f5))
## [1.32.2](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.32.1...@standardnotes/auth-server@1.32.2) (2022-09-22)
### Bug Fixes
* **auth:** add throwing an error if the subscription token was not persisted ([76cee6d](https://github.com/standardnotes/server/commit/76cee6dbad9bff041d8d5a1d4435046509c14f71))
## [1.32.1](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.32.0...@standardnotes/auth-server@1.32.1) (2022-09-22)
### Bug Fixes
* **auth:** settings and subscription settings projection ([d6cf8d4](https://github.com/standardnotes/server/commit/d6cf8d400a0177ee9030a171cf2ca47ade293fd9))
# [1.32.0](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.31.2...@standardnotes/auth-server@1.32.0) (2022-09-22)
### Features
* **auth:** remove muting emails by use case in favor of updating user settings ([d3f36c0](https://github.com/standardnotes/server/commit/d3f36c05dfc114098a6c231d81149ebd1a959b74))
## [1.31.2](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.31.1...@standardnotes/auth-server@1.31.2) (2022-09-21)
### Bug Fixes
* **auth:** response wrapping on web socket connection token creation ([413a276](https://github.com/standardnotes/server/commit/413a276d205d53c316f7d0af8aed422001a6c1ab))
## [1.31.1](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.31.0...@standardnotes/auth-server@1.31.1) (2022-09-21)
### Bug Fixes
* **auth:** web sockets routes ([875edce](https://github.com/standardnotes/server/commit/875edce5b1dc134b4e22702354b29303fab3c910))
# [1.31.0](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.30.1...@standardnotes/auth-server@1.31.0) (2022-09-21)
### Features
* **auth:** add creating cross service token in exchange for web socket connection token ([965ae79](https://github.com/standardnotes/server/commit/965ae79414e25d0959f67e16dcbb054229013e1c))
## [1.30.1](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.30.0...@standardnotes/auth-server@1.30.1) (2022-09-21)
### Bug Fixes
* **auth:** missing injectable annotation ([d935157](https://github.com/standardnotes/server/commit/d935157ee8425d427fa52465e766d18e29332b5b))
# [1.30.0](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.29.1...@standardnotes/auth-server@1.30.0) (2022-09-21)
### Features
* **auth:** add creating web socket connection tokens ([8033177](https://github.com/standardnotes/server/commit/8033177f48dc961194f24fb7daa1073b8b697b74))
## [1.29.1](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.29.0...@standardnotes/auth-server@1.29.1) (2022-09-19)
### Bug Fixes

View File

@@ -10,7 +10,6 @@ import '../src/Controller/SessionsController'
import '../src/Controller/UsersController'
import '../src/Controller/SettingsController'
import '../src/Controller/FeaturesController'
import '../src/Controller/WebSocketsController'
import '../src/Controller/AdminController'
import '../src/Controller/InternalController'
import '../src/Controller/SubscriptionTokensController'
@@ -21,6 +20,7 @@ import '../src/Controller/SubscriptionSettingsController'
import '../src/Infra/InversifyExpressUtils/InversifyExpressAuthController'
import '../src/Infra/InversifyExpressUtils/InversifyExpressSubscriptionInvitesController'
import '../src/Infra/InversifyExpressUtils/InversifyExpressWebSocketsController'
import * as cors from 'cors'
import { urlencoded, json, Request, Response, NextFunction, RequestHandler, ErrorRequestHandler } from 'express'

View File

@@ -1,6 +1,6 @@
{
"name": "@standardnotes/auth-server",
"version": "1.29.1",
"version": "1.32.11",
"engines": {
"node": ">=16.0.0 <17.0.0"
},
@@ -34,11 +34,11 @@
"@newrelic/winston-enricher": "^4.0.0",
"@sentry/node": "^7.3.0",
"@standardnotes/analytics": "workspace:*",
"@standardnotes/api": "^1.7.2",
"@standardnotes/api": "^1.8.1",
"@standardnotes/common": "workspace:*",
"@standardnotes/domain-events": "workspace:*",
"@standardnotes/domain-events-infra": "workspace:*",
"@standardnotes/features": "^1.47.0",
"@standardnotes/features": "^1.52.1",
"@standardnotes/predicates": "workspace:*",
"@standardnotes/responses": "^1.6.39",
"@standardnotes/security": "workspace:*",

View File

@@ -143,7 +143,6 @@ import { ApiGatewayOfflineAuthMiddleware } from '../Controller/ApiGatewayOffline
import { UserEmailChangedEventHandler } from '../Domain/Handler/UserEmailChangedEventHandler'
import { SettingsAssociationServiceInterface } from '../Domain/Setting/SettingsAssociationServiceInterface'
import { SettingsAssociationService } from '../Domain/Setting/SettingsAssociationService'
import { MuteFailedBackupsEmails } from '../Domain/UseCase/MuteFailedBackupsEmails/MuteFailedBackupsEmails'
import { SubscriptionSyncRequestedEventHandler } from '../Domain/Handler/SubscriptionSyncRequestedEventHandler'
import {
CrossServiceTokenData,
@@ -156,13 +155,13 @@ import {
TokenEncoder,
TokenEncoderInterface,
ValetTokenData,
WebSocketConnectionTokenData,
} from '@standardnotes/security'
import { FileUploadedEventHandler } from '../Domain/Handler/FileUploadedEventHandler'
import { CreateValetToken } from '../Domain/UseCase/CreateValetToken/CreateValetToken'
import { CreateListedAccount } from '../Domain/UseCase/CreateListedAccount/CreateListedAccount'
import { ListedAccountCreatedEventHandler } from '../Domain/Handler/ListedAccountCreatedEventHandler'
import { ListedAccountDeletedEventHandler } from '../Domain/Handler/ListedAccountDeletedEventHandler'
import { MuteSignInEmails } from '../Domain/UseCase/MuteSignInEmails/MuteSignInEmails'
import { FileRemovedEventHandler } from '../Domain/Handler/FileRemovedEventHandler'
import { UserDisabledSessionUserAgentLoggingEventHandler } from '../Domain/Handler/UserDisabledSessionUserAgentLoggingEventHandler'
import { SettingInterpreterInterface } from '../Domain/Setting/SettingInterpreterInterface'
@@ -203,11 +202,14 @@ import { GetUserAnalyticsId } from '../Domain/UseCase/GetUserAnalyticsId/GetUser
import { AuthController } from '../Controller/AuthController'
import { VerifyPredicate } from '../Domain/UseCase/VerifyPredicate/VerifyPredicate'
import { PredicateVerificationRequestedEventHandler } from '../Domain/Handler/PredicateVerificationRequestedEventHandler'
import { MuteMarketingEmails } from '../Domain/UseCase/MuteMarketingEmails/MuteMarketingEmails'
import { PaymentFailedEventHandler } from '../Domain/Handler/PaymentFailedEventHandler'
import { PaymentSuccessEventHandler } from '../Domain/Handler/PaymentSuccessEventHandler'
import { RefundProcessedEventHandler } from '../Domain/Handler/RefundProcessedEventHandler'
import { SubscriptionInvitesController } from '../Controller/SubscriptionInvitesController'
import { CreateWebSocketConnectionToken } from '../Domain/UseCase/CreateWebSocketConnectionToken/CreateWebSocketConnectionToken'
import { WebSocketsController } from '../Controller/WebSocketsController'
import { WebSocketServerInterface } from '@standardnotes/api'
import { CreateCrossServiceToken } from '../Domain/UseCase/CreateCrossServiceToken/CreateCrossServiceToken'
// eslint-disable-next-line @typescript-eslint/no-var-requires
const newrelicFormatter = require('@newrelic/winston-enricher')
@@ -271,6 +273,7 @@ export class ContainerConfigLoader {
// Controller
container.bind<AuthController>(TYPES.AuthController).to(AuthController)
container.bind<SubscriptionInvitesController>(TYPES.SubscriptionInvitesController).to(SubscriptionInvitesController)
container.bind<WebSocketServerInterface>(TYPES.WebSocketsController).to(WebSocketsController)
// Repositories
container.bind<SessionRepositoryInterface>(TYPES.SessionRepository).to(MySQLSessionRepository)
@@ -369,6 +372,12 @@ export class ContainerConfigLoader {
container.bind(TYPES.AUTH_JWT_TTL).toConstantValue(+env.get('AUTH_JWT_TTL'))
container.bind(TYPES.VALET_TOKEN_SECRET).toConstantValue(env.get('VALET_TOKEN_SECRET', true))
container.bind(TYPES.VALET_TOKEN_TTL).toConstantValue(+env.get('VALET_TOKEN_TTL', true))
container
.bind(TYPES.WEB_SOCKET_CONNECTION_TOKEN_SECRET)
.toConstantValue(env.get('WEB_SOCKET_CONNECTION_TOKEN_SECRET', true))
container
.bind(TYPES.WEB_SOCKET_CONNECTION_TOKEN_TTL)
.toConstantValue(+env.get('WEB_SOCKET_CONNECTION_TOKEN_TTL', true))
container.bind(TYPES.ENCRYPTION_SERVER_KEY).toConstantValue(env.get('ENCRYPTION_SERVER_KEY'))
container.bind(TYPES.ACCESS_TOKEN_AGE).toConstantValue(env.get('ACCESS_TOKEN_AGE'))
container.bind(TYPES.REFRESH_TOKEN_AGE).toConstantValue(env.get('REFRESH_TOKEN_AGE'))
@@ -427,9 +436,6 @@ export class ContainerConfigLoader {
container
.bind<CreateOfflineSubscriptionToken>(TYPES.CreateOfflineSubscriptionToken)
.to(CreateOfflineSubscriptionToken)
container.bind<MuteFailedBackupsEmails>(TYPES.MuteFailedBackupsEmails).to(MuteFailedBackupsEmails)
container.bind<MuteSignInEmails>(TYPES.MuteSignInEmails).to(MuteSignInEmails)
container.bind<MuteMarketingEmails>(TYPES.MuteMarketingEmails).to(MuteMarketingEmails)
container.bind<CreateValetToken>(TYPES.CreateValetToken).to(CreateValetToken)
container.bind<CreateListedAccount>(TYPES.CreateListedAccount).to(CreateListedAccount)
container.bind<InviteToSharedSubscription>(TYPES.InviteToSharedSubscription).to(InviteToSharedSubscription)
@@ -448,6 +454,10 @@ export class ContainerConfigLoader {
container.bind<GetSubscriptionSetting>(TYPES.GetSubscriptionSetting).to(GetSubscriptionSetting)
container.bind<GetUserAnalyticsId>(TYPES.GetUserAnalyticsId).to(GetUserAnalyticsId)
container.bind<VerifyPredicate>(TYPES.VerifyPredicate).to(VerifyPredicate)
container
.bind<CreateWebSocketConnectionToken>(TYPES.CreateWebSocketConnectionToken)
.to(CreateWebSocketConnectionToken)
container.bind<CreateCrossServiceToken>(TYPES.CreateCrossServiceToken).to(CreateCrossServiceToken)
// Handlers
container.bind<UserRegisteredEventHandler>(TYPES.UserRegisteredEventHandler).to(UserRegisteredEventHandler)
@@ -520,6 +530,11 @@ export class ContainerConfigLoader {
container
.bind<TokenDecoderInterface<OfflineUserTokenData>>(TYPES.OfflineUserTokenDecoder)
.toConstantValue(new TokenDecoder<OfflineUserTokenData>(container.get(TYPES.AUTH_JWT_SECRET)))
container
.bind<TokenDecoderInterface<WebSocketConnectionTokenData>>(TYPES.WebSocketConnectionTokenDecoder)
.toConstantValue(
new TokenDecoder<WebSocketConnectionTokenData>(container.get(TYPES.WEB_SOCKET_CONNECTION_TOKEN_SECRET)),
)
container
.bind<TokenEncoderInterface<OfflineUserTokenData>>(TYPES.OfflineUserTokenEncoder)
.toConstantValue(new TokenEncoder<OfflineUserTokenData>(container.get(TYPES.AUTH_JWT_SECRET)))
@@ -532,6 +547,11 @@ export class ContainerConfigLoader {
container
.bind<TokenEncoderInterface<ValetTokenData>>(TYPES.ValetTokenEncoder)
.toConstantValue(new TokenEncoder<ValetTokenData>(container.get(TYPES.VALET_TOKEN_SECRET)))
container
.bind<TokenEncoderInterface<WebSocketConnectionTokenData>>(TYPES.WebSocketConnectionTokenEncoder)
.toConstantValue(
new TokenEncoder<WebSocketConnectionTokenData>(container.get(TYPES.WEB_SOCKET_CONNECTION_TOKEN_SECRET)),
)
container.bind<AuthenticationMethodResolver>(TYPES.AuthenticationMethodResolver).to(AuthenticationMethodResolver)
container.bind<DomainEventFactory>(TYPES.DomainEventFactory).to(DomainEventFactory)
container.bind<AxiosInstance>(TYPES.HTTPClient).toConstantValue(axios.create())

View File

@@ -6,6 +6,7 @@ const TYPES = {
// Controller
AuthController: Symbol.for('AuthController'),
SubscriptionInvitesController: Symbol.for('SubscriptionInvitesController'),
WebSocketsController: Symbol.for('WebSocketsController'),
// Repositories
UserRepository: Symbol.for('UserRepository'),
SessionRepository: Symbol.for('SessionRepository'),
@@ -60,6 +61,8 @@ const TYPES = {
AUTH_JWT_TTL: Symbol.for('AUTH_JWT_TTL'),
VALET_TOKEN_SECRET: Symbol.for('VALET_TOKEN_SECRET'),
VALET_TOKEN_TTL: Symbol.for('VALET_TOKEN_TTL'),
WEB_SOCKET_CONNECTION_TOKEN_SECRET: Symbol.for('WEB_SOCKET_CONNECTION_TOKEN_SECRET'),
WEB_SOCKET_CONNECTION_TOKEN_TTL: Symbol.for('WEB_SOCKET_CONNECTION_TOKEN_TTL'),
ENCRYPTION_SERVER_KEY: Symbol.for('ENCRYPTION_SERVER_KEY'),
ACCESS_TOKEN_AGE: Symbol.for('ACCESS_TOKEN_AGE'),
REFRESH_TOKEN_AGE: Symbol.for('REFRESH_TOKEN_AGE'),
@@ -112,9 +115,6 @@ const TYPES = {
AuthenticateSubscriptionToken: Symbol.for('AuthenticateSubscriptionToken'),
CreateOfflineSubscriptionToken: Symbol.for('CreateOfflineSubscriptionToken'),
AuthenticateOfflineSubscriptionToken: Symbol.for('AuthenticateOfflineSubscriptionToken'),
MuteFailedBackupsEmails: Symbol.for('MuteFailedBackupsEmails'),
MuteSignInEmails: Symbol.for('MuteSignInEmails'),
MuteMarketingEmails: Symbol.for('MuteMarketingEmails'),
CreateValetToken: Symbol.for('CreateValetToken'),
CreateListedAccount: Symbol.for('CreateListedAccount'),
InviteToSharedSubscription: Symbol.for('InviteToSharedSubscription'),
@@ -125,6 +125,8 @@ const TYPES = {
GetSubscriptionSetting: Symbol.for('GetSubscriptionSetting'),
GetUserAnalyticsId: Symbol.for('GetUserAnalyticsId'),
VerifyPredicate: Symbol.for('VerifyPredicate'),
CreateWebSocketConnectionToken: Symbol.for('CreateWebSocketConnectionToken'),
CreateCrossServiceToken: Symbol.for('CreateCrossServiceToken'),
// Handlers
UserRegisteredEventHandler: Symbol.for('UserRegisteredEventHandler'),
AccountDeletionRequestedEventHandler: Symbol.for('AccountDeletionRequestedEventHandler'),
@@ -166,6 +168,8 @@ const TYPES = {
CrossServiceTokenEncoder: Symbol.for('CrossServiceTokenEncoder'),
SessionTokenEncoder: Symbol.for('SessionTokenEncoder'),
ValetTokenEncoder: Symbol.for('ValetTokenEncoder'),
WebSocketConnectionTokenEncoder: Symbol.for('WebSocketConnectionTokenEncoder'),
WebSocketConnectionTokenDecoder: Symbol.for('WebSocketConnectionTokenDecoder'),
AuthenticationMethodResolver: Symbol.for('AuthenticationMethodResolver'),
DomainEventPublisher: Symbol.for('DomainEventPublisher'),
DomainEventSubscriberFactory: Symbol.for('DomainEventSubscriberFactory'),

View File

@@ -7,23 +7,16 @@ import { results } from 'inversify-express-utils'
import { User } from '../Domain/User/User'
import { GetUserFeatures } from '../Domain/UseCase/GetUserFeatures/GetUserFeatures'
import { GetSetting } from '../Domain/UseCase/GetSetting/GetSetting'
import { MuteFailedBackupsEmails } from '../Domain/UseCase/MuteFailedBackupsEmails/MuteFailedBackupsEmails'
import { MuteSignInEmails } from '../Domain/UseCase/MuteSignInEmails/MuteSignInEmails'
import { MuteMarketingEmails } from '../Domain/UseCase/MuteMarketingEmails/MuteMarketingEmails'
describe('InternalController', () => {
let getUserFeatures: GetUserFeatures
let getSetting: GetSetting
let muteFailedBackupsEmails: MuteFailedBackupsEmails
let muteSignInEmails: MuteSignInEmails
let muteMarketingEmails: MuteMarketingEmails
let request: express.Request
let response: express.Response
let user: User
const createController = () =>
new InternalController(getUserFeatures, getSetting, muteFailedBackupsEmails, muteSignInEmails, muteMarketingEmails)
const createController = () => new InternalController(getUserFeatures, getSetting)
beforeEach(() => {
user = {} as jest.Mocked<User>
@@ -35,15 +28,6 @@ describe('InternalController', () => {
getSetting = {} as jest.Mocked<GetSetting>
getSetting.execute = jest.fn()
muteFailedBackupsEmails = {} as jest.Mocked<MuteFailedBackupsEmails>
muteFailedBackupsEmails.execute = jest.fn()
muteSignInEmails = {} as jest.Mocked<MuteSignInEmails>
muteSignInEmails.execute = jest.fn()
muteMarketingEmails = {} as jest.Mocked<MuteMarketingEmails>
muteMarketingEmails.execute = jest.fn()
request = {
headers: {},
body: {},
@@ -120,83 +104,4 @@ describe('InternalController', () => {
expect(result.statusCode).toEqual(400)
})
it('should mute failed backup emails user setting', async () => {
request.params.settingUuid = '1-2-3'
muteFailedBackupsEmails.execute = jest.fn().mockReturnValue({ success: true })
const httpResponse = <results.JsonResult>await createController().muteFailedBackupsEmails(request)
const result = await httpResponse.executeAsync()
expect(muteFailedBackupsEmails.execute).toHaveBeenCalledWith({ settingUuid: '1-2-3' })
expect(result.statusCode).toEqual(200)
})
it('should not mute failed backup emails user setting if it does not exist', async () => {
request.params.settingUuid = '1-2-3'
muteFailedBackupsEmails.execute = jest.fn().mockReturnValue({ success: false })
const httpResponse = <results.JsonResult>await createController().muteFailedBackupsEmails(request)
const result = await httpResponse.executeAsync()
expect(muteFailedBackupsEmails.execute).toHaveBeenCalledWith({ settingUuid: '1-2-3' })
expect(result.statusCode).toEqual(404)
})
it('should mute sign in emails user setting', async () => {
request.params.settingUuid = '1-2-3'
muteSignInEmails.execute = jest.fn().mockReturnValue({ success: true })
const httpResponse = <results.JsonResult>await createController().muteSignInEmails(request)
const result = await httpResponse.executeAsync()
expect(muteSignInEmails.execute).toHaveBeenCalledWith({ settingUuid: '1-2-3' })
expect(result.statusCode).toEqual(200)
})
it('should not mute sign in emails user setting if it does not exist', async () => {
request.params.settingUuid = '1-2-3'
muteSignInEmails.execute = jest.fn().mockReturnValue({ success: false })
const httpResponse = <results.JsonResult>await createController().muteSignInEmails(request)
const result = await httpResponse.executeAsync()
expect(muteSignInEmails.execute).toHaveBeenCalledWith({ settingUuid: '1-2-3' })
expect(result.statusCode).toEqual(404)
})
it('should mute marketing emails user setting', async () => {
request.params.settingUuid = '1-2-3'
muteMarketingEmails.execute = jest.fn().mockReturnValue({ success: true, message: 'foobar' })
await createController().muteMarketingEmails(request, response)
expect(muteMarketingEmails.execute).toHaveBeenCalledWith({ settingUuid: '1-2-3' })
expect(response.setHeader).toHaveBeenCalledWith('content-type', 'text/html')
expect(response.send).toHaveBeenCalledWith('foobar')
})
it('should not mute marketing emails user setting if it does not exist', async () => {
request.params.settingUuid = '1-2-3'
muteMarketingEmails.execute = jest.fn().mockReturnValue({ success: false, message: 'foobar' })
await createController().muteMarketingEmails(request, response)
expect(muteMarketingEmails.execute).toHaveBeenCalledWith({ settingUuid: '1-2-3' })
expect(response.setHeader).toHaveBeenCalledWith('content-type', 'text/html')
expect(response.status).toHaveBeenCalledWith(404)
expect(response.send).toHaveBeenCalledWith('foobar')
})
})

View File

@@ -1,4 +1,4 @@
import { Request, Response } from 'express'
import { Request } from 'express'
import { inject } from 'inversify'
import {
BaseHttpController,
@@ -10,18 +10,12 @@ import {
import TYPES from '../Bootstrap/Types'
import { GetSetting } from '../Domain/UseCase/GetSetting/GetSetting'
import { GetUserFeatures } from '../Domain/UseCase/GetUserFeatures/GetUserFeatures'
import { MuteFailedBackupsEmails } from '../Domain/UseCase/MuteFailedBackupsEmails/MuteFailedBackupsEmails'
import { MuteMarketingEmails } from '../Domain/UseCase/MuteMarketingEmails/MuteMarketingEmails'
import { MuteSignInEmails } from '../Domain/UseCase/MuteSignInEmails/MuteSignInEmails'
@controller('/internal')
export class InternalController extends BaseHttpController {
constructor(
@inject(TYPES.GetUserFeatures) private doGetUserFeatures: GetUserFeatures,
@inject(TYPES.GetSetting) private doGetSetting: GetSetting,
@inject(TYPES.MuteFailedBackupsEmails) private doMuteFailedBackupsEmails: MuteFailedBackupsEmails,
@inject(TYPES.MuteSignInEmails) private doMuteSignInEmails: MuteSignInEmails,
@inject(TYPES.MuteMarketingEmails) private doMuteMarketingEmails: MuteMarketingEmails,
) {
super()
}
@@ -54,50 +48,4 @@ export class InternalController extends BaseHttpController {
return this.json(result, 400)
}
@httpGet('/settings/email_backup/:settingUuid/mute')
async muteFailedBackupsEmails(request: Request): Promise<results.JsonResult> {
const { settingUuid } = request.params
const result = await this.doMuteFailedBackupsEmails.execute({
settingUuid,
})
if (result.success) {
return this.json({ message: result.message })
}
return this.json({ message: result.message }, 404)
}
@httpGet('/settings/sign_in/:settingUuid/mute')
async muteSignInEmails(request: Request): Promise<results.JsonResult> {
const { settingUuid } = request.params
const result = await this.doMuteSignInEmails.execute({
settingUuid,
})
if (result.success) {
return this.json({ message: result.message })
}
return this.json({ message: result.message }, 404)
}
@httpGet('/settings/marketing-emails/:settingUuid/mute')
async muteMarketingEmails(request: Request, response: Response): Promise<void> {
const { settingUuid } = request.params
const result = await this.doMuteMarketingEmails.execute({
settingUuid,
})
response.setHeader('content-type', 'text/html')
if (result.success) {
response.send(result.message)
return
}
response.status(404).send(result.message)
}
}

View File

@@ -9,43 +9,25 @@ import { ProjectorInterface } from '../Projection/ProjectorInterface'
import { GetActiveSessionsForUser } from '../Domain/UseCase/GetActiveSessionsForUser'
import { AuthenticateRequest } from '../Domain/UseCase/AuthenticateRequest'
import { User } from '../Domain/User/User'
import { Role } from '../Domain/Role/Role'
import { CrossServiceTokenData, TokenEncoderInterface } from '@standardnotes/security'
import { GetUserAnalyticsId } from '../Domain/UseCase/GetUserAnalyticsId/GetUserAnalyticsId'
import { CreateCrossServiceToken } from '../Domain/UseCase/CreateCrossServiceToken/CreateCrossServiceToken'
describe('SessionsController', () => {
let getActiveSessionsForUser: GetActiveSessionsForUser
let authenticateRequest: AuthenticateRequest
let userProjector: ProjectorInterface<User>
let tokenEncoder: TokenEncoderInterface<CrossServiceTokenData>
const jwtTTL = 60
let sessionProjector: ProjectorInterface<Session>
let roleProjector: ProjectorInterface<Role>
let session: Session
let request: express.Request
let response: express.Response
let user: User
let role: Role
let getUserAnalyticsId: GetUserAnalyticsId
let createCrossServiceToken: CreateCrossServiceToken
const createController = () =>
new SessionsController(
getActiveSessionsForUser,
authenticateRequest,
userProjector,
sessionProjector,
roleProjector,
tokenEncoder,
getUserAnalyticsId,
true,
jwtTTL,
)
new SessionsController(getActiveSessionsForUser, authenticateRequest, sessionProjector, createCrossServiceToken)
beforeEach(() => {
session = {} as jest.Mocked<Session>
user = {} as jest.Mocked<User>
user.roles = Promise.resolve([role])
getActiveSessionsForUser = {} as jest.Mocked<GetActiveSessionsForUser>
getActiveSessionsForUser.execute = jest.fn().mockReturnValue({ sessions: [session] })
@@ -53,21 +35,11 @@ describe('SessionsController', () => {
authenticateRequest = {} as jest.Mocked<AuthenticateRequest>
authenticateRequest.execute = jest.fn()
userProjector = {} as jest.Mocked<ProjectorInterface<User>>
userProjector.projectSimple = jest.fn().mockReturnValue({ bar: 'baz' })
roleProjector = {} as jest.Mocked<ProjectorInterface<Role>>
roleProjector.projectSimple = jest.fn().mockReturnValue({ name: 'role1', uuid: '1-3-4' })
sessionProjector = {} as jest.Mocked<ProjectorInterface<Session>>
sessionProjector.projectCustom = jest.fn().mockReturnValue({ foo: 'bar' })
sessionProjector.projectSimple = jest.fn().mockReturnValue({ test: 'test' })
tokenEncoder = {} as jest.Mocked<TokenEncoderInterface<CrossServiceTokenData>>
tokenEncoder.encodeExpirableToken = jest.fn().mockReturnValue('foobar')
getUserAnalyticsId = {} as jest.Mocked<GetUserAnalyticsId>
getUserAnalyticsId.execute = jest.fn().mockReturnValue({ analyticsId: 123 })
createCrossServiceToken = {} as jest.Mocked<CreateCrossServiceToken>
createCrossServiceToken.execute = jest.fn().mockReturnValue({ token: 'foobar' })
request = {
params: {},
@@ -114,75 +86,6 @@ describe('SessionsController', () => {
const httpResponseContent = await result.content.readAsStringAsync()
const httpResponseJSON = JSON.parse(httpResponseContent)
expect(tokenEncoder.encodeExpirableToken).toHaveBeenCalledWith(
{
analyticsId: 123,
roles: [
{
name: 'role1',
uuid: '1-3-4',
},
],
session: {
test: 'test',
},
user: {
bar: 'baz',
},
},
60,
)
expect(httpResponseJSON.authToken).toEqual('foobar')
})
it('should validate a session from an incoming request - disabled analytics', async () => {
authenticateRequest.execute = jest.fn().mockReturnValue({
success: true,
user,
session,
})
request.headers.authorization = 'test'
const controller = new SessionsController(
getActiveSessionsForUser,
authenticateRequest,
userProjector,
sessionProjector,
roleProjector,
tokenEncoder,
getUserAnalyticsId,
false,
jwtTTL,
)
const httpResponse = await controller.validate(request)
expect(httpResponse).toBeInstanceOf(results.JsonResult)
const result = await httpResponse.executeAsync()
const httpResponseContent = await result.content.readAsStringAsync()
const httpResponseJSON = JSON.parse(httpResponseContent)
expect(tokenEncoder.encodeExpirableToken).toHaveBeenCalledWith(
{
roles: [
{
name: 'role1',
uuid: '1-3-4',
},
],
session: {
test: 'test',
},
user: {
bar: 'baz',
},
},
60,
)
expect(httpResponseJSON.authToken).toEqual('foobar')
})

View File

@@ -12,26 +12,18 @@ import TYPES from '../Bootstrap/Types'
import { Session } from '../Domain/Session/Session'
import { AuthenticateRequest } from '../Domain/UseCase/AuthenticateRequest'
import { GetActiveSessionsForUser } from '../Domain/UseCase/GetActiveSessionsForUser'
import { Role } from '../Domain/Role/Role'
import { User } from '../Domain/User/User'
import { ProjectorInterface } from '../Projection/ProjectorInterface'
import { SessionProjector } from '../Projection/SessionProjector'
import { CrossServiceTokenData, TokenEncoderInterface } from '@standardnotes/security'
import { RoleName } from '@standardnotes/common'
import { GetUserAnalyticsId } from '../Domain/UseCase/GetUserAnalyticsId/GetUserAnalyticsId'
import { CreateCrossServiceToken } from '../Domain/UseCase/CreateCrossServiceToken/CreateCrossServiceToken'
@controller('/sessions')
export class SessionsController extends BaseHttpController {
constructor(
@inject(TYPES.GetActiveSessionsForUser) private getActiveSessionsForUser: GetActiveSessionsForUser,
@inject(TYPES.AuthenticateRequest) private authenticateRequest: AuthenticateRequest,
@inject(TYPES.UserProjector) private userProjector: ProjectorInterface<User>,
@inject(TYPES.SessionProjector) private sessionProjector: ProjectorInterface<Session>,
@inject(TYPES.RoleProjector) private roleProjector: ProjectorInterface<Role>,
@inject(TYPES.CrossServiceTokenEncoder) private tokenEncoder: TokenEncoderInterface<CrossServiceTokenData>,
@inject(TYPES.GetUserAnalyticsId) private getUserAnalyticsId: GetUserAnalyticsId,
@inject(TYPES.ANALYTICS_ENABLED) private analyticsEnabled: boolean,
@inject(TYPES.AUTH_JWT_TTL) private jwtTTL: number,
@inject(TYPES.CreateCrossServiceToken) private createCrossServiceToken: CreateCrossServiceToken,
) {
super()
}
@@ -56,25 +48,12 @@ export class SessionsController extends BaseHttpController {
const user = authenticateRequestResponse.user as User
const roles = await user.roles
const result = await this.createCrossServiceToken.execute({
user,
session: authenticateRequestResponse.session,
})
const authTokenData: CrossServiceTokenData = {
user: this.projectUser(user),
roles: this.projectRoles(roles),
}
if (this.analyticsEnabled) {
const { analyticsId } = await this.getUserAnalyticsId.execute({ userUuid: user.uuid })
authTokenData.analyticsId = analyticsId
}
if (authenticateRequestResponse.session !== undefined) {
authTokenData.session = this.projectSession(authenticateRequestResponse.session)
}
const authToken = this.tokenEncoder.encodeExpirableToken(authTokenData, this.jwtTTL)
return this.json({ authToken })
return this.json({ authToken: result.token })
}
@httpGet('/', TYPES.AuthMiddleware, TYPES.SessionMiddleware)
@@ -93,36 +72,4 @@ export class SessionsController extends BaseHttpController {
),
)
}
private projectUser(user: User): { uuid: string; email: string } {
return <{ uuid: string; email: string }>this.userProjector.projectSimple(user)
}
private projectSession(session: Session): {
uuid: string
api_version: string
created_at: string
updated_at: string
device_info: string
readonly_access: boolean
access_expiration: string
refresh_expiration: string
} {
return <
{
uuid: string
api_version: string
created_at: string
updated_at: string
device_info: string
readonly_access: boolean
access_expiration: string
refresh_expiration: string
}
>this.sessionProjector.projectSimple(session)
}
private projectRoles(roles: Array<Role>): Array<{ uuid: string; name: RoleName }> {
return roles.map((role) => <{ uuid: string; name: RoleName }>this.roleProjector.projectSimple(role))
}
}

View File

@@ -1,65 +1,28 @@
import 'reflect-metadata'
import * as express from 'express'
import { results } from 'inversify-express-utils'
import { AddWebSocketsConnection } from '../Domain/UseCase/AddWebSocketsConnection/AddWebSocketsConnection'
import { WebSocketsController } from './WebSocketsController'
import { RemoveWebSocketsConnection } from '../Domain/UseCase/RemoveWebSocketsConnection/RemoveWebSocketsConnection'
import { CreateWebSocketConnectionToken } from '../Domain/UseCase/CreateWebSocketConnectionToken/CreateWebSocketConnectionToken'
describe('WebSocketsController', () => {
let addWebSocketsConnection: AddWebSocketsConnection
let removeWebSocketsConnection: RemoveWebSocketsConnection
let request: express.Request
let response: express.Response
let createWebSocketConnectionToken: CreateWebSocketConnectionToken
const createController = () => new WebSocketsController(addWebSocketsConnection, removeWebSocketsConnection)
const createController = () => new WebSocketsController(createWebSocketConnectionToken)
beforeEach(() => {
addWebSocketsConnection = {} as jest.Mocked<AddWebSocketsConnection>
addWebSocketsConnection.execute = jest.fn()
removeWebSocketsConnection = {} as jest.Mocked<RemoveWebSocketsConnection>
removeWebSocketsConnection.execute = jest.fn()
request = {
body: {
userUuid: '1-2-3',
},
params: {},
headers: {},
} as jest.Mocked<express.Request>
request.params.connectionId = '2-3-4'
response = {
locals: {},
} as jest.Mocked<express.Response>
response.locals.user = {
uuid: '1-2-3',
}
createWebSocketConnectionToken = {} as jest.Mocked<CreateWebSocketConnectionToken>
createWebSocketConnectionToken.execute = jest.fn().mockReturnValue({ token: 'foobar' })
})
it('should persist an established web sockets connection', async () => {
const httpResponse = await createController().storeWebSocketsConnection(request, response)
it('should create a web sockets connection token', async () => {
const response = await createController().createConnectionToken({ userUuid: '1-2-3' })
expect(httpResponse).toBeInstanceOf(results.JsonResult)
expect((<results.JsonResult>httpResponse).statusCode).toEqual(200)
expect(addWebSocketsConnection.execute).toHaveBeenCalledWith({
userUuid: '1-2-3',
connectionId: '2-3-4',
expect(response).toEqual({
status: 200,
data: { token: 'foobar' },
})
})
it('should remove a disconnected web sockets connection', async () => {
const httpResponse = await createController().deleteWebSocketsConnection(request)
expect(httpResponse).toBeInstanceOf(results.JsonResult)
expect((<results.JsonResult>httpResponse).statusCode).toEqual(200)
expect(removeWebSocketsConnection.execute).toHaveBeenCalledWith({
connectionId: '2-3-4',
expect(createWebSocketConnectionToken.execute).toHaveBeenCalledWith({
userUuid: '1-2-3',
})
})
})

View File

@@ -1,45 +1,29 @@
import { Request, Response } from 'express'
import { inject } from 'inversify'
import {
BaseHttpController,
controller,
httpDelete,
httpPost,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
results,
} from 'inversify-express-utils'
HttpStatusCode,
WebSocketConnectionTokenRequestParams,
WebSocketConnectionTokenResponse,
WebSocketServerInterface,
} from '@standardnotes/api'
import { inject, injectable } from 'inversify'
import TYPES from '../Bootstrap/Types'
import { AddWebSocketsConnection } from '../Domain/UseCase/AddWebSocketsConnection/AddWebSocketsConnection'
import { RemoveWebSocketsConnection } from '../Domain/UseCase/RemoveWebSocketsConnection/RemoveWebSocketsConnection'
import { CreateWebSocketConnectionToken } from '../Domain/UseCase/CreateWebSocketConnectionToken/CreateWebSocketConnectionToken'
@controller('/sockets')
export class WebSocketsController extends BaseHttpController {
@injectable()
export class WebSocketsController implements WebSocketServerInterface {
constructor(
@inject(TYPES.AddWebSocketsConnection) private addWebSocketsConnection: AddWebSocketsConnection,
@inject(TYPES.RemoveWebSocketsConnection) private removeWebSocketsConnection: RemoveWebSocketsConnection,
) {
super()
}
@inject(TYPES.CreateWebSocketConnectionToken)
private createWebSocketConnectionToken: CreateWebSocketConnectionToken,
) {}
@httpPost('/:connectionId', TYPES.ApiGatewayAuthMiddleware)
async storeWebSocketsConnection(
request: Request,
response: Response,
): Promise<results.JsonResult | results.BadRequestErrorMessageResult> {
await this.addWebSocketsConnection.execute({
userUuid: response.locals.user.uuid,
connectionId: request.params.connectionId,
})
async createConnectionToken(
params: WebSocketConnectionTokenRequestParams,
): Promise<WebSocketConnectionTokenResponse> {
const result = await this.createWebSocketConnectionToken.execute({ userUuid: params.userUuid as string })
return this.json({ success: true })
}
@httpDelete('/:connectionId')
async deleteWebSocketsConnection(
request: Request,
): Promise<results.JsonResult | results.BadRequestErrorMessageResult> {
await this.removeWebSocketsConnection.execute({ connectionId: request.params.connectionId })
return this.json({ success: true })
return {
status: HttpStatusCode.Success,
data: result,
}
}
}

View File

@@ -87,6 +87,20 @@ describe('SubscriptionCancelledEventHandler', () => {
])
})
it('should not track statistics for subscriptions that are in a legacy 5 year plan', async () => {
event.payload.timestamp = 1642395451516000
const userSubscription = {
createdAt: 1642395451515000,
endsAt: 1642395451515000 + 126_230_400_000_001,
} as jest.Mocked<UserSubscription>
userSubscriptionRepository.findBySubscriptionId = jest.fn().mockReturnValue([userSubscription])
await createHandler().handle(event)
expect(statisticsStore.incrementMeasure).not.toHaveBeenCalled()
})
it('should update subscription cancelled - user not found', async () => {
userRepository.findOneByEmail = jest.fn().mockReturnValue(null)

View File

@@ -35,31 +35,10 @@ export class SubscriptionCancelledEventHandler implements DomainEventHandlerInte
Period.ThisWeek,
Period.ThisMonth,
])
const subscriptions = await this.userSubscriptionRepository.findBySubscriptionId(event.payload.subscriptionId)
if (subscriptions.length !== 0) {
const lastSubscription = subscriptions.shift() as UserSubscription
const subscriptionLength = event.payload.timestamp - lastSubscription.createdAt
await this.statisticsStore.incrementMeasure(StatisticsMeasure.SubscriptionLength, subscriptionLength, [
Period.Today,
Period.ThisWeek,
Period.ThisMonth,
])
const lastPurchaseTime = lastSubscription.renewedAt ?? lastSubscription.updatedAt
const remainingSubscriptionTime = lastSubscription.endsAt - event.payload.timestamp
const totalSubscriptionTime = lastSubscription.endsAt - lastPurchaseTime
const remainingSubscriptionPercentage = Math.floor((remainingSubscriptionTime / totalSubscriptionTime) * 100)
await this.statisticsStore.incrementMeasure(
StatisticsMeasure.RemainingSubscriptionTimePercentage,
remainingSubscriptionPercentage,
[Period.Today, Period.ThisWeek, Period.ThisMonth],
)
}
}
await this.trackSubscriptionStatistics(event)
if (event.payload.offline) {
await this.updateOfflineSubscriptionCancelled(event.payload.subscriptionId, event.payload.timestamp)
@@ -76,4 +55,39 @@ export class SubscriptionCancelledEventHandler implements DomainEventHandlerInte
private async updateOfflineSubscriptionCancelled(subscriptionId: number, timestamp: number): Promise<void> {
await this.offlineUserSubscriptionRepository.updateCancelled(subscriptionId, true, timestamp)
}
private async trackSubscriptionStatistics(event: SubscriptionCancelledEvent) {
const subscriptions = await this.userSubscriptionRepository.findBySubscriptionId(event.payload.subscriptionId)
if (subscriptions.length !== 0) {
const lastSubscription = subscriptions.shift() as UserSubscription
if (this.isLegacy5yearSubscriptionPlan(lastSubscription)) {
return
}
const subscriptionLength = event.payload.timestamp - lastSubscription.createdAt
await this.statisticsStore.incrementMeasure(StatisticsMeasure.SubscriptionLength, subscriptionLength, [
Period.Today,
Period.ThisWeek,
Period.ThisMonth,
])
const lastPurchaseTime = lastSubscription.renewedAt ?? lastSubscription.updatedAt
const remainingSubscriptionTime = lastSubscription.endsAt - event.payload.timestamp
const totalSubscriptionTime = lastSubscription.endsAt - lastPurchaseTime
const remainingSubscriptionPercentage = Math.floor((remainingSubscriptionTime / totalSubscriptionTime) * 100)
await this.statisticsStore.incrementMeasure(
StatisticsMeasure.RemainingSubscriptionTimePercentage,
remainingSubscriptionPercentage,
[Period.Today, Period.ThisWeek, Period.ThisMonth],
)
}
}
private isLegacy5yearSubscriptionPlan(subscription: UserSubscription) {
const fourYearsInMicroseconds = 126_230_400_000_000
return subscription.endsAt - subscription.createdAt > fourYearsInMicroseconds
}
}

View File

@@ -4,4 +4,5 @@ export type SettingDescription = {
value: string
sensitive: boolean
serverEncryptionVersion: EncryptionVersion
replaceable: boolean
}

View File

@@ -52,6 +52,7 @@ export class SettingsAssociationService implements SettingsAssociationServiceInt
sensitive: false,
serverEncryptionVersion: EncryptionVersion.Unencrypted,
value: MuteSignInEmailsOption.NotMuted,
replaceable: false,
},
],
[
@@ -60,6 +61,7 @@ export class SettingsAssociationService implements SettingsAssociationServiceInt
sensitive: false,
serverEncryptionVersion: EncryptionVersion.Unencrypted,
value: MuteMarketingEmailsOption.NotMuted,
replaceable: false,
},
],
[
@@ -68,6 +70,7 @@ export class SettingsAssociationService implements SettingsAssociationServiceInt
sensitive: false,
serverEncryptionVersion: EncryptionVersion.Unencrypted,
value: LogSessionUserAgentOption.Enabled,
replaceable: false,
},
],
])
@@ -79,6 +82,7 @@ export class SettingsAssociationService implements SettingsAssociationServiceInt
sensitive: false,
serverEncryptionVersion: EncryptionVersion.Unencrypted,
value: LogSessionUserAgentOption.Disabled,
replaceable: false,
},
],
])

View File

@@ -60,6 +60,7 @@ describe('SubscriptionSettingService', () => {
value: '0',
sensitive: 0,
serverEncryptionVersion: EncryptionVersion.Unencrypted,
replaceable: true,
},
],
]),
@@ -80,6 +81,48 @@ describe('SubscriptionSettingService', () => {
expect(subscriptionSettingRepository.save).toHaveBeenCalledWith(setting)
})
it('should not replace existing default settings for a subscription if it is not replaceable', async () => {
subscriptionSettingsAssociationService.getDefaultSettingsAndValuesForSubscriptionName = jest.fn().mockReturnValue(
new Map([
[
SubscriptionSettingName.FileUploadBytesUsed,
{
value: '0',
sensitive: 0,
serverEncryptionVersion: EncryptionVersion.Unencrypted,
replaceable: false,
},
],
]),
)
subscriptionSettingRepository.findLastByNameAndUserSubscriptionUuid = jest.fn().mockReturnValue(setting)
await createService().applyDefaultSubscriptionSettingsForSubscription(userSubscription, SubscriptionName.PlusPlan)
expect(subscriptionSettingRepository.save).not.toHaveBeenCalled()
})
it('should create default settings for a subscription if it is not replaceable and not existing', async () => {
subscriptionSettingsAssociationService.getDefaultSettingsAndValuesForSubscriptionName = jest.fn().mockReturnValue(
new Map([
[
SubscriptionSettingName.FileUploadBytesUsed,
{
value: '0',
sensitive: 0,
serverEncryptionVersion: EncryptionVersion.Unencrypted,
replaceable: false,
},
],
]),
)
subscriptionSettingRepository.findLastByNameAndUserSubscriptionUuid = jest.fn().mockReturnValue(null)
await createService().applyDefaultSubscriptionSettingsForSubscription(userSubscription, SubscriptionName.PlusPlan)
expect(subscriptionSettingRepository.save).toHaveBeenCalledWith(setting)
})
it('should not create default settings for a subscription if subscription has no defaults', async () => {
subscriptionSettingsAssociationService.getDefaultSettingsAndValuesForSubscriptionName = jest
.fn()

View File

@@ -43,6 +43,15 @@ export class SubscriptionSettingService implements SubscriptionSettingServiceInt
for (const settingName of defaultSettingsWithValues.keys()) {
const setting = defaultSettingsWithValues.get(settingName) as SettingDescription
if (!setting.replaceable) {
const existingSetting = await this.subscriptionSettingRepository.findLastByNameAndUserSubscriptionUuid(
settingName,
userSubscription.uuid,
)
if (existingSetting !== null) {
continue
}
}
await this.createOrReplace({
userSubscription,

View File

@@ -61,6 +61,7 @@ describe('SubscriptionSettingsAssociationService', () => {
sensitive: false,
serverEncryptionVersion: 0,
value: '107374182400',
replaceable: true,
})
})
@@ -88,6 +89,7 @@ describe('SubscriptionSettingsAssociationService', () => {
sensitive: false,
serverEncryptionVersion: 0,
value: '104857600',
replaceable: true,
})
})

View File

@@ -28,7 +28,7 @@ export class SubscriptionSettingsAssociationService implements SubscriptionSetti
new Map([
[
SubscriptionSettingName.FileUploadBytesUsed,
{ sensitive: false, serverEncryptionVersion: EncryptionVersion.Unencrypted, value: '0' },
{ sensitive: false, serverEncryptionVersion: EncryptionVersion.Unencrypted, value: '0', replaceable: false },
],
]),
],
@@ -37,7 +37,7 @@ export class SubscriptionSettingsAssociationService implements SubscriptionSetti
new Map([
[
SubscriptionSettingName.FileUploadBytesUsed,
{ sensitive: false, serverEncryptionVersion: EncryptionVersion.Unencrypted, value: '0' },
{ sensitive: false, serverEncryptionVersion: EncryptionVersion.Unencrypted, value: '0', replaceable: false },
],
]),
],
@@ -56,6 +56,7 @@ export class SubscriptionSettingsAssociationService implements SubscriptionSetti
sensitive: false,
serverEncryptionVersion: EncryptionVersion.Unencrypted,
value: (await this.getFileUploadLimit(subscriptionName)).toString(),
replaceable: true,
})
return defaultSettings

View File

@@ -3,6 +3,6 @@ import { Uuid } from '@standardnotes/common'
import { SubscriptionToken } from './SubscriptionToken'
export interface SubscriptionTokenRepositoryInterface {
save(subscriptionToken: SubscriptionToken): Promise<void>
save(subscriptionToken: SubscriptionToken): Promise<boolean>
getUserUuidByToken(token: string): Promise<Uuid | undefined>
}

View File

@@ -0,0 +1,173 @@
import 'reflect-metadata'
import { TokenEncoderInterface, CrossServiceTokenData } from '@standardnotes/security'
import { ProjectorInterface } from '../../../Projection/ProjectorInterface'
import { Session } from '../../Session/Session'
import { User } from '../../User/User'
import { Role } from '../../Role/Role'
import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
import { GetUserAnalyticsId } from '../GetUserAnalyticsId/GetUserAnalyticsId'
import { CreateCrossServiceToken } from './CreateCrossServiceToken'
describe('CreateCrossServiceToken', () => {
let userProjector: ProjectorInterface<User>
let sessionProjector: ProjectorInterface<Session>
let roleProjector: ProjectorInterface<Role>
let tokenEncoder: TokenEncoderInterface<CrossServiceTokenData>
let getUserAnalyticsId: GetUserAnalyticsId
let userRepository: UserRepositoryInterface
const jwtTTL = 60
let session: Session
let user: User
let role: Role
const createUseCase = (analyticsEnabled = true) =>
new CreateCrossServiceToken(
userProjector,
sessionProjector,
roleProjector,
tokenEncoder,
getUserAnalyticsId,
userRepository,
analyticsEnabled,
jwtTTL,
)
beforeEach(() => {
session = {} as jest.Mocked<Session>
user = {} as jest.Mocked<User>
user.roles = Promise.resolve([role])
userProjector = {} as jest.Mocked<ProjectorInterface<User>>
userProjector.projectSimple = jest.fn().mockReturnValue({ bar: 'baz' })
roleProjector = {} as jest.Mocked<ProjectorInterface<Role>>
roleProjector.projectSimple = jest.fn().mockReturnValue({ name: 'role1', uuid: '1-3-4' })
sessionProjector = {} as jest.Mocked<ProjectorInterface<Session>>
sessionProjector.projectCustom = jest.fn().mockReturnValue({ foo: 'bar' })
sessionProjector.projectSimple = jest.fn().mockReturnValue({ test: 'test' })
tokenEncoder = {} as jest.Mocked<TokenEncoderInterface<CrossServiceTokenData>>
tokenEncoder.encodeExpirableToken = jest.fn().mockReturnValue('foobar')
getUserAnalyticsId = {} as jest.Mocked<GetUserAnalyticsId>
getUserAnalyticsId.execute = jest.fn().mockReturnValue({ analyticsId: 123 })
userRepository = {} as jest.Mocked<UserRepositoryInterface>
userRepository.findOneByUuid = jest.fn().mockReturnValue(user)
})
it('should create a cross service token for user', async () => {
await createUseCase().execute({
user,
session,
})
expect(tokenEncoder.encodeExpirableToken).toHaveBeenCalledWith(
{
analyticsId: 123,
roles: [
{
name: 'role1',
uuid: '1-3-4',
},
],
session: {
test: 'test',
},
user: {
bar: 'baz',
},
},
60,
)
})
it('should create a cross service token for user - analytics disabled', async () => {
await createUseCase(false).execute({
user,
session,
})
expect(tokenEncoder.encodeExpirableToken).toHaveBeenCalledWith(
{
roles: [
{
name: 'role1',
uuid: '1-3-4',
},
],
session: {
test: 'test',
},
user: {
bar: 'baz',
},
},
60,
)
})
it('should create a cross service token for user without a session', async () => {
await createUseCase().execute({
user,
})
expect(tokenEncoder.encodeExpirableToken).toHaveBeenCalledWith(
{
analyticsId: 123,
roles: [
{
name: 'role1',
uuid: '1-3-4',
},
],
user: {
bar: 'baz',
},
},
60,
)
})
it('should create a cross service token for user by user uuid', async () => {
await createUseCase().execute({
userUuid: '1-2-3',
})
expect(tokenEncoder.encodeExpirableToken).toHaveBeenCalledWith(
{
analyticsId: 123,
roles: [
{
name: 'role1',
uuid: '1-3-4',
},
],
user: {
bar: 'baz',
},
},
60,
)
})
it('should throw an error if user does not exist', async () => {
userRepository.findOneByUuid = jest.fn().mockReturnValue(null)
let caughtError = null
try {
await createUseCase().execute({
userUuid: '1-2-3',
})
} catch (error) {
caughtError = error
}
expect(caughtError).not.toBeNull()
})
})

View File

@@ -0,0 +1,91 @@
import { RoleName } from '@standardnotes/common'
import { TokenEncoderInterface, CrossServiceTokenData } from '@standardnotes/security'
import { inject, injectable } from 'inversify'
import TYPES from '../../../Bootstrap/Types'
import { ProjectorInterface } from '../../../Projection/ProjectorInterface'
import { Role } from '../../Role/Role'
import { Session } from '../../Session/Session'
import { User } from '../../User/User'
import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
import { GetUserAnalyticsId } from '../GetUserAnalyticsId/GetUserAnalyticsId'
import { UseCaseInterface } from '../UseCaseInterface'
import { CreateCrossServiceTokenDTO } from './CreateCrossServiceTokenDTO'
import { CreateCrossServiceTokenResponse } from './CreateCrossServiceTokenResponse'
@injectable()
export class CreateCrossServiceToken implements UseCaseInterface {
constructor(
@inject(TYPES.UserProjector) private userProjector: ProjectorInterface<User>,
@inject(TYPES.SessionProjector) private sessionProjector: ProjectorInterface<Session>,
@inject(TYPES.RoleProjector) private roleProjector: ProjectorInterface<Role>,
@inject(TYPES.CrossServiceTokenEncoder) private tokenEncoder: TokenEncoderInterface<CrossServiceTokenData>,
@inject(TYPES.GetUserAnalyticsId) private getUserAnalyticsId: GetUserAnalyticsId,
@inject(TYPES.UserRepository) private userRepository: UserRepositoryInterface,
@inject(TYPES.ANALYTICS_ENABLED) private analyticsEnabled: boolean,
@inject(TYPES.AUTH_JWT_TTL) private jwtTTL: number,
) {}
async execute(dto: CreateCrossServiceTokenDTO): Promise<CreateCrossServiceTokenResponse> {
let user: User | undefined | null = dto.user
if (user === undefined && dto.userUuid !== undefined) {
user = await this.userRepository.findOneByUuid(dto.userUuid)
}
if (!user) {
throw new Error(`Could not find user with uuid ${dto.userUuid}`)
}
const roles = await user.roles
const authTokenData: CrossServiceTokenData = {
user: this.projectUser(user),
roles: this.projectRoles(roles),
}
if (this.analyticsEnabled) {
const { analyticsId } = await this.getUserAnalyticsId.execute({ userUuid: user.uuid })
authTokenData.analyticsId = analyticsId
}
if (dto.session !== undefined) {
authTokenData.session = this.projectSession(dto.session)
}
return {
token: this.tokenEncoder.encodeExpirableToken(authTokenData, this.jwtTTL),
}
}
private projectUser(user: User): { uuid: string; email: string } {
return <{ uuid: string; email: string }>this.userProjector.projectSimple(user)
}
private projectSession(session: Session): {
uuid: string
api_version: string
created_at: string
updated_at: string
device_info: string
readonly_access: boolean
access_expiration: string
refresh_expiration: string
} {
return <
{
uuid: string
api_version: string
created_at: string
updated_at: string
device_info: string
readonly_access: boolean
access_expiration: string
refresh_expiration: string
}
>this.sessionProjector.projectSimple(session)
}
private projectRoles(roles: Array<Role>): Array<{ uuid: string; name: RoleName }> {
return roles.map((role) => <{ uuid: string; name: RoleName }>this.roleProjector.projectSimple(role))
}
}

View File

@@ -0,0 +1,13 @@
import { Either, Uuid } from '@standardnotes/common'
import { Session } from '../../Session/Session'
import { User } from '../../User/User'
export type CreateCrossServiceTokenDTO = Either<
{
user: User
session?: Session
},
{
userUuid: Uuid
}
>

View File

@@ -0,0 +1,3 @@
export type CreateCrossServiceTokenResponse = {
token: string
}

View File

@@ -5,17 +5,19 @@ import { TimerInterface } from '@standardnotes/time'
import { SubscriptionTokenRepositoryInterface } from '../../Subscription/SubscriptionTokenRepositoryInterface'
import { CreateSubscriptionToken } from './CreateSubscriptionToken'
import { Logger } from 'winston'
describe('CreateSubscriptionToken', () => {
let subscriptionTokenRepository: SubscriptionTokenRepositoryInterface
let cryptoNode: CryptoNode
let timer: TimerInterface
let logger: Logger
const createUseCase = () => new CreateSubscriptionToken(subscriptionTokenRepository, cryptoNode, timer)
const createUseCase = () => new CreateSubscriptionToken(subscriptionTokenRepository, cryptoNode, timer, logger)
beforeEach(() => {
subscriptionTokenRepository = {} as jest.Mocked<SubscriptionTokenRepositoryInterface>
subscriptionTokenRepository.save = jest.fn()
subscriptionTokenRepository.save = jest.fn().mockReturnValue(true)
cryptoNode = {} as jest.Mocked<CryptoNode>
cryptoNode.generateRandomKey = jest.fn().mockReturnValueOnce('random-string')
@@ -23,6 +25,9 @@ describe('CreateSubscriptionToken', () => {
timer = {} as jest.Mocked<TimerInterface>
timer.convertStringDateToMicroseconds = jest.fn().mockReturnValue(1)
timer.getUTCDateNHoursAhead = jest.fn().mockReturnValue(new Date(1))
logger = {} as jest.Mocked<Logger>
logger.error = jest.fn()
})
it('should create an subscription token and persist it', async () => {
@@ -36,4 +41,19 @@ describe('CreateSubscriptionToken', () => {
expiresAt: 1,
})
})
it('should throw an error if the subscription token was not created', async () => {
subscriptionTokenRepository.save = jest.fn().mockReturnValue(false)
let caughtError = null
try {
await createUseCase().execute({
userUuid: '1-2-3',
})
} catch (error) {
caughtError = error
}
expect(caughtError).not.toBeNull()
})
})

View File

@@ -1,6 +1,7 @@
import { CryptoNode } from '@standardnotes/sncrypto-node'
import { TimerInterface } from '@standardnotes/time'
import { inject, injectable } from 'inversify'
import { Logger } from 'winston'
import TYPES from '../../../Bootstrap/Types'
import { SubscriptionTokenRepositoryInterface } from '../../Subscription/SubscriptionTokenRepositoryInterface'
@@ -15,6 +16,7 @@ export class CreateSubscriptionToken implements UseCaseInterface {
private subscriptionTokenRepository: SubscriptionTokenRepositoryInterface,
@inject(TYPES.CryptoNode) private cryptoNode: CryptoNode,
@inject(TYPES.Timer) private timer: TimerInterface,
@inject(TYPES.Logger) private logger: Logger,
) {}
async execute(dto: CreateSubscriptionTokenDTO): Promise<CreateSubscriptionTokenResponse> {
@@ -26,7 +28,13 @@ export class CreateSubscriptionToken implements UseCaseInterface {
expiresAt: this.timer.convertStringDateToMicroseconds(this.timer.getUTCDateNHoursAhead(3).toString()),
}
await this.subscriptionTokenRepository.save(subscriptionToken)
const subscriptionTokenWasSaved = await this.subscriptionTokenRepository.save(subscriptionToken)
if (!subscriptionTokenWasSaved) {
this.logger.error(`Could not create subscription token for user ${dto.userUuid}`)
throw new Error('Could not create subscription token')
}
return {
subscriptionToken,

View File

@@ -0,0 +1,25 @@
import 'reflect-metadata'
import { TokenEncoderInterface, WebSocketConnectionTokenData } from '@standardnotes/security'
import { CreateWebSocketConnectionToken } from './CreateWebSocketConnectionToken'
describe('CreateWebSocketConnection', () => {
let tokenEncoder: TokenEncoderInterface<WebSocketConnectionTokenData>
const tokenTTL = 30
const createUseCase = () => new CreateWebSocketConnectionToken(tokenEncoder, tokenTTL)
beforeEach(() => {
tokenEncoder = {} as jest.Mocked<TokenEncoderInterface<WebSocketConnectionTokenData>>
tokenEncoder.encodeExpirableToken = jest.fn().mockReturnValue('foobar')
})
it('should create a web socket connection token', async () => {
const result = await createUseCase().execute({ userUuid: '1-2-3' })
expect(result.token).toEqual('foobar')
expect(tokenEncoder.encodeExpirableToken).toHaveBeenCalledWith({ userUuid: '1-2-3' }, 30)
})
})

View File

@@ -0,0 +1,3 @@
export type CreateWebSocketConnectionDTO = {
userUuid: string
}

View File

@@ -0,0 +1,3 @@
export type CreateWebSocketConnectionResponse = {
token: string
}

View File

@@ -0,0 +1,26 @@
import { TokenEncoderInterface, WebSocketConnectionTokenData } from '@standardnotes/security'
import { inject, injectable } from 'inversify'
import TYPES from '../../../Bootstrap/Types'
import { UseCaseInterface } from '../UseCaseInterface'
import { CreateWebSocketConnectionDTO } from './CreateWebSocketConnectionDTO'
import { CreateWebSocketConnectionResponse } from './CreateWebSocketConnectionResponse'
@injectable()
export class CreateWebSocketConnectionToken implements UseCaseInterface {
constructor(
@inject(TYPES.WebSocketConnectionTokenEncoder)
private tokenEncoder: TokenEncoderInterface<WebSocketConnectionTokenData>,
@inject(TYPES.WEB_SOCKET_CONNECTION_TOKEN_TTL) private tokenTTL: number,
) {}
async execute(dto: CreateWebSocketConnectionDTO): Promise<CreateWebSocketConnectionResponse> {
const data: WebSocketConnectionTokenData = {
userUuid: dto.userUuid,
}
return {
token: this.tokenEncoder.encodeExpirableToken(data, this.tokenTTL),
}
}
}

View File

@@ -11,6 +11,7 @@ import { UserSubscription } from '../../Subscription/UserSubscription'
import { RoleName } from '@standardnotes/common'
import { UserSubscriptionType } from '../../Subscription/UserSubscriptionType'
import { SharedSubscriptionInvitation } from '../../SharedSubscription/SharedSubscriptionInvitation'
import { InvitationStatus } from '../../SharedSubscription/InvitationStatus'
describe('InviteToSharedSubscription', () => {
let userSubscriptionRepository: UserSubscriptionRepositoryInterface
@@ -187,7 +188,7 @@ describe('InviteToSharedSubscription', () => {
it('should not create an invitation if it already exists', async () => {
sharedSubscriptionInvitationRepository.findOneByInviteeAndInviterEmail = jest
.fn()
.mockReturnValue({} as jest.Mocked<SharedSubscriptionInvitation>)
.mockReturnValue({ status: InvitationStatus.Sent } as jest.Mocked<SharedSubscriptionInvitation>)
expect(
await createUseCase().execute({
@@ -205,4 +206,27 @@ describe('InviteToSharedSubscription', () => {
expect(domainEventFactory.createSharedSubscriptionInvitationCreatedEvent).not.toHaveBeenCalled()
expect(domainEventPublisher.publish).not.toHaveBeenCalled()
})
it('should create an invitation if it already exists but was canceled', async () => {
sharedSubscriptionInvitationRepository.findOneByInviteeAndInviterEmail = jest
.fn()
.mockReturnValue({ status: InvitationStatus.Canceled } as jest.Mocked<SharedSubscriptionInvitation>)
expect(
await createUseCase().execute({
inviteeIdentifier: 'invitee@test.te',
inviterUuid: '1-2-3',
inviterEmail: 'inviter@test.te',
inviterRoles: [RoleName.ProUser],
}),
).toEqual({
success: true,
sharedSubscriptionInvitationUuid: '1-2-3',
})
expect(sharedSubscriptionInvitationRepository.save).toHaveBeenCalled()
expect(domainEventFactory.createSharedSubscriptionInvitationCreatedEvent).toHaveBeenCalled()
expect(domainEventPublisher.publish).toHaveBeenCalled()
})
})

View File

@@ -57,7 +57,7 @@ export class InviteToSharedSubscription implements UseCaseInterface {
dto.inviteeIdentifier,
dto.inviterEmail,
)
if (existingInvitation !== null) {
if (existingInvitation !== null && existingInvitation.status !== InvitationStatus.Canceled) {
return {
success: false,
}

View File

@@ -1,39 +0,0 @@
import 'reflect-metadata'
import { Setting } from '../../Setting/Setting'
import { SettingRepositoryInterface } from '../../Setting/SettingRepositoryInterface'
import { MuteFailedBackupsEmails } from './MuteFailedBackupsEmails'
describe('MuteFailedBackupsEmails', () => {
let settingRepository: SettingRepositoryInterface
const createUseCase = () => new MuteFailedBackupsEmails(settingRepository)
beforeEach(() => {
const setting = {} as jest.Mocked<Setting>
settingRepository = {} as jest.Mocked<SettingRepositoryInterface>
settingRepository.findOneByUuidAndNames = jest.fn().mockReturnValue(setting)
settingRepository.save = jest.fn()
})
it('should not succeed if extension setting is not found', async () => {
settingRepository.findOneByUuidAndNames = jest.fn().mockReturnValue(null)
expect(await createUseCase().execute({ settingUuid: '1-2-3' })).toEqual({
success: false,
message: 'Could not find setting setting.',
})
})
it('should update mute email setting on extension setting', async () => {
expect(await createUseCase().execute({ settingUuid: '1-2-3' })).toEqual({
success: true,
message: 'These emails have been muted.',
})
expect(settingRepository.save).toHaveBeenCalledWith({
value: 'muted',
})
})
})

View File

@@ -1,35 +0,0 @@
import { MuteFailedBackupsEmailsOption, SettingName } from '@standardnotes/settings'
import { inject, injectable } from 'inversify'
import TYPES from '../../../Bootstrap/Types'
import { SettingRepositoryInterface } from '../../Setting/SettingRepositoryInterface'
import { UseCaseInterface } from '../UseCaseInterface'
import { MuteFailedBackupsEmailsDTO } from './MuteFailedBackupsEmailsDTO'
import { MuteFailedBackupsEmailsResponse } from './MuteFailedBackupsEmailsResponse'
@injectable()
export class MuteFailedBackupsEmails implements UseCaseInterface {
constructor(@inject(TYPES.SettingRepository) private settingRepository: SettingRepositoryInterface) {}
async execute(dto: MuteFailedBackupsEmailsDTO): Promise<MuteFailedBackupsEmailsResponse> {
const setting = await this.settingRepository.findOneByUuidAndNames(dto.settingUuid, [
SettingName.MuteFailedBackupsEmails,
SettingName.MuteFailedCloudBackupsEmails,
])
if (setting === null) {
return {
success: false,
message: 'Could not find setting setting.',
}
}
setting.value = MuteFailedBackupsEmailsOption.Muted
await this.settingRepository.save(setting)
return {
success: true,
message: 'These emails have been muted.',
}
}
}

View File

@@ -1,3 +0,0 @@
export type MuteFailedBackupsEmailsDTO = {
settingUuid: string
}

View File

@@ -1,4 +0,0 @@
export type MuteFailedBackupsEmailsResponse = {
success: boolean
message: string
}

View File

@@ -1,40 +0,0 @@
import 'reflect-metadata'
import { Setting } from '../../Setting/Setting'
import { SettingRepositoryInterface } from '../../Setting/SettingRepositoryInterface'
import { MuteMarketingEmails } from './MuteMarketingEmails'
describe('MuteMarketingEmails', () => {
let settingRepository: SettingRepositoryInterface
const createUseCase = () => new MuteMarketingEmails(settingRepository)
beforeEach(() => {
const setting = {} as jest.Mocked<Setting>
settingRepository = {} as jest.Mocked<SettingRepositoryInterface>
settingRepository.findOneByUuidAndNames = jest.fn().mockReturnValue(setting)
settingRepository.save = jest.fn()
})
it('should not succeed if extension setting is not found', async () => {
settingRepository.findOneByUuidAndNames = jest.fn().mockReturnValue(null)
expect(await createUseCase().execute({ settingUuid: '1-2-3' })).toEqual({
success: false,
message: 'Could not find setting setting.',
})
})
it('should update mute email setting on extension setting', async () => {
expect(await createUseCase().execute({ settingUuid: '1-2-3' })).toEqual({
success: true,
message: 'These emails have been muted.',
})
expect(settingRepository.save).toHaveBeenCalledWith({
value: 'muted',
serverEncryptionVersion: 0,
})
})
})

View File

@@ -1,36 +0,0 @@
import { MuteMarketingEmailsOption, SettingName } from '@standardnotes/settings'
import { inject, injectable } from 'inversify'
import TYPES from '../../../Bootstrap/Types'
import { EncryptionVersion } from '../../Encryption/EncryptionVersion'
import { SettingRepositoryInterface } from '../../Setting/SettingRepositoryInterface'
import { UseCaseInterface } from '../UseCaseInterface'
import { MuteMarketingEmailsDTO } from './MuteMarketingEmailsDTO'
import { MuteMarketingEmailsResponse } from './MuteMarketingEmailsResponse'
@injectable()
export class MuteMarketingEmails implements UseCaseInterface {
constructor(@inject(TYPES.SettingRepository) private settingRepository: SettingRepositoryInterface) {}
async execute(dto: MuteMarketingEmailsDTO): Promise<MuteMarketingEmailsResponse> {
const setting = await this.settingRepository.findOneByUuidAndNames(dto.settingUuid, [
SettingName.MuteMarketingEmails,
])
if (setting === null) {
return {
success: false,
message: 'Could not find setting setting.',
}
}
setting.value = MuteMarketingEmailsOption.Muted
setting.serverEncryptionVersion = EncryptionVersion.Unencrypted
await this.settingRepository.save(setting)
return {
success: true,
message: 'These emails have been muted.',
}
}
}

View File

@@ -1,3 +0,0 @@
export type MuteMarketingEmailsDTO = {
settingUuid: string
}

View File

@@ -1,4 +0,0 @@
export type MuteMarketingEmailsResponse = {
success: boolean
message: string
}

View File

@@ -1,39 +0,0 @@
import 'reflect-metadata'
import { Setting } from '../../Setting/Setting'
import { SettingRepositoryInterface } from '../../Setting/SettingRepositoryInterface'
import { MuteSignInEmails } from './MuteSignInEmails'
describe('MuteSignInEmails', () => {
let settingRepository: SettingRepositoryInterface
const createUseCase = () => new MuteSignInEmails(settingRepository)
beforeEach(() => {
const setting = {} as jest.Mocked<Setting>
settingRepository = {} as jest.Mocked<SettingRepositoryInterface>
settingRepository.findOneByUuidAndNames = jest.fn().mockReturnValue(setting)
settingRepository.save = jest.fn()
})
it('should not succeed if extension setting is not found', async () => {
settingRepository.findOneByUuidAndNames = jest.fn().mockReturnValue(null)
expect(await createUseCase().execute({ settingUuid: '1-2-3' })).toEqual({
success: false,
message: 'Could not find setting setting.',
})
})
it('should update mute email setting on extension setting', async () => {
expect(await createUseCase().execute({ settingUuid: '1-2-3' })).toEqual({
success: true,
message: 'These emails have been muted.',
})
expect(settingRepository.save).toHaveBeenCalledWith({
value: 'muted',
})
})
})

View File

@@ -1,32 +0,0 @@
import { MuteSignInEmailsOption, SettingName } from '@standardnotes/settings'
import { inject, injectable } from 'inversify'
import TYPES from '../../../Bootstrap/Types'
import { SettingRepositoryInterface } from '../../Setting/SettingRepositoryInterface'
import { UseCaseInterface } from '../UseCaseInterface'
import { MuteSignInEmailsDTO } from './MuteSignInEmailsDTO'
import { MuteSignInEmailsResponse } from './MuteSignInEmailsResponse'
@injectable()
export class MuteSignInEmails implements UseCaseInterface {
constructor(@inject(TYPES.SettingRepository) private settingRepository: SettingRepositoryInterface) {}
async execute(dto: MuteSignInEmailsDTO): Promise<MuteSignInEmailsResponse> {
const setting = await this.settingRepository.findOneByUuidAndNames(dto.settingUuid, [SettingName.MuteSignInEmails])
if (setting === null) {
return {
success: false,
message: 'Could not find setting setting.',
}
}
setting.value = MuteSignInEmailsOption.Muted
await this.settingRepository.save(setting)
return {
success: true,
message: 'These emails have been muted.',
}
}
}

View File

@@ -1,3 +0,0 @@
export type MuteSignInEmailsDTO = {
settingUuid: string
}

View File

@@ -1,4 +0,0 @@
export type MuteSignInEmailsResponse = {
success: boolean
message: string
}

View File

@@ -0,0 +1,97 @@
import { WebSocketServerInterface } from '@standardnotes/api'
import { ErrorTag } from '@standardnotes/common'
import { TokenDecoderInterface, WebSocketConnectionTokenData } from '@standardnotes/security'
import { Request, Response } from 'express'
import { inject } from 'inversify'
import {
BaseHttpController,
controller,
httpDelete,
httpPost,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
results,
} from 'inversify-express-utils'
import TYPES from '../../Bootstrap/Types'
import { AddWebSocketsConnection } from '../../Domain/UseCase/AddWebSocketsConnection/AddWebSocketsConnection'
import { CreateCrossServiceToken } from '../../Domain/UseCase/CreateCrossServiceToken/CreateCrossServiceToken'
import { RemoveWebSocketsConnection } from '../../Domain/UseCase/RemoveWebSocketsConnection/RemoveWebSocketsConnection'
@controller('/sockets')
export class InversifyExpressWebSocketsController extends BaseHttpController {
constructor(
@inject(TYPES.AddWebSocketsConnection) private addWebSocketsConnection: AddWebSocketsConnection,
@inject(TYPES.RemoveWebSocketsConnection) private removeWebSocketsConnection: RemoveWebSocketsConnection,
@inject(TYPES.CreateCrossServiceToken) private createCrossServiceToken: CreateCrossServiceToken,
@inject(TYPES.WebSocketsController) private webSocketsController: WebSocketServerInterface,
@inject(TYPES.WebSocketConnectionTokenDecoder)
private tokenDecoder: TokenDecoderInterface<WebSocketConnectionTokenData>,
) {
super()
}
@httpPost('/tokens', TYPES.ApiGatewayAuthMiddleware)
async createConnectionToken(_request: Request, response: Response): Promise<results.JsonResult> {
const result = await this.webSocketsController.createConnectionToken({
userUuid: response.locals.user.uuid,
})
return this.json(result.data, result.status)
}
@httpPost('/tokens/validate')
async validateToken(request: Request): Promise<results.JsonResult> {
if (!request.headers.authorization) {
return this.json(
{
error: {
tag: ErrorTag.AuthInvalid,
message: 'Invalid authorization token.',
},
},
401,
)
}
const token: WebSocketConnectionTokenData | undefined = this.tokenDecoder.decodeToken(request.headers.authorization)
if (token === undefined) {
return this.json(
{
error: {
tag: ErrorTag.AuthInvalid,
message: 'Invalid authorization token.',
},
},
401,
)
}
const result = await this.createCrossServiceToken.execute({
userUuid: token.userUuid,
})
return this.json({ authToken: result.token })
}
@httpPost('/connections/:connectionId', TYPES.ApiGatewayAuthMiddleware)
async storeWebSocketsConnection(
request: Request,
response: Response,
): Promise<results.JsonResult | results.BadRequestErrorMessageResult> {
await this.addWebSocketsConnection.execute({
userUuid: response.locals.user.uuid,
connectionId: request.params.connectionId,
})
return this.json({ success: true })
}
@httpDelete('/connections/:connectionId')
async deleteWebSocketsConnection(
request: Request,
): Promise<results.JsonResult | results.BadRequestErrorMessageResult> {
await this.removeWebSocketsConnection.execute({ connectionId: request.params.connectionId })
return this.json({ success: true })
}
}

View File

@@ -15,7 +15,6 @@ describe('LockRepository', () => {
redisClient.expire = jest.fn()
redisClient.del = jest.fn()
redisClient.get = jest.fn()
redisClient.set = jest.fn()
redisClient.setex = jest.fn()
})
@@ -88,6 +87,6 @@ describe('LockRepository', () => {
it('should update a lock counter', async () => {
await createRepository().updateLockCounter('123', 3)
expect(redisClient.set).toHaveBeenCalledWith('lock:123', 3)
expect(redisClient.setex).toHaveBeenCalledWith('lock:123', 120, 3)
})
})

View File

@@ -30,7 +30,7 @@ export class LockRepository implements LockRepositoryInterface {
}
async updateLockCounter(userIdentifier: string, counter: number): Promise<void> {
await this.redisClient.set(`${this.PREFIX}:${userIdentifier}`, counter)
await this.redisClient.setex(`${this.PREFIX}:${userIdentifier}`, this.failedLoginLockout, counter)
}
async getLockCounter(userIdentifier: string): Promise<number> {

View File

@@ -14,9 +14,9 @@ describe('RedisSubscriptionTokenRepository', () => {
beforeEach(() => {
redisClient = {} as jest.Mocked<IORedis.Redis>
redisClient.set = jest.fn()
redisClient.set = jest.fn().mockReturnValue('OK')
redisClient.get = jest.fn()
redisClient.expireat = jest.fn()
redisClient.expireat = jest.fn().mockReturnValue(1)
timer = {} as jest.Mocked<TimerInterface>
timer.convertMicrosecondsToSeconds = jest.fn().mockReturnValue(1)
@@ -45,7 +45,23 @@ describe('RedisSubscriptionTokenRepository', () => {
expiresAt: 123,
}
await createRepository().save(subscriptionToken)
expect(await createRepository().save(subscriptionToken)).toBeTruthy()
expect(redisClient.set).toHaveBeenCalledWith('subscription-token:random-string', '1-2-3')
expect(redisClient.expireat).toHaveBeenCalledWith('subscription-token:random-string', 1)
})
it('should indicate subscription token was not saved', async () => {
redisClient.set = jest.fn().mockReturnValue(null)
const subscriptionToken: SubscriptionToken = {
userUuid: '1-2-3',
token: 'random-string',
expiresAt: 123,
}
expect(await createRepository().save(subscriptionToken)).toBeFalsy()
expect(redisClient.set).toHaveBeenCalledWith('subscription-token:random-string', '1-2-3')

View File

@@ -24,11 +24,13 @@ export class RedisSubscriptionTokenRepository implements SubscriptionTokenReposi
return userUuid
}
async save(subscriptionToken: SubscriptionToken): Promise<void> {
async save(subscriptionToken: SubscriptionToken): Promise<boolean> {
const key = `${this.PREFIX}:${subscriptionToken.token}`
const expiresAtTimestampInSeconds = this.timer.convertMicrosecondsToSeconds(subscriptionToken.expiresAt)
await this.redisClient.set(key, subscriptionToken.userUuid)
await this.redisClient.expireat(key, expiresAtTimestampInSeconds)
const wasSet = await this.redisClient.set(key, subscriptionToken.userUuid)
const timeoutWasSet = await this.redisClient.expireat(key, expiresAtTimestampInSeconds)
return wasSet === 'OK' && timeoutWasSet !== 0
}
}

View File

@@ -17,28 +17,31 @@ describe('SettingProjector', () => {
serverEncryptionVersion: 1,
createdAt: 1,
updatedAt: 2,
sensitive: false,
} as jest.Mocked<Setting>
})
it('should create a simple projection of a setting', async () => {
const projection = await createProjector().projectSimple(setting)
expect(projection).toEqual({
expect(projection).toStrictEqual({
uuid: 'setting-uuid',
name: 'setting-name',
value: 'setting-value',
createdAt: 1,
updatedAt: 2,
sensitive: false,
})
})
it('should create a simple projection of list of settings', async () => {
const projection = await createProjector().projectManySimple([setting])
expect(projection).toEqual([
expect(projection).toStrictEqual([
{
uuid: 'setting-uuid',
name: 'setting-name',
value: 'setting-value',
createdAt: 1,
updatedAt: 2,
sensitive: false,
},
])
})

View File

@@ -6,16 +6,16 @@ import { SimpleSetting } from '../Domain/Setting/SimpleSetting'
@injectable()
export class SettingProjector {
async projectSimple(setting: Setting): Promise<SimpleSetting> {
const {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
user,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
serverEncryptionVersion,
...rest
} = setting
return rest
return {
uuid: setting.uuid,
name: setting.name,
value: setting.value,
createdAt: setting.createdAt,
updatedAt: setting.updatedAt,
sensitive: setting.sensitive,
}
}
async projectManySimple(settings: Setting[]): Promise<SimpleSetting[]> {
return Promise.all(
settings.map(async (setting) => {

View File

@@ -17,28 +17,31 @@ describe('SubscriptionSettingProjector', () => {
serverEncryptionVersion: 1,
createdAt: 1,
updatedAt: 2,
sensitive: false,
} as jest.Mocked<SubscriptionSetting>
})
it('should create a simple projection of a setting', async () => {
const projection = await createProjector().projectSimple(setting)
expect(projection).toEqual({
expect(projection).toStrictEqual({
uuid: 'setting-uuid',
name: 'setting-name',
value: 'setting-value',
createdAt: 1,
updatedAt: 2,
sensitive: false,
})
})
it('should create a simple projection of list of settings', async () => {
const projection = await createProjector().projectManySimple([setting])
expect(projection).toEqual([
expect(projection).toStrictEqual([
{
uuid: 'setting-uuid',
name: 'setting-name',
value: 'setting-value',
createdAt: 1,
updatedAt: 2,
sensitive: false,
},
])
})

View File

@@ -6,16 +6,16 @@ import { SubscriptionSetting } from '../Domain/Setting/SubscriptionSetting'
@injectable()
export class SubscriptionSettingProjector {
async projectSimple(setting: SubscriptionSetting): Promise<SimpleSubscriptionSetting> {
const {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
userSubscription,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
serverEncryptionVersion,
...rest
} = setting
return rest
return {
uuid: setting.uuid,
name: setting.name,
value: setting.value,
createdAt: setting.createdAt,
updatedAt: setting.updatedAt,
sensitive: setting.sensitive,
}
}
async projectManySimple(settings: SubscriptionSetting[]): Promise<SimpleSubscriptionSetting[]> {
return Promise.all(
settings.map(async (setting) => {

View File

@@ -3,6 +3,14 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.8.13](https://github.com/standardnotes/server/compare/@standardnotes/domain-events-infra@1.8.12...@standardnotes/domain-events-infra@1.8.13) (2022-09-28)
**Note:** Version bump only for package @standardnotes/domain-events-infra
## [1.8.12](https://github.com/standardnotes/server/compare/@standardnotes/domain-events-infra@1.8.11...@standardnotes/domain-events-infra@1.8.12) (2022-09-21)
**Note:** Version bump only for package @standardnotes/domain-events-infra
## [1.8.11](https://github.com/standardnotes/server/compare/@standardnotes/domain-events-infra@1.8.10...@standardnotes/domain-events-infra@1.8.11) (2022-09-19)
**Note:** Version bump only for package @standardnotes/domain-events-infra

View File

@@ -1,6 +1,6 @@
{
"name": "@standardnotes/domain-events-infra",
"version": "1.8.11",
"version": "1.8.13",
"engines": {
"node": ">=16.0.0 <17.0.0"
},

View File

@@ -3,6 +3,14 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [2.60.7](https://github.com/standardnotes/server/compare/@standardnotes/domain-events@2.60.6...@standardnotes/domain-events@2.60.7) (2022-09-28)
**Note:** Version bump only for package @standardnotes/domain-events
## [2.60.6](https://github.com/standardnotes/server/compare/@standardnotes/domain-events@2.60.5...@standardnotes/domain-events@2.60.6) (2022-09-21)
**Note:** Version bump only for package @standardnotes/domain-events
## [2.60.5](https://github.com/standardnotes/server/compare/@standardnotes/domain-events@2.60.4...@standardnotes/domain-events@2.60.5) (2022-09-19)
**Note:** Version bump only for package @standardnotes/domain-events

View File

@@ -1,6 +1,6 @@
{
"name": "@standardnotes/domain-events",
"version": "2.60.5",
"version": "2.60.7",
"engines": {
"node": ">=16.0.0 <17.0.0"
},
@@ -25,7 +25,7 @@
},
"dependencies": {
"@standardnotes/common": "workspace:*",
"@standardnotes/features": "^1.47.0",
"@standardnotes/features": "^1.52.1",
"@standardnotes/predicates": "workspace:*",
"@standardnotes/security": "workspace:*",
"reflect-metadata": "^0.1.13"

View File

@@ -3,6 +3,14 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.3.18](https://github.com/standardnotes/server/compare/@standardnotes/event-store@1.3.17...@standardnotes/event-store@1.3.18) (2022-09-28)
**Note:** Version bump only for package @standardnotes/event-store
## [1.3.17](https://github.com/standardnotes/server/compare/@standardnotes/event-store@1.3.16...@standardnotes/event-store@1.3.17) (2022-09-21)
**Note:** Version bump only for package @standardnotes/event-store
## [1.3.16](https://github.com/standardnotes/server/compare/@standardnotes/event-store@1.3.15...@standardnotes/event-store@1.3.16) (2022-09-19)
**Note:** Version bump only for package @standardnotes/event-store

View File

@@ -1,6 +1,6 @@
{
"name": "@standardnotes/event-store",
"version": "1.3.16",
"version": "1.3.18",
"description": "Event Store Service",
"private": true,
"main": "dist/src/index.js",

View File

@@ -3,6 +3,20 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.6.4](https://github.com/standardnotes/files/compare/@standardnotes/files-server@1.6.3...@standardnotes/files-server@1.6.4) (2022-09-28)
**Note:** Version bump only for package @standardnotes/files-server
## [1.6.3](https://github.com/standardnotes/files/compare/@standardnotes/files-server@1.6.2...@standardnotes/files-server@1.6.3) (2022-09-21)
**Note:** Version bump only for package @standardnotes/files-server
## [1.6.2](https://github.com/standardnotes/files/compare/@standardnotes/files-server@1.6.1...@standardnotes/files-server@1.6.2) (2022-09-19)
### Bug Fixes
* add upper bound for FS file chunk upload ([dfa7e06](https://github.com/standardnotes/files/commit/dfa7e06f8780bec21893ec77ab4a0945a6681545))
## [1.6.1](https://github.com/standardnotes/files/compare/@standardnotes/files-server@1.6.0...@standardnotes/files-server@1.6.1) (2022-09-19)
### Bug Fixes

View File

@@ -1,6 +1,6 @@
{
"name": "@standardnotes/files-server",
"version": "1.6.1",
"version": "1.6.4",
"engines": {
"node": ">=16.0.0 <17.0.0"
},

View File

@@ -298,6 +298,7 @@ describe('FilesController', () => {
chunkId: 2,
data: Buffer.from([123]),
resourceRemoteIdentifier: '2-3-4',
resourceUnencryptedFileSize: 123,
userUuid: '1-2-3',
})
})

View File

@@ -63,6 +63,7 @@ export class FilesController extends BaseHttpController {
const result = await this.uploadFileChunk.execute({
userUuid: response.locals.userUuid,
resourceRemoteIdentifier: response.locals.permittedResources[0].remoteIdentifier,
resourceUnencryptedFileSize: response.locals.permittedResources[0].unencryptedFileSize,
chunkId,
data: request.body,
})

View File

@@ -4,6 +4,12 @@ import { UploadId } from '../Upload/UploadId'
export interface FileUploaderInterface {
createUploadSession(filePath: string): Promise<UploadId>
uploadFileChunk(dto: { uploadId: string; data: Uint8Array; filePath: string; chunkId: ChunkId }): Promise<string>
uploadFileChunk(dto: {
uploadId: string
data: Uint8Array
filePath: string
chunkId: ChunkId
unencryptedFileSize: number
}): Promise<string>
finishUploadSession(uploadId: string, filePath: string, uploadChunkResults: Array<UploadChunkResult>): Promise<void>
}

View File

@@ -33,6 +33,7 @@ describe('UploadFileChunk', () => {
chunkId: 2,
data: new Uint8Array([123]),
resourceRemoteIdentifier: '2-3-4',
resourceUnencryptedFileSize: 123,
userUuid: '1-2-3',
})
@@ -50,6 +51,7 @@ describe('UploadFileChunk', () => {
chunkId: 2,
data: new Uint8Array([123]),
resourceRemoteIdentifier: '2-3-4',
resourceUnencryptedFileSize: 123,
userUuid: '1-2-3',
}),
).toEqual({
@@ -66,6 +68,7 @@ describe('UploadFileChunk', () => {
chunkId: 2,
data: new Uint8Array([123]),
resourceRemoteIdentifier: '2-3-4',
resourceUnencryptedFileSize: 123,
userUuid: '1-2-3',
})
@@ -74,6 +77,7 @@ describe('UploadFileChunk', () => {
data: new Uint8Array([123]),
filePath: '1-2-3/2-3-4',
uploadId: '123',
unencryptedFileSize: 123,
})
expect(uploadRepository.storeUploadChunkResult).toHaveBeenCalledWith('123', {
tag: 'ETag123',

View File

@@ -39,6 +39,7 @@ export class UploadFileChunk implements UseCaseInterface {
data: dto.data,
chunkId: dto.chunkId,
filePath,
unencryptedFileSize: dto.resourceUnencryptedFileSize,
})
await this.uploadRepository.storeUploadChunkResult(uploadId, {

View File

@@ -5,4 +5,5 @@ export type UploadFileChunkDTO = {
chunkId: ChunkId
userUuid: string
resourceRemoteIdentifier: string
resourceUnencryptedFileSize: number
}

View File

@@ -1,11 +1,12 @@
import { promises } from 'fs'
import { dirname } from 'path'
import { inject, injectable } from 'inversify'
import { Logger } from 'winston'
import { FileUploaderInterface } from '../../Domain/Services/FileUploaderInterface'
import { UploadChunkResult } from '../../Domain/Upload/UploadChunkResult'
import { Logger } from 'winston'
import TYPES from '../../Bootstrap/Types'
import { ChunkId } from '../../Domain/Upload/ChunkId'
@injectable()
export class FSFileUploader implements FileUploaderInterface {
@@ -22,7 +23,8 @@ export class FSFileUploader implements FileUploaderInterface {
uploadId: string
data: Uint8Array
filePath: string
chunkId: number
chunkId: ChunkId
unencryptedFileSize: number
}): Promise<string> {
if (!this.inMemoryChunks.has(dto.uploadId)) {
this.inMemoryChunks.set(dto.uploadId, new Map<number, Uint8Array>())
@@ -30,6 +32,13 @@ export class FSFileUploader implements FileUploaderInterface {
const fileChunks = this.inMemoryChunks.get(dto.uploadId) as Map<number, Uint8Array>
const alreadyStoredBytes = this.accumulatedEncryptedFileSize(fileChunks)
if (alreadyStoredBytes >= dto.unencryptedFileSize) {
throw new Error(
`Could not finish chunk upload. Accumulated encrypted file size (${alreadyStoredBytes}B) already exceeds the unecrypted file size: ${dto.unencryptedFileSize}`,
)
}
this.logger.debug(`FS storing file chunk ${dto.chunkId} in memory for ${dto.uploadId}`)
fileChunks.set(dto.chunkId, dto.data)
@@ -64,4 +73,14 @@ export class FSFileUploader implements FileUploaderInterface {
return fullPath
}
private accumulatedEncryptedFileSize(fileChunks: Map<number, Uint8Array>): number {
let accumulatedSize = 0
for (const value of fileChunks.values()) {
accumulatedSize += value.byteLength
}
return accumulatedSize
}
}

View File

@@ -3,6 +3,14 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.10.32](https://github.com/standardnotes/server/compare/@standardnotes/scheduler-server@1.10.31...@standardnotes/scheduler-server@1.10.32) (2022-09-28)
**Note:** Version bump only for package @standardnotes/scheduler-server
## [1.10.31](https://github.com/standardnotes/server/compare/@standardnotes/scheduler-server@1.10.30...@standardnotes/scheduler-server@1.10.31) (2022-09-21)
**Note:** Version bump only for package @standardnotes/scheduler-server
## [1.10.30](https://github.com/standardnotes/server/compare/@standardnotes/scheduler-server@1.10.29...@standardnotes/scheduler-server@1.10.30) (2022-09-19)
**Note:** Version bump only for package @standardnotes/scheduler-server

View File

@@ -1,6 +1,6 @@
{
"name": "@standardnotes/scheduler-server",
"version": "1.10.30",
"version": "1.10.32",
"engines": {
"node": ">=16.0.0 <17.0.0"
},

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.4.0](https://github.com/standardnotes/server/compare/@standardnotes/security@1.3.3...@standardnotes/security@1.4.0) (2022-09-21)
### Features
* **auth:** add creating web socket connection tokens ([8033177](https://github.com/standardnotes/server/commit/8033177f48dc961194f24fb7daa1073b8b697b74))
## [1.3.3](https://github.com/standardnotes/server/compare/@standardnotes/security@1.3.2...@standardnotes/security@1.3.3) (2022-09-19)
**Note:** Version bump only for package @standardnotes/security

View File

@@ -1,6 +1,6 @@
{
"name": "@standardnotes/security",
"version": "1.3.3",
"version": "1.4.0",
"engines": {
"node": ">=16.0.0 <17.0.0"
},

View File

@@ -0,0 +1,5 @@
import { Uuid } from '@standardnotes/common'
export type WebSocketConnectionTokenData = {
userUuid: Uuid
}

View File

@@ -12,3 +12,4 @@ export * from './Token/OfflineUserTokenData'
export * from './Token/SessionTokenData'
export * from './Token/ValetTokenData'
export * from './Token/ValetTokenOperation'
export * from './Token/WebSocketConnectionToken'

View File

@@ -3,6 +3,32 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.8.11](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.8.10...@standardnotes/syncing-server@1.8.11) (2022-09-28)
**Note:** Version bump only for package @standardnotes/syncing-server
## [1.8.10](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.8.9...@standardnotes/syncing-server@1.8.10) (2022-09-22)
### Bug Fixes
* **syncing-server-js:** binding of sync limit ([5628de6](https://github.com/standardnotes/syncing-server-js/commit/5628de6445a90901735b449488fb8c8374f2171e))
## [1.8.9](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.8.8...@standardnotes/syncing-server@1.8.9) (2022-09-22)
### Bug Fixes
* **syncing-server:** introduce upper bound for sync items limit as an env var ([03d1bc6](https://github.com/standardnotes/syncing-server-js/commit/03d1bc611c39c0d1f0bcaa00824825304e08d30b))
## [1.8.8](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.8.7...@standardnotes/syncing-server@1.8.8) (2022-09-21)
**Note:** Version bump only for package @standardnotes/syncing-server
## [1.8.7](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.8.6...@standardnotes/syncing-server@1.8.7) (2022-09-20)
### Bug Fixes
* **syncing-server:** content size calculation and add syncing upper bound for limit paramter ([c2e9f3e](https://github.com/standardnotes/syncing-server-js/commit/c2e9f3e72b87c445a6f4d61cbf59621954187d21))
## [1.8.6](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.8.5...@standardnotes/syncing-server@1.8.6) (2022-09-19)
**Note:** Version bump only for package @standardnotes/syncing-server

View File

@@ -1,6 +1,6 @@
{
"name": "@standardnotes/syncing-server",
"version": "1.8.6",
"version": "1.8.11",
"engines": {
"node": ">=16.0.0 <17.0.0"
},

View File

@@ -90,6 +90,7 @@ const newrelicFormatter = require('@newrelic/winston-enricher')
export class ContainerConfigLoader {
private readonly DEFAULT_CONTENT_SIZE_TRANSFER_LIMIT = 10_000_000
private readonly DEFAULT_MAX_ITEMS_LIMIT = 300
async load(): Promise<Container> {
const env: Env = new Env()
@@ -191,7 +192,16 @@ export class ContainerConfigLoader {
container.bind(TYPES.VERSION).toConstantValue(env.get('VERSION'))
container
.bind(TYPES.CONTENT_SIZE_TRANSFER_LIMIT)
.toConstantValue(env.get('CONTENT_SIZE_TRANSFER_LIMIT', true) ?? this.DEFAULT_CONTENT_SIZE_TRANSFER_LIMIT)
.toConstantValue(
env.get('CONTENT_SIZE_TRANSFER_LIMIT', true)
? +env.get('CONTENT_SIZE_TRANSFER_LIMIT', true)
: this.DEFAULT_CONTENT_SIZE_TRANSFER_LIMIT,
)
container
.bind(TYPES.MAX_ITEMS_LIMIT)
.toConstantValue(
env.get('MAX_ITEMS_LIMIT', true) ? +env.get('MAX_ITEMS_LIMIT', true) : this.DEFAULT_MAX_ITEMS_LIMIT,
)
// use cases
container.bind<SyncItems>(TYPES.SyncItems).to(SyncItems)

View File

@@ -36,6 +36,7 @@ const TYPES = {
NEW_RELIC_ENABLED: Symbol.for('NEW_RELIC_ENABLED'),
VERSION: Symbol.for('VERSION'),
CONTENT_SIZE_TRANSFER_LIMIT: Symbol.for('CONTENT_SIZE_TRANSFER_LIMIT'),
MAX_ITEMS_LIMIT: Symbol.for('MAX_ITEMS_LIMIT'),
// use cases
SyncItems: Symbol.for('SyncItems'),
CheckIntegrity: Symbol.for('CheckIntegrity'),

View File

@@ -5,12 +5,16 @@ import { ContentType } from '@standardnotes/common'
import { ItemFactory } from './ItemFactory'
import { ItemHash } from './ItemHash'
import { ProjectorInterface } from '../../Projection/ProjectorInterface'
import { ItemProjection } from '../../Projection/ItemProjection'
import { Item } from './Item'
describe('ItemFactory', () => {
let timer: TimerInterface
let itemProjector: ProjectorInterface<Item, ItemProjection>
let timeHelper: Timer
const createFactory = () => new ItemFactory(timer)
const createFactory = () => new ItemFactory(timer, itemProjector)
beforeEach(() => {
timeHelper = new Timer()
@@ -26,6 +30,23 @@ describe('ItemFactory', () => {
timer.convertStringDateToDate = jest
.fn()
.mockImplementation((date: string) => timeHelper.convertStringDateToDate(date))
itemProjector = {} as jest.Mocked<ProjectorInterface<Item, ItemProjection>>
itemProjector.projectFull = jest.fn().mockReturnValue({
uuid: '1-2-3',
items_key_id: 'foobar',
duplicate_of: null,
enc_item_key: 'foobar',
content: 'foobar',
content_type: ContentType.Note,
auth_hash: 'foobar',
deleted: false,
created_at: '2022-09-01 10:00:00',
created_at_timestamp: 123123123123123,
updated_at: '2022-09-01 10:00:00',
updated_at_timestamp: 123123123123123,
updated_with_session: '2-4-5',
})
})
it('should create an item based on item hash', () => {
@@ -43,7 +64,7 @@ describe('ItemFactory', () => {
updatedAtTimestamp: 1616164633241568,
userUuid: 'a-b-c',
uuid: '1-2-3',
contentSize: 0,
contentSize: 341,
})
})
@@ -64,7 +85,7 @@ describe('ItemFactory', () => {
userUuid: 'a-b-c',
uuid: '1-2-3',
content: null,
contentSize: 0,
contentSize: 341,
})
})
@@ -86,7 +107,7 @@ describe('ItemFactory', () => {
userUuid: 'a-b-c',
uuid: '1-2-3',
content: 'foobar',
contentSize: 6,
contentSize: 341,
})
})
@@ -106,7 +127,7 @@ describe('ItemFactory', () => {
userUuid: 'a-b-c',
uuid: '1-2-3',
content: null,
contentSize: 0,
contentSize: 341,
})
})
@@ -128,7 +149,7 @@ describe('ItemFactory', () => {
expect(item).toEqual({
content: 'asdqwe1',
contentSize: 7,
contentSize: 341,
contentType: 'Note',
createdAt: expect.any(Date),
updatedWithSession: '1-2-3',
@@ -161,7 +182,7 @@ describe('ItemFactory', () => {
expect(item).toEqual({
content: 'asdqwe1',
contentSize: 7,
contentSize: 341,
contentType: 'Note',
createdAt: expect.any(Date),
updatedWithSession: '1-2-3',

View File

@@ -3,13 +3,18 @@ import { TimerInterface } from '@standardnotes/time'
import { inject, injectable } from 'inversify'
import TYPES from '../../Bootstrap/Types'
import { ItemProjection } from '../../Projection/ItemProjection'
import { ProjectorInterface } from '../../Projection/ProjectorInterface'
import { Item } from './Item'
import { ItemFactoryInterface } from './ItemFactoryInterface'
import { ItemHash } from './ItemHash'
@injectable()
export class ItemFactory implements ItemFactoryInterface {
constructor(@inject(TYPES.Timer) private timer: TimerInterface) {}
constructor(
@inject(TYPES.Timer) private timer: TimerInterface,
@inject(TYPES.ItemProjector) private itemProjector: ProjectorInterface<Item, ItemProjection>,
) {}
createStub(dto: { userUuid: string; itemHash: ItemHash; sessionUuid: Uuid | null }): Item {
const item = this.create(dto)
@@ -36,7 +41,6 @@ export class ItemFactory implements ItemFactoryInterface {
newItem.contentSize = 0
if (dto.itemHash.content) {
newItem.content = dto.itemHash.content
newItem.contentSize = Buffer.byteLength(dto.itemHash.content)
}
newItem.userUuid = dto.userUuid
if (dto.itemHash.content_type) {
@@ -75,6 +79,8 @@ export class ItemFactory implements ItemFactoryInterface {
newItem.createdAt = this.timer.convertStringDateToDate(dto.itemHash.created_at)
}
newItem.contentSize = Buffer.byteLength(JSON.stringify(this.itemProjector.projectFull(newItem)))
return newItem
}
}

View File

@@ -16,6 +16,8 @@ import { ItemSaveValidatorInterface } from './SaveValidator/ItemSaveValidatorInt
import { ItemFactoryInterface } from './ItemFactoryInterface'
import { ItemConflict } from './ItemConflict'
import { ItemTransferCalculatorInterface } from './ItemTransferCalculatorInterface'
import { ProjectorInterface } from '../../Projection/ProjectorInterface'
import { ItemProjection } from '../../Projection/ItemProjection'
describe('ItemService', () => {
let itemRepository: ItemRepositoryInterface
@@ -37,6 +39,8 @@ describe('ItemService', () => {
let itemFactory: ItemFactoryInterface
let timeHelper: Timer
let itemTransferCalculator: ItemTransferCalculatorInterface
let itemProjector: ProjectorInterface<Item, ItemProjection>
const maxItemsSyncLimit = 300
const createService = () =>
new ItemService(
@@ -50,6 +54,8 @@ describe('ItemService', () => {
contentSizeTransferLimit,
itemTransferCalculator,
timer,
itemProjector,
maxItemsSyncLimit,
logger,
)
@@ -156,6 +162,24 @@ describe('ItemService', () => {
itemFactory = {} as jest.Mocked<ItemFactoryInterface>
itemFactory.create = jest.fn().mockReturnValue(newItem)
itemFactory.createStub = jest.fn().mockReturnValue(newItem)
itemProjector = {} as jest.Mocked<ProjectorInterface<Item, ItemProjection>>
itemProjector.projectFull = jest.fn().mockReturnValue({
uuid: '1-2-3',
items_key_id: 'foobar',
duplicate_of: null,
enc_item_key: 'foobar',
content:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Sed viverra tellus in hac habitasse. Tortor posuere ac ut consequat semper. Ut diam quam nulla porttitor. Sapien pellentesque habitant morbi tristique senectus et netus et malesuada. Dapibus ultrices in iaculis nunc. Pellentesque habitant morbi tristique senectus et netus et malesuada fames. Faucibus et molestie ac feugiat sed lectus vestibulum mattis. Eu consequat ac felis donec. Eget velit aliquet sagittis id. Nullam eget felis eget nunc. Turpis in eu mi bibendum neque egestas congue.',
content_type: ContentType.Note,
auth_hash: 'foobar',
deleted: false,
created_at: '2022-09-01 10:00:00',
created_at_timestamp: 123123123123123,
updated_at: '2022-09-01 10:00:00',
updated_at_timestamp: 123123123123123,
updated_with_session: '2-4-5',
})
})
it('should retrieve all items for a user from last sync with sync token version 1', async () => {
@@ -214,6 +238,34 @@ describe('ItemService', () => {
})
})
it('should retrieve all items for a user from last sync with upper bound items limit', async () => {
expect(
await createService().getItems({
userUuid: '1-2-3',
syncToken,
contentType: ContentType.Note,
limit: 1000,
}),
).toEqual({
items: [item1, item2],
})
expect(itemRepository.countAll).toHaveBeenCalledWith({
contentType: 'Note',
lastSyncTime: 1616164633241564,
syncTimeComparison: '>',
sortBy: 'updated_at_timestamp',
sortOrder: 'ASC',
userUuid: '1-2-3',
limit: 300,
})
expect(itemRepository.findAll).toHaveBeenCalledWith({
uuids: ['1-2-3', '2-3-4'],
sortBy: 'updated_at_timestamp',
sortOrder: 'ASC',
})
})
it('should retrieve no items for a user if there are none from last sync', async () => {
itemTransferCalculator.computeItemUuidsToFetch = jest.fn().mockReturnValue([])
@@ -589,7 +641,7 @@ describe('ItemService', () => {
savedItems: [
{
content: 'asdqwe1',
contentSize: 7,
contentSize: 950,
contentType: 'Note',
createdAtTimestamp: expect.any(Number),
createdAt: expect.any(Date),
@@ -625,7 +677,7 @@ describe('ItemService', () => {
savedItems: [
{
content: 'asdqwe1',
contentSize: 7,
contentSize: 950,
contentType: 'Note',
createdAtTimestamp: expect.any(Number),
createdAt: expect.any(Date),
@@ -660,7 +712,7 @@ describe('ItemService', () => {
savedItems: [
{
content: 'asdqwe1',
contentSize: 7,
contentSize: 950,
contentType: 'Note',
createdAtTimestamp: 123,
createdAt: expect.any(Date),
@@ -696,7 +748,7 @@ describe('ItemService', () => {
conflicts: [],
savedItems: [
{
contentSize: 0,
contentSize: 950,
createdAtTimestamp: expect.any(Number),
createdAt: expect.any(Date),
userUuid: '1-2-3',
@@ -726,7 +778,7 @@ describe('ItemService', () => {
savedItems: [
{
content: 'asdqwe1',
contentSize: 7,
contentSize: 950,
contentType: 'Note',
createdAtTimestamp: expect.any(Number),
createdAt: expect.any(Date),
@@ -759,7 +811,7 @@ describe('ItemService', () => {
savedItems: [
{
content: 'asdqwe1',
contentSize: 7,
contentSize: 950,
contentType: 'Note',
createdAtTimestamp: expect.any(Number),
createdAt: expect.any(Date),
@@ -794,7 +846,7 @@ describe('ItemService', () => {
savedItems: [
{
content: 'asdqwe1',
contentSize: 7,
contentSize: 950,
contentType: 'Note',
createdAtTimestamp: expect.any(Number),
createdAt: expect.any(Date),
@@ -865,7 +917,7 @@ describe('ItemService', () => {
savedItems: [
{
content: 'asdqwe1',
contentSize: 7,
contentSize: 950,
contentType: 'Note',
createdAtTimestamp: expect.any(Number),
createdAt: expect.any(Date),

View File

@@ -21,6 +21,8 @@ import { SaveItemsResult } from './SaveItemsResult'
import { ItemSaveValidatorInterface } from './SaveValidator/ItemSaveValidatorInterface'
import { ConflictType } from '@standardnotes/responses'
import { ItemTransferCalculatorInterface } from './ItemTransferCalculatorInterface'
import { ProjectorInterface } from '../../Projection/ProjectorInterface'
import { ItemProjection } from '../../Projection/ItemProjection'
@injectable()
export class ItemService implements ItemServiceInterface {
@@ -38,6 +40,8 @@ export class ItemService implements ItemServiceInterface {
@inject(TYPES.CONTENT_SIZE_TRANSFER_LIMIT) private contentSizeTransferLimit: number,
@inject(TYPES.ItemTransferCalculator) private itemTransferCalculator: ItemTransferCalculatorInterface,
@inject(TYPES.Timer) private timer: TimerInterface,
@inject(TYPES.ItemProjector) private itemProjector: ProjectorInterface<Item, ItemProjection>,
@inject(TYPES.MAX_ITEMS_LIMIT) private maxItemsSyncLimit: number,
@inject(TYPES.Logger) private logger: Logger,
) {}
@@ -54,7 +58,7 @@ export class ItemService implements ItemServiceInterface {
deleted: lastSyncTime ? undefined : false,
sortBy: 'updated_at_timestamp',
sortOrder: 'ASC',
limit,
limit: limit < this.maxItemsSyncLimit ? limit : this.maxItemsSyncLimit,
}
const itemUuidsToFetch = await this.itemTransferCalculator.computeItemUuidsToFetch(
@@ -196,7 +200,6 @@ export class ItemService implements ItemServiceInterface {
dto.existingItem.contentSize = 0
if (dto.itemHash.content) {
dto.existingItem.content = dto.itemHash.content
dto.existingItem.contentSize = Buffer.byteLength(dto.itemHash.content)
}
if (dto.itemHash.content_type) {
dto.existingItem.contentType = dto.itemHash.content_type
@@ -219,14 +222,6 @@ export class ItemService implements ItemServiceInterface {
dto.existingItem.itemsKeyId = dto.itemHash.items_key_id
}
if (dto.itemHash.deleted === true) {
dto.existingItem.deleted = true
dto.existingItem.content = null
;(dto.existingItem.contentSize = 0), (dto.existingItem.encItemKey = null)
dto.existingItem.authHash = null
dto.existingItem.itemsKeyId = null
}
const updatedAt = this.timer.getTimestampInMicroseconds()
const secondsFromLastUpdate = this.timer.convertMicrosecondsToSeconds(
updatedAt - dto.existingItem.updatedAtTimestamp,
@@ -243,6 +238,17 @@ export class ItemService implements ItemServiceInterface {
dto.existingItem.updatedAtTimestamp = updatedAt
dto.existingItem.updatedAt = this.timer.convertMicrosecondsToDate(updatedAt)
dto.existingItem.contentSize = Buffer.byteLength(JSON.stringify(this.itemProjector.projectFull(dto.existingItem)))
if (dto.itemHash.deleted === true) {
dto.existingItem.deleted = true
dto.existingItem.content = null
dto.existingItem.contentSize = 0
dto.existingItem.encItemKey = null
dto.existingItem.authHash = null
dto.existingItem.itemsKeyId = null
}
const savedItem = await this.itemRepository.save(dto.existingItem)
if (secondsFromLastUpdate >= this.revisionFrequency) {

View File

@@ -1803,18 +1803,18 @@ __metadata:
languageName: unknown
linkType: soft
"@standardnotes/api@npm:^1.7.2":
version: 1.7.2
resolution: "@standardnotes/api@npm:1.7.2"
"@standardnotes/api@npm:^1.8.1":
version: 1.8.1
resolution: "@standardnotes/api@npm:1.8.1"
dependencies:
"@standardnotes/common": ^1.32.0
"@standardnotes/encryption": 1.15.2
"@standardnotes/models": 1.18.2
"@standardnotes/responses": 1.10.1
"@standardnotes/encryption": 1.15.3
"@standardnotes/models": 1.18.3
"@standardnotes/responses": 1.10.2
"@standardnotes/security": ^1.1.0
"@standardnotes/utils": 1.9.0
reflect-metadata: ^0.1.13
checksum: bdfc414e6d01620fd047979255a43eb447afbb69d1bb694015b162ad236431273cd234bba4129d13ba94791271aaff71895d726357491d6ab984c7d5a7a8a3f7
checksum: 76c5d1a2d29cf7f407813246febf54fe02c5d7cacedcfd1bf5f6ee6630d847f58cae0b5827fbba1c5c5d5a30e56095833c9eff8b413111f8aae9cc17802ffa63
languageName: node
linkType: hard
@@ -1825,11 +1825,11 @@ __metadata:
"@newrelic/winston-enricher": ^4.0.0
"@sentry/node": ^7.3.0
"@standardnotes/analytics": "workspace:*"
"@standardnotes/api": ^1.7.2
"@standardnotes/api": ^1.8.1
"@standardnotes/common": "workspace:*"
"@standardnotes/domain-events": "workspace:*"
"@standardnotes/domain-events-infra": "workspace:*"
"@standardnotes/features": ^1.47.0
"@standardnotes/features": ^1.52.1
"@standardnotes/predicates": "workspace:*"
"@standardnotes/responses": ^1.6.39
"@standardnotes/security": "workspace:*"
@@ -1939,7 +1939,7 @@ __metadata:
resolution: "@standardnotes/domain-events@workspace:packages/domain-events"
dependencies:
"@standardnotes/common": "workspace:*"
"@standardnotes/features": ^1.47.0
"@standardnotes/features": ^1.52.1
"@standardnotes/predicates": "workspace:*"
"@standardnotes/security": "workspace:*"
"@types/jest": ^28.1.4
@@ -1951,17 +1951,17 @@ __metadata:
languageName: unknown
linkType: soft
"@standardnotes/encryption@npm:1.15.2":
version: 1.15.2
resolution: "@standardnotes/encryption@npm:1.15.2"
"@standardnotes/encryption@npm:1.15.3":
version: 1.15.3
resolution: "@standardnotes/encryption@npm:1.15.3"
dependencies:
"@standardnotes/common": ^1.32.0
"@standardnotes/models": 1.18.2
"@standardnotes/responses": 1.10.1
"@standardnotes/models": 1.18.3
"@standardnotes/responses": 1.10.2
"@standardnotes/sncrypto-common": 1.11.1
"@standardnotes/utils": 1.9.0
reflect-metadata: ^0.1.13
checksum: 6e8336f1e7e961fbd42c4890458dca877da62dcc1987f7e9a7fb6ca230821276fce6a33652669bcc1752a80ffc55e4cf82b8631f7902d9714f4a07a7956092b0
checksum: 1a7863299f86ee28de1640b93277f8b3e206bec2b34a205eb6e6fd6c6899a4908623acd9f2452d71a83c542b1f181408e7743386e5e1079239c6c0fa384242c9
languageName: node
linkType: hard
@@ -2016,6 +2016,18 @@ __metadata:
languageName: node
linkType: hard
"@standardnotes/features@npm:^1.52.1":
version: 1.52.1
resolution: "@standardnotes/features@npm:1.52.1"
dependencies:
"@standardnotes/auth": ^3.19.4
"@standardnotes/common": ^1.32.0
"@standardnotes/security": ^1.2.0
reflect-metadata: ^0.1.13
checksum: ff3684399e0e0c0e799f11e69dddea2e1f65f315e5a5dd3ca5640e24e836ee85faf1f5ee15fc804411bf083004527fcef08411d5c2d0b5894491bf2f28ceca68
languageName: node
linkType: hard
"@standardnotes/files-server@workspace:packages/files":
version: 0.0.0-use.local
resolution: "@standardnotes/files-server@workspace:packages/files"
@@ -2066,17 +2078,17 @@ __metadata:
languageName: unknown
linkType: soft
"@standardnotes/models@npm:1.18.2":
version: 1.18.2
resolution: "@standardnotes/models@npm:1.18.2"
"@standardnotes/models@npm:1.18.3":
version: 1.18.3
resolution: "@standardnotes/models@npm:1.18.3"
dependencies:
"@standardnotes/common": ^1.32.0
"@standardnotes/features": 1.52.0
"@standardnotes/responses": 1.10.1
"@standardnotes/responses": 1.10.2
"@standardnotes/utils": 1.9.0
lodash: ^4.17.21
reflect-metadata: ^0.1.13
checksum: 88180a93e5acdc349e1f96159c40610d7f52d49f0566386d9d6db8767d5ac4ba73af3131c8e433afa253557349e3f96238f6b2060e94df51ceedb5d378b3dd1f
checksum: 21830c805ffa1ac2184c64903f88915b7b439eb4eb80ac0686c4920a9a4c86cc6c71a3daeb1ede8f3fe6cf0ce106f7ba396f994165306c1c59c05902a7ec075a
languageName: node
linkType: hard
@@ -2105,15 +2117,15 @@ __metadata:
languageName: unknown
linkType: soft
"@standardnotes/responses@npm:1.10.1":
version: 1.10.1
resolution: "@standardnotes/responses@npm:1.10.1"
"@standardnotes/responses@npm:1.10.2":
version: 1.10.2
resolution: "@standardnotes/responses@npm:1.10.2"
dependencies:
"@standardnotes/common": ^1.32.0
"@standardnotes/features": 1.52.0
"@standardnotes/security": ^1.1.0
reflect-metadata: ^0.1.13
checksum: b84fb3f71cc32286fc757280e01c2da7fd0576e96455bfd53c5e55f807875d7201a23e727a7c702277b90f1959837a9a0cbda94ca6a4f4ad6a4896e306ed851c
checksum: 364724b5c7efa06948a240da82320817bce58049d6ea226cc2e828e86144ba4d19e5ae6fa437311438008b16aff7bd8cbe7e3f7475a2e9c89cf47650e1e1e9b0
languageName: node
linkType: hard