├── .editorconfig ├── .env.sample ├── .eslintignore ├── .eslintrc ├── .github ├── FUNDING.yml ├── codeql │ ├── codeql-config.yml │ └── custom-queries │ │ └── javascript │ │ └── qlpack.yml ├── pull_request_template.md └── workflows │ ├── codeql-analysis.yml │ ├── dev.yml │ ├── pr.yml │ └── prod.yml ├── .gitignore ├── .nvmrc ├── Dockerfile ├── LICENSE.txt ├── README.md ├── bin ├── server.ts └── worker.ts ├── data └── .gitkeep ├── docker-compose.yml ├── docker ├── api-gateway.env.sample ├── auth.env.sample └── entrypoint.sh ├── linter.tsconfig.json ├── migrations ├── 1606470249552-init_database.ts ├── 1617615657558-add_extension_settings.ts ├── 1629964808297-drop_unused_indexes.ts ├── 1630318893601-refactor_calculating_integrity_hash.ts ├── 1630417724617-restrict_content_type.ts ├── 1631529502150-add_revision_for_duplicated_items.ts ├── 1631530260504-drop_item_revisions_joining_table.ts ├── 1632219307742-cleanup_orphan_items_and_revisions.ts ├── 1632221263106-add_revisions_items_relation.ts ├── 1637738491169-add_item_content_size.ts ├── 1639134926025-remove_extension_settings.ts ├── 1642073387521-remove_sf_extension_items.ts ├── 1647501696205-remove_user_agent.ts └── 1654518291191-add_updated_with_session.ts ├── nodemon.json ├── package.json ├── server.sh ├── src ├── Bootstrap │ ├── Container.ts │ ├── DataSource.ts │ ├── Env.ts │ └── Types.ts ├── Controller │ ├── AuthMiddleware.spec.ts │ ├── AuthMiddleware.ts │ ├── HealthCheckController.ts │ ├── ItemsController.spec.ts │ ├── ItemsController.ts │ ├── RevisionsController.spec.ts │ └── RevisionsController.ts ├── Domain │ ├── Api │ │ └── ApiVersion.ts │ ├── Auth │ │ └── AuthHttpServiceInterface.ts │ ├── Event │ │ ├── DomainEventFactory.spec.ts │ │ ├── DomainEventFactory.ts │ │ └── DomainEventFactoryInterface.ts │ ├── Extension │ │ ├── ExtensionName.ts │ │ ├── ExtensionsHttpService.spec.ts │ │ ├── ExtensionsHttpService.ts │ │ ├── ExtensionsHttpServiceInterface.ts │ │ └── SendItemsToExtensionsServerDTO.ts │ ├── Handler │ │ ├── AccountDeletionRequestedEventHandler.spec.ts │ │ ├── AccountDeletionRequestedEventHandler.ts │ │ ├── CloudBackupRequestedEventHandler.spec.ts │ │ ├── CloudBackupRequestedEventHandler.ts │ │ ├── DuplicateItemSyncedEventHandler.spec.ts │ │ ├── DuplicateItemSyncedEventHandler.ts │ │ ├── EmailArchiveExtensionSyncedEventHandler.spec.ts │ │ ├── EmailArchiveExtensionSyncedEventHandler.ts │ │ ├── EmailBackupRequestedEventHandler.spec.ts │ │ ├── EmailBackupRequestedEventHandler.ts │ │ ├── ItemsSyncedEventHandler.spec.ts │ │ └── ItemsSyncedEventHandler.ts │ ├── Item │ │ ├── ContentDecoder.spec.ts │ │ ├── ContentDecoder.ts │ │ ├── ContentDecoderInterface.ts │ │ ├── ExtendedIntegrityPayload.ts │ │ ├── GetItemsDTO.ts │ │ ├── GetItemsResult.ts │ │ ├── Item.ts │ │ ├── ItemBackupServiceInterface.ts │ │ ├── ItemConflict.ts │ │ ├── ItemFactory.spec.ts │ │ ├── ItemFactory.ts │ │ ├── ItemFactoryInterface.ts │ │ ├── ItemHash.ts │ │ ├── ItemQuery.ts │ │ ├── ItemRepositoryInterface.ts │ │ ├── ItemService.spec.ts │ │ ├── ItemService.ts │ │ ├── ItemServiceInterface.ts │ │ ├── ItemTransferCalculator.spec.ts │ │ ├── ItemTransferCalculator.ts │ │ ├── ItemTransferCalculatorInterface.ts │ │ ├── SaveItemsDTO.ts │ │ ├── SaveItemsResult.ts │ │ ├── SaveRule │ │ │ ├── ContentFilter.spec.ts │ │ │ ├── ContentFilter.ts │ │ │ ├── ContentTypeFilter.spec.ts │ │ │ ├── ContentTypeFilter.ts │ │ │ ├── ItemSaveRuleInterface.ts │ │ │ ├── ItemSaveRuleResult.ts │ │ │ ├── OwnershipFilter.spec.ts │ │ │ ├── OwnershipFilter.ts │ │ │ ├── TimeDifferenceFilter.spec.ts │ │ │ ├── TimeDifferenceFilter.ts │ │ │ ├── UuidFilter.spec.ts │ │ │ └── UuidFilter.ts │ │ ├── SaveValidator │ │ │ ├── ItemSaveValidationDTO.ts │ │ │ ├── ItemSaveValidationResult.ts │ │ │ ├── ItemSaveValidator.spec.ts │ │ │ ├── ItemSaveValidator.ts │ │ │ └── ItemSaveValidatorInterface.ts │ │ └── SyncResponse │ │ │ ├── SyncResponse20161215.ts │ │ │ ├── SyncResponse20200115.ts │ │ │ ├── SyncResponseFactory20161215.spec.ts │ │ │ ├── SyncResponseFactory20161215.ts │ │ │ ├── SyncResponseFactory20200115.spec.ts │ │ │ ├── SyncResponseFactory20200115.ts │ │ │ ├── SyncResponseFactoryInterface.ts │ │ │ ├── SyncResponseFactoryResolver.spec.ts │ │ │ ├── SyncResponseFactoryResolver.ts │ │ │ └── SyncResponseFactoryResolverInterface.ts │ ├── Revision │ │ ├── Revision.spec.ts │ │ ├── Revision.ts │ │ ├── RevisionRepositoryInterface.ts │ │ ├── RevisionService.spec.ts │ │ ├── RevisionService.ts │ │ └── RevisionServiceInterface.ts │ └── UseCase │ │ ├── CheckIntegrity │ │ ├── CheckIntegrity.spec.ts │ │ ├── CheckIntegrity.ts │ │ ├── CheckIntegrityDTO.ts │ │ └── CheckIntegrityResponse.ts │ │ ├── GetItem │ │ ├── GetItem.spec.ts │ │ ├── GetItem.ts │ │ ├── GetItemDTO.ts │ │ └── GetItemResponse.ts │ │ ├── SyncItems.spec.ts │ │ ├── SyncItems.ts │ │ ├── SyncItemsDTO.ts │ │ ├── SyncItemsResponse.ts │ │ └── UseCaseInterface.ts ├── Infra │ ├── HTTP │ │ ├── AuthHttpService.spec.ts │ │ └── AuthHttpService.ts │ ├── MySQL │ │ ├── MySQLItemRepository.spec.ts │ │ ├── MySQLItemRepository.ts │ │ ├── MySQLRevisionRepository.spec.ts │ │ └── MySQLRevisionRepository.ts │ └── S3 │ │ ├── S3ItemBackupService.spec.ts │ │ └── S3ItemBackupService.ts └── Projection │ ├── ItemConflictProjection.ts │ ├── ItemConflictProjector.spec.ts │ ├── ItemConflictProjector.ts │ ├── ItemProjection.ts │ ├── ItemProjector.spec.ts │ ├── ItemProjector.ts │ ├── ProjectorInterface.ts │ ├── RevisionProjection.ts │ ├── RevisionProjector.spec.ts │ ├── RevisionProjector.ts │ ├── SavedItemProjection.ts │ ├── SavedItemProjector.spec.ts │ ├── SavedItemProjector.ts │ └── SimpleRevisionProjection.ts ├── test-setup.ts ├── tsconfig.json ├── wait-for.sh └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # http://editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | 9 | # Change these settings to your own preference 10 | indent_style = space 11 | indent_size = 2 12 | 13 | # We recommend you to keep these unchanged 14 | end_of_line = lf 15 | charset = utf-8 16 | trim_trailing_whitespace = true 17 | insert_final_newline = true 18 | 19 | [*.md] 20 | trim_trailing_whitespace = false 21 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | LOG_LEVEL=info 2 | NODE_ENV=development 3 | VERSION=development 4 | 5 | AUTH_JWT_SECRET=auth_jwt_secret 6 | 7 | PORT=3000 8 | 9 | DB_HOST=db 10 | DB_REPLICA_HOST=db 11 | DB_PORT=3306 12 | DB_USERNAME=std_notes_user 13 | DB_PASSWORD=changeme123 14 | DB_DATABASE=standard_notes_db 15 | DB_DEBUG_LEVEL=all # "all" | "query" | "schema" | "error" | "warn" | "info" | "log" | "migration" 16 | DB_MIGRATIONS_PATH=dist/migrations/*.js 17 | 18 | REDIS_URL=redis://cache 19 | 20 | SNS_TOPIC_ARN= 21 | SNS_AWS_REGION= 22 | SQS_QUEUE_URL= 23 | SQS_AWS_REGION= 24 | S3_AWS_REGION= 25 | S3_BACKUP_BUCKET_NAME= 26 | 27 | REDIS_EVENTS_CHANNEL=events 28 | 29 | INTERNAL_DNS_REROUTE_ENABLED=false 30 | EXTENSIONS_SERVER_URL=http://extensions-server:3004 31 | AUTH_SERVER_URL=http://auth:3000 32 | 33 | EMAIL_ATTACHMENT_MAX_BYTE_SIZE=10485760 34 | 35 | REVISIONS_FREQUENCY=300 36 | 37 | # (Optional) New Relic Setup 38 | NEW_RELIC_ENABLED=false 39 | NEW_RELIC_APP_NAME="Syncing Server JS" 40 | NEW_RELIC_LICENSE_KEY= 41 | NEW_RELIC_NO_CONFIG_FILE=true 42 | NEW_RELIC_DISTRIBUTED_TRACING_ENABLED=false 43 | NEW_RELIC_LOG_ENABLED=false 44 | NEW_RELIC_LOG_LEVEL=info 45 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | test-setup.ts 4 | codeqldb 5 | data 6 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "./node_modules/@standardnotes/config/src/.eslintrc" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [standardnotes] 4 | -------------------------------------------------------------------------------- /.github/codeql/codeql-config.yml: -------------------------------------------------------------------------------- 1 | name: "Custom CodeQL Config" 2 | 3 | queries: 4 | - uses: security-and-quality 5 | - uses: ./.github/codeql/custom-queries/javascript 6 | 7 | paths: 8 | - src 9 | - bin 10 | 11 | paths-ignore: 12 | - dist 13 | - node_modules 14 | - coverage 15 | -------------------------------------------------------------------------------- /.github/codeql/custom-queries/javascript/qlpack.yml: -------------------------------------------------------------------------------- 1 | name: custom-javascript-queries 2 | version: 0.0.0 3 | libraryPathDependencies: 4 | - codeql-javascript 5 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | 4 | 5 | ## Related Issue(s) 6 | 7 | 8 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ develop, main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ develop ] 20 | schedule: 21 | - cron: '19 23 * * 3' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | 28 | strategy: 29 | fail-fast: false 30 | matrix: 31 | language: [ 'javascript' ] 32 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 33 | # Learn more: 34 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 35 | 36 | steps: 37 | - name: Checkout repository 38 | uses: actions/checkout@v2 39 | 40 | # Initializes the CodeQL tools for scanning. 41 | - name: Initialize CodeQL 42 | uses: github/codeql-action/init@v1 43 | with: 44 | languages: ${{ matrix.language }} 45 | config-file: ./.github/codeql/codeql-config.yml 46 | # If you wish to specify custom queries, you can do so here or in a config file. 47 | # By default, queries listed here will override any specified in a config file. 48 | # Prefix the list here with "+" to use these queries and those in the config file. 49 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 50 | 51 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 52 | # If this step fails, then you should remove it and run the build manually (see below) 53 | - name: Autobuild 54 | uses: github/codeql-action/autobuild@v1 55 | 56 | # ℹ️ Command-line programs to run using the OS shell. 57 | # 📚 https://git.io/JvXDl 58 | 59 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 60 | # and modify them (or add more) to build your code if your project 61 | # uses a compiled language 62 | 63 | #- run: | 64 | # make bootstrap 65 | # make release 66 | 67 | - name: Perform CodeQL Analysis 68 | uses: github/codeql-action/analyze@v1 69 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: PR 2 | 3 | on: 4 | pull_request: 5 | branches: [ develop ] 6 | 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: actions/setup-node@v1 14 | with: 15 | node-version: '16.x' 16 | - run: yarn install --pure-lockfile 17 | - run: yarn test 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | 4 | yarn-error.log 5 | newrelic_agent.log 6 | 7 | # Coverage 8 | coverage 9 | junit.xml 10 | 11 | .DS_Store 12 | 13 | # MySQL & Redis data 14 | data/mysql 15 | data/redis 16 | 17 | # Ignore ENV variables config 18 | .env 19 | auth.env 20 | api-gateway.env 21 | syncing-server.env 22 | 23 | codeqldb 24 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 16.13.1 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16.13.1-alpine3.15 2 | 3 | ARG UID=1001 4 | ARG GID=1001 5 | 6 | RUN addgroup -S syncingserver -g $GID && adduser -D -S syncingserver -G syncingserver -u $UID 7 | 8 | RUN apk add --update --no-cache \ 9 | alpine-sdk \ 10 | python3 11 | 12 | WORKDIR /var/www 13 | 14 | RUN chown -R $UID:$GID . 15 | 16 | USER syncingserver 17 | 18 | COPY --chown=$UID:$GID package.json yarn.lock /var/www/ 19 | 20 | RUN yarn install --pure-lockfile 21 | 22 | COPY --chown=$UID:$GID . /var/www 23 | 24 | RUN NODE_OPTIONS="--max-old-space-size=2048" yarn build 25 | 26 | ENTRYPOINT [ "docker/entrypoint.sh" ] 27 | 28 | CMD [ "start-web" ] 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Standard Notes Syncing Server 2 | 3 | You can run your own Standard Notes server and use it with any Standard Notes app. This allows you to have 100% control of your data. This server is built with TypeScript and can be deployed in minutes. 4 | 5 | **Requirements** 6 | 7 | - Docker 8 | 9 | **Data persistency** 10 | 11 | Your MySQL Data will be written to your local disk in the `data` folder to keep it persistent between server runs. 12 | 13 | ### Getting started 14 | 15 | 1. Clone the project: 16 | 17 | ``` 18 | git clone --branch main https://github.com/standardnotes/syncing-server-js.git 19 | ``` 20 | 21 | 1. Setup the server by running: 22 | ``` 23 | ./server.sh setup 24 | ``` 25 | 26 | 1. Run the server by typing: 27 | ``` 28 | ./server.sh start 29 | ``` 30 | 31 | Your server should now be available under http://localhost:3000 32 | 33 | **Note**: When running the server locally it is by default ran in a Hot Reload Mode. This means that every time you change any of the source files locally the server is restarted to reflect the changes. This is in particular helpful when doing local development. 34 | 35 | ### Logs 36 | 37 | You can check the logs of the running server by typing: 38 | 39 | ``` 40 | ./server.sh logs 41 | ``` 42 | 43 | ### Stopping the Server 44 | 45 | In order to stop the server type: 46 | ``` 47 | ./server.sh stop 48 | ``` 49 | 50 | ### Updating to latest version 51 | 52 | In order to update to the latest version of our software type: 53 | 54 | ``` 55 | ./server.sh update 56 | ``` 57 | 58 | ### Checking Status 59 | 60 | You can check the status of running services by typing: 61 | ``` 62 | ./server.sh status 63 | ``` 64 | 65 | ### Cleanup Data 66 | 67 | Please use this step with caution. In order to remove all your data and start with a fresh environment please type: 68 | ``` 69 | ./server.sh cleanup 70 | ``` 71 | 72 | ### Tests 73 | 74 | To execute all of the test specs, run the following command at the root of the project directory: 75 | 76 | ```bash 77 | yarn test 78 | ``` 79 | -------------------------------------------------------------------------------- /bin/server.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | 3 | import 'newrelic' 4 | 5 | import * as Sentry from '@sentry/node' 6 | 7 | import '../src/Controller/HealthCheckController' 8 | import '../src/Controller/RevisionsController' 9 | import '../src/Controller/ItemsController' 10 | 11 | import * as helmet from 'helmet' 12 | import * as cors from 'cors' 13 | import { urlencoded, json, Request, Response, NextFunction, RequestHandler, ErrorRequestHandler } from 'express' 14 | import * as winston from 'winston' 15 | 16 | import { InversifyExpressServer } from 'inversify-express-utils' 17 | import { ContainerConfigLoader } from '../src/Bootstrap/Container' 18 | import TYPES from '../src/Bootstrap/Types' 19 | import { Env } from '../src/Bootstrap/Env' 20 | 21 | const container = new ContainerConfigLoader() 22 | void container.load().then((container) => { 23 | const env: Env = new Env() 24 | env.load() 25 | 26 | const server = new InversifyExpressServer(container) 27 | 28 | server.setConfig((app) => { 29 | app.use((_request: Request, response: Response, next: NextFunction) => { 30 | response.setHeader('X-SSJS-Version', container.get(TYPES.VERSION)) 31 | next() 32 | }) 33 | /* eslint-disable */ 34 | app.use(helmet({ 35 | contentSecurityPolicy: { 36 | directives: { 37 | defaultSrc: ["https: 'self'"], 38 | baseUri: ["'self'"], 39 | childSrc: ["*", "blob:"], 40 | connectSrc: ["*"], 41 | fontSrc: ["*", "'self'"], 42 | formAction: ["'self'"], 43 | frameAncestors: ["*", "*.standardnotes.org"], 44 | frameSrc: ["*", "blob:"], 45 | imgSrc: ["'self'", "*", "data:"], 46 | manifestSrc: ["'self'"], 47 | mediaSrc: ["'self'"], 48 | objectSrc: ["'self'"], 49 | scriptSrc: ["'self'"], 50 | styleSrc: ["'self'"] 51 | } 52 | } 53 | })) 54 | /* eslint-enable */ 55 | app.use(json({ limit: '50mb' })) 56 | app.use(urlencoded({ extended: true, limit: '50mb', parameterLimit: 5000 })) 57 | app.use(cors()) 58 | 59 | if (env.get('SENTRY_DSN', true)) { 60 | Sentry.init({ 61 | dsn: env.get('SENTRY_DSN'), 62 | integrations: [new Sentry.Integrations.Http({ tracing: false, breadcrumbs: true })], 63 | tracesSampleRate: 0, 64 | }) 65 | 66 | app.use(Sentry.Handlers.requestHandler() as RequestHandler) 67 | } 68 | }) 69 | 70 | const logger: winston.Logger = container.get(TYPES.Logger) 71 | 72 | server.setErrorConfig((app) => { 73 | if (env.get('SENTRY_DSN', true)) { 74 | app.use(Sentry.Handlers.errorHandler() as ErrorRequestHandler) 75 | } 76 | 77 | app.use((error: Record, _request: Request, response: Response, _next: NextFunction) => { 78 | logger.error(error.stack) 79 | 80 | response.status(500).send({ 81 | error: { 82 | message: 83 | "Unfortunately, we couldn't handle your request. Please try again or contact our support if the error persists.", 84 | }, 85 | }) 86 | }) 87 | }) 88 | 89 | const serverInstance = server.build() 90 | 91 | serverInstance.listen(env.get('PORT')) 92 | 93 | logger.info(`Server started on port ${process.env.PORT}`) 94 | }) 95 | -------------------------------------------------------------------------------- /bin/worker.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | 3 | import 'newrelic' 4 | 5 | import { Logger } from 'winston' 6 | 7 | import { ContainerConfigLoader } from '../src/Bootstrap/Container' 8 | import TYPES from '../src/Bootstrap/Types' 9 | import { Env } from '../src/Bootstrap/Env' 10 | import { DomainEventSubscriberFactoryInterface } from '@standardnotes/domain-events' 11 | 12 | const container = new ContainerConfigLoader() 13 | void container.load().then((container) => { 14 | const env: Env = new Env() 15 | env.load() 16 | 17 | const logger: Logger = container.get(TYPES.Logger) 18 | 19 | logger.info('Starting worker...') 20 | 21 | const subscriberFactory: DomainEventSubscriberFactoryInterface = container.get(TYPES.DomainEventSubscriberFactory) 22 | subscriberFactory.create().start() 23 | 24 | setInterval(() => logger.info('Alive and kicking!'), 20 * 60 * 1000) 25 | }) 26 | -------------------------------------------------------------------------------- /data/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/standardnotes/syncing-server-js/8ffb21d12bda80607b6c1c1fee834b2848251bd9/data/.gitkeep -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | services: 3 | syncing-server-js: 4 | image: syncing-server-js-local 5 | build: . 6 | entrypoint: [ 7 | "./wait-for.sh", "db", "3306", 8 | "./wait-for.sh", "cache", "6379", 9 | "./docker/entrypoint.sh", "start-local" 10 | ] 11 | env_file: .env 12 | restart: unless-stopped 13 | ports: 14 | - 3000:${PORT} 15 | networks: 16 | - syncing_server_js 17 | volumes: 18 | - .:/var/www 19 | 20 | syncing-server-js-worker: 21 | image: syncing-server-js-local 22 | build: . 23 | entrypoint: [ 24 | "./wait-for.sh", "db", "3306", 25 | "./wait-for.sh", "cache", "6379", 26 | "./wait-for.sh", "syncing-server-js", "3000", 27 | "./docker/entrypoint.sh", "start-worker" 28 | ] 29 | env_file: .env 30 | restart: unless-stopped 31 | networks: 32 | - syncing_server_js 33 | volumes: 34 | - .:/var/www 35 | 36 | api-gateway: 37 | image: standardnotes/api-gateway:dev 38 | env_file: docker/api-gateway.env 39 | environment: 40 | PORT: 3000 41 | AUTH_JWT_SECRET: '${AUTH_JWT_SECRET}' 42 | entrypoint: [ 43 | "./wait-for.sh", "auth", "3000", 44 | "./wait-for.sh", "syncing-server-js", "3000", 45 | "./docker/entrypoint.sh", "start-web" 46 | ] 47 | ports: 48 | - 3124:3000 49 | networks: 50 | - syncing_server_js 51 | 52 | auth: 53 | image: standardnotes/auth:dev 54 | entrypoint: [ 55 | "./wait-for.sh", "db", "3306", 56 | "./wait-for.sh", "cache", "6379", 57 | "./wait-for.sh", "syncing-server-js", "3000", 58 | "./docker/entrypoint.sh", "start-web" 59 | ] 60 | env_file: docker/auth.env 61 | environment: 62 | DB_HOST: '${DB_HOST}' 63 | DB_REPLICA_HOST: '${DB_REPLICA_HOST}' 64 | DB_PORT: '${DB_PORT}' 65 | DB_DATABASE: '${DB_DATABASE}' 66 | DB_USERNAME: '${DB_USERNAME}' 67 | DB_PASSWORD: '${DB_PASSWORD}' 68 | DB_DEBUG_LEVEL: '${DB_DEBUG_LEVEL}' 69 | DB_MIGRATIONS_PATH: '${DB_MIGRATIONS_PATH}' 70 | REDIS_URL: '${REDIS_URL}' 71 | AUTH_JWT_SECRET: '${AUTH_JWT_SECRET}' 72 | networks: 73 | - syncing_server_js 74 | 75 | auth-worker: 76 | image: standardnotes/auth:dev 77 | entrypoint: [ 78 | "./wait-for.sh", "db", "3306", 79 | "./wait-for.sh", "cache", "6379", 80 | "./wait-for.sh", "auth", "3000", 81 | "./docker/entrypoint.sh", "start-worker" 82 | ] 83 | env_file: docker/auth.env 84 | environment: 85 | DB_HOST: '${DB_HOST}' 86 | DB_REPLICA_HOST: '${DB_REPLICA_HOST}' 87 | DB_PORT: '${DB_PORT}' 88 | DB_DATABASE: '${DB_DATABASE}' 89 | DB_USERNAME: '${DB_USERNAME}' 90 | DB_PASSWORD: '${DB_PASSWORD}' 91 | DB_DEBUG_LEVEL: '${DB_DEBUG_LEVEL}' 92 | DB_MIGRATIONS_PATH: '${DB_MIGRATIONS_PATH}' 93 | REDIS_URL: '${REDIS_URL}' 94 | AUTH_JWT_SECRET: '${AUTH_JWT_SECRET}' 95 | networks: 96 | - syncing_server_js 97 | 98 | db: 99 | image: mysql:5.6 100 | environment: 101 | MYSQL_DATABASE: '${DB_DATABASE}' 102 | MYSQL_USER: '${DB_USERNAME}' 103 | MYSQL_PASSWORD: '${DB_PASSWORD}' 104 | MYSQL_ROOT_PASSWORD: '${DB_PASSWORD}' 105 | ports: 106 | - 32789:3306 107 | restart: unless-stopped 108 | command: --default-authentication-plugin=mysql_native_password --character-set-server=utf8 --collation-server=utf8_general_ci 109 | volumes: 110 | - ./data/mysql:/var/lib/mysql 111 | networks: 112 | - syncing_server_js 113 | 114 | cache: 115 | image: redis:6.0-alpine 116 | volumes: 117 | - ./data/redis/:/data 118 | ports: 119 | - 6380:6379 120 | networks: 121 | - syncing_server_js 122 | 123 | networks: 124 | syncing_server_js: 125 | name: syncing_server_js 126 | -------------------------------------------------------------------------------- /docker/api-gateway.env.sample: -------------------------------------------------------------------------------- 1 | LOG_LEVEL="info" 2 | NODE_ENV="production" 3 | VERSION="development" 4 | 5 | PORT=3000 6 | 7 | NEW_RELIC_ENABLED=false 8 | NEW_RELIC_APP_NAME="API Gateway" 9 | NEW_RELIC_NO_CONFIG_FILE=true 10 | 11 | SYNCING_SERVER_JS_URL="http://syncing-server-js:3000" 12 | AUTH_SERVER_URL="http://auth:3000" 13 | 14 | HTTP_CALL_TIMEOUT=10000 15 | -------------------------------------------------------------------------------- /docker/auth.env.sample: -------------------------------------------------------------------------------- 1 | LOG_LEVEL="info" 2 | NODE_ENV="production" 3 | VERSION="development" 4 | 5 | JWT_SECRET=secret 6 | LEGACY_JWT_SECRET=legacy_jwt_secret 7 | AUTH_JWT_TTL=60000 8 | 9 | NEW_RELIC_ENABLED=false 10 | NEW_RELIC_APP_NAME=Auth 11 | NEW_RELIC_NO_CONFIG_FILE=true 12 | 13 | REDIS_EVENTS_CHANNEL="auth-events" 14 | 15 | DISABLE_USER_REGISTRATION=false 16 | 17 | PSEUDO_KEY_PARAMS_KEY=secret_key 18 | 19 | FAILED_LOGIN_LOCKOUT=3600 20 | MAX_LOGIN_ATTEMPTS=6 21 | 22 | ACCESS_TOKEN_AGE=5184000 23 | REFRESH_TOKEN_AGE=31556926 24 | 25 | USER_SERVER_REGISTRATION_URL= 26 | USER_SERVER_AUTH_KEY= 27 | 28 | EPHEMERAL_SESSION_AGE=259200 29 | 30 | # Must be a hex string exactly 32 bytes long 31 | # e.g. feffe9928665731c6d6a8f9467308308feffe9928665731c6d6a8f9467308308 32 | ENCRYPTION_SERVER_KEY=change-me-! 33 | 34 | PORT=3000 35 | -------------------------------------------------------------------------------- /docker/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | COMMAND=$1 && shift 1 5 | 6 | case "$COMMAND" in 7 | 'start-local') 8 | echo "Starting Web in Local Mode..." 9 | yarn start:local 10 | ;; 11 | 12 | 'start-web' ) 13 | echo "Starting Web..." 14 | yarn start 15 | ;; 16 | 17 | 'start-worker' ) 18 | echo "Starting Worker..." 19 | yarn worker 20 | ;; 21 | 22 | * ) 23 | echo "Unknown command" 24 | ;; 25 | esac 26 | 27 | exec "$@" 28 | -------------------------------------------------------------------------------- /linter.tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": [ 3 | "node_modules", 4 | "dist" 5 | ], 6 | "extends": "./tsconfig.json" 7 | } 8 | -------------------------------------------------------------------------------- /migrations/1606470249552-init_database.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner } from 'typeorm' 2 | 3 | export class initDatabase1606470249552 implements MigrationInterface { 4 | name = 'initDatabase1606470249552' 5 | 6 | public async up(queryRunner: QueryRunner): Promise { 7 | await this.fixUpdatedAtTimestampsFromLegacyMigration(queryRunner) 8 | 9 | await queryRunner.query( 10 | 'CREATE TABLE IF NOT EXISTS `items` (`uuid` varchar(36) NOT NULL, `duplicate_of` varchar(36) NULL, `items_key_id` varchar(255) NULL, `content` mediumtext NULL, `content_type` varchar(255) NULL, `enc_item_key` text NULL, `auth_hash` varchar(255) NULL, `user_uuid` varchar(36) NULL, `deleted` tinyint(1) NULL DEFAULT 0, `last_user_agent` text NULL, `created_at` datetime(6) NOT NULL, `updated_at` datetime(6) NOT NULL, `created_at_timestamp` BIGINT NOT NULL, `updated_at_timestamp` BIGINT NOT NULL, INDEX `index_items_on_content_type` (`content_type`), INDEX `index_items_on_user_uuid` (`user_uuid`), INDEX `index_items_on_deleted` (`deleted`), INDEX `updated_at_timestamp` (`updated_at_timestamp`), INDEX `index_items_on_updated_at` (`updated_at`), INDEX `user_uuid_and_updated_at_timestamp_and_created_at_timestamp` (`user_uuid`, `updated_at_timestamp`, `created_at_timestamp`), INDEX `index_items_on_user_uuid_and_updated_at_and_created_at` (`user_uuid`, `updated_at`, `created_at`), INDEX `index_items_on_user_uuid_and_content_type` (`user_uuid`, `content_type`), PRIMARY KEY (`uuid`)) ENGINE=InnoDB', 11 | ) 12 | await queryRunner.query( 13 | 'CREATE TABLE IF NOT EXISTS `revisions` (`uuid` varchar(36) NOT NULL, `item_uuid` varchar(36) NULL, `content` mediumtext NULL, `content_type` varchar(255) NULL, `items_key_id` varchar(255) NULL, `enc_item_key` text NULL, `auth_hash` varchar(255) NULL, `creation_date` date NULL, `created_at` datetime(6) NULL, `updated_at` datetime(6) NULL, INDEX `index_revisions_on_item_uuid` (`item_uuid`), INDEX `index_revisions_on_creation_date` (`creation_date`), INDEX `index_revisions_on_created_at` (`created_at`), PRIMARY KEY (`uuid`)) ENGINE=InnoDB', 14 | ) 15 | await queryRunner.query( 16 | 'CREATE TABLE IF NOT EXISTS `item_revisions` (`uuid` varchar(36) NOT NULL, `item_uuid` varchar(36) NOT NULL, `revision_uuid` varchar(36) NOT NULL, INDEX `index_item_revisions_on_item_uuid` (`item_uuid`), INDEX `index_item_revisions_on_revision_uuid` (`revision_uuid`), PRIMARY KEY (`uuid`)) ENGINE=InnoDB', 17 | ) 18 | } 19 | 20 | public async down(_queryRunner: QueryRunner): Promise { 21 | return 22 | } 23 | 24 | private async fixUpdatedAtTimestampsFromLegacyMigration(queryRunner: QueryRunner): Promise { 25 | const itemsTableExistsQueryResult = await queryRunner.manager.query( 26 | 'SELECT COUNT(*) as count FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = "items"', 27 | ) 28 | const itemsTableExists = itemsTableExistsQueryResult[0].count === 1 29 | if (!itemsTableExists) { 30 | return 31 | } 32 | 33 | const updatedAtTimestampColumnExistsQueryResult = await queryRunner.manager.query( 34 | 'SELECT COUNT(*) as count FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = "items" AND column_name = "updated_at_timestamp"', 35 | ) 36 | const updatedAtTimestampColumnExists = updatedAtTimestampColumnExistsQueryResult[0].count === 1 37 | if (updatedAtTimestampColumnExists) { 38 | return 39 | } 40 | 41 | await queryRunner.query('ALTER TABLE `items` ADD COLUMN `updated_at_timestamp` BIGINT NOT NULL') 42 | await queryRunner.query('ALTER TABLE `items` ADD COLUMN `created_at_timestamp` BIGINT NOT NULL') 43 | await queryRunner.query( 44 | 'ALTER TABLE `items` ADD INDEX `user_uuid_and_updated_at_timestamp_and_created_at_timestamp` (`user_uuid`, `updated_at_timestamp`, `created_at_timestamp`)', 45 | ) 46 | await queryRunner.query('ALTER TABLE `items` ADD INDEX `updated_at_timestamp` (`updated_at_timestamp`)') 47 | await queryRunner.query('UPDATE `items` SET `created_at_timestamp` = UNIX_TIMESTAMP(`created_at`) * 1000000') 48 | await queryRunner.query('UPDATE `items` SET `updated_at_timestamp` = UNIX_TIMESTAMP(`updated_at`) * 1000000') 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /migrations/1617615657558-add_extension_settings.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner } from 'typeorm' 2 | 3 | export class addExtensionSettings1617615657558 implements MigrationInterface { 4 | name = 'addExtensionSettings1617615657558' 5 | 6 | public async up(queryRunner: QueryRunner): Promise { 7 | await queryRunner.query( 8 | 'CREATE TABLE IF NOT EXISTS `extension_settings` (`uuid` varchar(36) NOT NULL, `extension_id` varchar(255) NULL, `mute_emails` tinyint(1) NULL DEFAULT 0, `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX `index_extension_settings_on_extension_id` (`extension_id`), PRIMARY KEY (`uuid`)) ENGINE=InnoDB', 9 | ) 10 | } 11 | 12 | public async down(_queryRunner: QueryRunner): Promise { 13 | return 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /migrations/1629964808297-drop_unused_indexes.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner } from 'typeorm' 2 | 3 | export class dropUnusedIndexes1629964808297 implements MigrationInterface { 4 | name = 'dropUnusedIndexes1629964808297' 5 | 6 | public async up(queryRunner: QueryRunner): Promise { 7 | const indexItemsOnUserAndTimestamp = await queryRunner.manager.query( 8 | 'SHOW INDEX FROM `items` where `key_name` = "index_items_on_user_uuid_and_updated_at_and_created_at"', 9 | ) 10 | const indexItemsOnUserAndTimestampExists = indexItemsOnUserAndTimestamp && indexItemsOnUserAndTimestamp.length > 0 11 | if (indexItemsOnUserAndTimestampExists) { 12 | await queryRunner.query('ALTER TABLE `items` DROP INDEX index_items_on_user_uuid_and_updated_at_and_created_at') 13 | } 14 | 15 | const indexItemsOnUpdatedAt = await queryRunner.manager.query( 16 | 'SHOW INDEX FROM `items` where `key_name` = "index_items_on_updated_at"', 17 | ) 18 | const indexItemsOnUpdatedAtExists = indexItemsOnUpdatedAt && indexItemsOnUpdatedAt.length > 0 19 | if (indexItemsOnUpdatedAtExists) { 20 | await queryRunner.query('ALTER TABLE `items` DROP INDEX index_items_on_updated_at') 21 | } 22 | } 23 | 24 | public async down(): Promise { 25 | return 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /migrations/1630318893601-refactor_calculating_integrity_hash.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner } from 'typeorm' 2 | 3 | export class refactorCalculatingIntegrityHash1630318893601 implements MigrationInterface { 4 | public async up(queryRunner: QueryRunner): Promise { 5 | await queryRunner.query('ALTER TABLE `items` ADD INDEX `user_uuid_and_deleted` (`user_uuid`, `deleted`)') 6 | } 7 | 8 | public async down(): Promise { 9 | return 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /migrations/1630417724617-restrict_content_type.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner } from 'typeorm' 2 | 3 | export class restrictContentType1630417724617 implements MigrationInterface { 4 | public async up(queryRunner: QueryRunner): Promise { 5 | await queryRunner.query('UPDATE `items` SET content_type = "Unknown" WHERE `content_type` IS NULL') 6 | await queryRunner.query('ALTER TABLE `items` CHANGE `content_type` `content_type` varchar(255) NOT NULL') 7 | } 8 | 9 | public async down(): Promise { 10 | return 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /migrations/1631529502150-add_revision_for_duplicated_items.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner } from 'typeorm' 2 | 3 | import { v4 } from 'uuid' 4 | 5 | export class addRevisionForDuplicatedItems1631529502150 implements MigrationInterface { 6 | public async up(queryRunner: QueryRunner): Promise { 7 | const itemRevisions = await queryRunner.manager.query( 8 | 'SELECT r.uuid as originalRevisionUuid, ir.item_uuid as properItemUuid, ir.uuid as relationUuid FROM revisions r INNER JOIN item_revisions ir ON ir.revision_uuid = r.uuid AND ir.item_uuid <> r.item_uuid', 9 | ) 10 | 11 | for (const itemRevision of itemRevisions) { 12 | const revisionUuid = v4() 13 | 14 | await queryRunner.manager.query( 15 | `INSERT INTO revisions (uuid, item_uuid, content, content_type, items_key_id, enc_item_key, auth_hash, creation_date, created_at, updated_at) SELECT "${revisionUuid}", "${itemRevision['properItemUuid']}", content, content_type, items_key_id, enc_item_key, auth_hash, creation_date, created_at, updated_at FROM revisions WHERE uuid = "${itemRevision['originalRevisionUuid']}"`, 16 | ) 17 | await queryRunner.manager.query( 18 | `UPDATE item_revisions SET revision_uuid = "${revisionUuid}" WHERE uuid = "${itemRevision['relationUuid']}"`, 19 | ) 20 | } 21 | } 22 | 23 | public async down(): Promise { 24 | return 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /migrations/1631530260504-drop_item_revisions_joining_table.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner } from 'typeorm' 2 | 3 | export class dropItemRevisionsJoiningTable1631530260504 implements MigrationInterface { 4 | public async up(queryRunner: QueryRunner): Promise { 5 | await queryRunner.query('DROP TABLE `item_revisions`') 6 | } 7 | 8 | public async down(queryRunner: QueryRunner): Promise { 9 | await queryRunner.query( 10 | 'CREATE TABLE `item_revisions` (`uuid` varchar(36) NOT NULL, `item_uuid` varchar(36) NOT NULL, `revision_uuid` varchar(36) NOT NULL, INDEX `index_item_revisions_on_item_uuid` (`item_uuid`), INDEX `index_item_revisions_on_revision_uuid` (`revision_uuid`), PRIMARY KEY (`uuid`)) ENGINE=InnoDB', 11 | ) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /migrations/1632219307742-cleanup_orphan_items_and_revisions.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner } from 'typeorm' 2 | 3 | export class cleanupOrphanItemsAndRevisions1632219307742 implements MigrationInterface { 4 | public async up(queryRunner: QueryRunner): Promise { 5 | const usersTableExistsQueryResult = await queryRunner.manager.query( 6 | 'SELECT COUNT(*) as count FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = "users"', 7 | ) 8 | const usersTableExists = usersTableExistsQueryResult[0].count === 1 9 | if (usersTableExists) { 10 | const orphanedItems = await queryRunner.manager.query( 11 | 'SELECT i.uuid as uuid FROM items i LEFT JOIN users u ON i.user_uuid = u.uuid WHERE u.uuid IS NULL', 12 | ) 13 | 14 | for (const orphanedItem of orphanedItems) { 15 | await queryRunner.manager.query(`DELETE FROM revisions WHERE item_uuid = "${orphanedItem['uuid']}"`) 16 | await queryRunner.manager.query(`DELETE FROM items WHERE uuid = "${orphanedItem['uuid']}"`) 17 | } 18 | } 19 | 20 | await queryRunner.manager.query('DELETE FROM items WHERE user_uuid IS NULL') 21 | 22 | const orphanedRevisions = await queryRunner.manager.query( 23 | 'SELECT r.uuid as uuid FROM revisions r LEFT JOIN items i ON r.item_uuid = i.uuid WHERE i.uuid IS NULL', 24 | ) 25 | 26 | for (const orphanedRevision of orphanedRevisions) { 27 | await queryRunner.manager.query(`DELETE FROM revisions WHERE uuid = "${orphanedRevision['uuid']}"`) 28 | } 29 | 30 | await queryRunner.manager.query('DELETE FROM revisions WHERE item_uuid IS NULL') 31 | } 32 | 33 | public async down(): Promise { 34 | return 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /migrations/1632221263106-add_revisions_items_relation.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner } from 'typeorm' 2 | 3 | export class addRevisionsItemsRelation1632221263106 implements MigrationInterface { 4 | name = 'addRevisionsItemsRelation1632221263106' 5 | 6 | public async up(queryRunner: QueryRunner): Promise { 7 | const indexRevisionsOnItemUuid = await queryRunner.manager.query( 8 | 'SHOW INDEX FROM `revisions` where `key_name` = "index_revisions_on_item_uuid"', 9 | ) 10 | const indexRevisionsOnItemUuidExists = indexRevisionsOnItemUuid && indexRevisionsOnItemUuid.length > 0 11 | if (indexRevisionsOnItemUuidExists) { 12 | await queryRunner.query('DROP INDEX `index_revisions_on_item_uuid` ON `revisions`') 13 | } 14 | 15 | await queryRunner.query('ALTER TABLE `revisions` CHANGE `item_uuid` `item_uuid` varchar(36) NOT NULL') 16 | await queryRunner.query('ALTER TABLE `items` CHANGE `user_uuid` `user_uuid` varchar(36) NOT NULL') 17 | await queryRunner.query( 18 | 'ALTER TABLE `revisions` ADD CONSTRAINT `FK_ab3b92e54701fe3010022a31d90` FOREIGN KEY (`item_uuid`) REFERENCES `items`(`uuid`) ON DELETE CASCADE ON UPDATE NO ACTION', 19 | ) 20 | } 21 | 22 | public async down(queryRunner: QueryRunner): Promise { 23 | await queryRunner.query('ALTER TABLE `revisions` DROP FOREIGN KEY `FK_ab3b92e54701fe3010022a31d90`') 24 | await queryRunner.query('ALTER TABLE `items` CHANGE `user_uuid` `user_uuid` varchar(36) NULL') 25 | await queryRunner.query('ALTER TABLE `revisions` CHANGE `item_uuid` `item_uuid` varchar(36) NULL') 26 | await queryRunner.query('CREATE INDEX `index_revisions_on_item_uuid` ON `revisions` (`item_uuid`)') 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /migrations/1637738491169-add_item_content_size.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner } from 'typeorm' 2 | 3 | export class addItemContentSize1637738491169 implements MigrationInterface { 4 | name = 'addItemContentSize1637738491169' 5 | 6 | public async up(queryRunner: QueryRunner): Promise { 7 | await queryRunner.query('ALTER TABLE `items` ADD `content_size` INT UNSIGNED NULL') 8 | } 9 | 10 | public async down(queryRunner: QueryRunner): Promise { 11 | await queryRunner.query('ALTER TABLE `items` DROP COLUMN `content_size`') 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /migrations/1639134926025-remove_extension_settings.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner } from 'typeorm' 2 | 3 | export class removeExtensionSettings1639134926025 implements MigrationInterface { 4 | public async up(queryRunner: QueryRunner): Promise { 5 | await queryRunner.query('DROP TABLE `extension_settings`') 6 | } 7 | 8 | public async down(): Promise { 9 | return 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /migrations/1642073387521-remove_sf_extension_items.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner } from 'typeorm' 2 | 3 | export class removeSfExtensionItems1642073387521 implements MigrationInterface { 4 | public async up(queryRunner: QueryRunner): Promise { 5 | await queryRunner.manager.query('DELETE FROM items WHERE content_type = "SF|Extension"') 6 | } 7 | 8 | public async down(): Promise { 9 | return 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /migrations/1647501696205-remove_user_agent.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner } from 'typeorm' 2 | 3 | export class removeUserAgent1647501696205 implements MigrationInterface { 4 | public async up(queryRunner: QueryRunner): Promise { 5 | await queryRunner.query('ALTER TABLE `items` DROP COLUMN `last_user_agent`') 6 | } 7 | 8 | public async down(): Promise { 9 | return 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /migrations/1654518291191-add_updated_with_session.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner } from 'typeorm' 2 | 3 | export class addUpdatedWithSession1654518291191 implements MigrationInterface { 4 | name = 'addUpdatedWithSession1654518291191' 5 | 6 | public async up(queryRunner: QueryRunner): Promise { 7 | await queryRunner.query('ALTER TABLE `items` ADD `updated_with_session` varchar(36) NULL') 8 | } 9 | 10 | public async down(queryRunner: QueryRunner): Promise { 11 | await queryRunner.query('ALTER TABLE `items` DROP COLUMN `updated_with_session`') 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["src", "bin"], 3 | "ext": "ts, json", 4 | "exec": "npx ts-node" 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "syncing-server-js", 3 | "version": "1.0.0", 4 | "engines": { 5 | "node": ">=15.0.0 <17.0.0" 6 | }, 7 | "description": "Syncing Server JS", 8 | "main": "dist/src/index.js", 9 | "typings": "dist/src/index.d.ts", 10 | "repository": "git@github.com:standardnotes/syncing-server-js.git", 11 | "author": "Karol Sójko ", 12 | "license": "AGPL-3.0-or-later", 13 | "scripts": { 14 | "clean": "rm -fr dist", 15 | "prebuild": "yarn clean", 16 | "build": "tsc --rootDir ./", 17 | "lint": "eslint . --ext .ts", 18 | "pretest": "yarn lint && yarn build", 19 | "test": "jest --coverage", 20 | "start": "node dist/bin/server.js", 21 | "start:local": "npx nodemon -L bin/server.ts", 22 | "worker": "node dist/bin/worker.js" 23 | }, 24 | "dependencies": { 25 | "@newrelic/native-metrics": "7.0.2", 26 | "@newrelic/winston-enricher": "^2.1.0", 27 | "@sentry/node": "^6.16.1", 28 | "@standardnotes/analytics": "^1.6.0", 29 | "@standardnotes/auth": "^3.19.2", 30 | "@standardnotes/common": "^1.22.0", 31 | "@standardnotes/domain-events": "^2.29.0", 32 | "@standardnotes/domain-events-infra": "1.4.127", 33 | "@standardnotes/payloads": "^1.5.1", 34 | "@standardnotes/responses": "^1.6.15", 35 | "@standardnotes/settings": "1.14.3", 36 | "@standardnotes/time": "1.6.9", 37 | "axios": "0.24.0", 38 | "cors": "2.8.5", 39 | "dotenv": "8.2.0", 40 | "express": "4.17.1", 41 | "helmet": "4.3.1", 42 | "inversify": "5.0.5", 43 | "inversify-express-utils": "6.3.2", 44 | "ioredis": "4.19.4", 45 | "jsonwebtoken": "8.5.1", 46 | "mysql2": "^2.3.3", 47 | "newrelic": "8.6.0", 48 | "nodemon": "2.0.7", 49 | "prettyjson": "1.2.1", 50 | "reflect-metadata": "0.1.13", 51 | "typeorm": "^0.3.6", 52 | "ua-parser-js": "1.0.2", 53 | "uuid": "8.3.2", 54 | "winston": "3.3.3" 55 | }, 56 | "devDependencies": { 57 | "@standardnotes/config": "^2.4.3", 58 | "@types/cors": "^2.8.9", 59 | "@types/dotenv": "^8.2.0", 60 | "@types/express": "^4.17.9", 61 | "@types/inversify-express-utils": "^2.0.0", 62 | "@types/ioredis": "^4.19.3", 63 | "@types/jest": "^27.5.0", 64 | "@types/jsonwebtoken": "^8.5.0", 65 | "@types/newrelic": "^7.0.2", 66 | "@types/prettyjson": "^0.0.29", 67 | "@types/ua-parser-js": "^0.7.36", 68 | "@types/uuid": "^8.3.0", 69 | "eslint": "^8.14.0", 70 | "jest": "^28.0.3", 71 | "ts-jest": "^28.0.1", 72 | "ts-node": "^10.7.0", 73 | "typescript": "^4.3.5" 74 | }, 75 | "jest": { 76 | "preset": "./node_modules/@standardnotes/config/src/jest.json", 77 | "coveragePathIgnorePatterns": [ 78 | "/node_modules/", 79 | "/Bootstrap/", 80 | "HealthCheckController", 81 | "/InMemory/" 82 | ], 83 | "setupFilesAfterEnv": [ 84 | "/test-setup.ts" 85 | ] 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /server.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | DOCKER_VERSION=`docker --version` 5 | 6 | if [ "$?" -ne "0" ]; then 7 | echo "Please install Docker before proceeding." 8 | exit 1 9 | fi 10 | 11 | checkConfigFiles() { 12 | if [ ! -f ".env" ]; then echo "Could not find syncing-server environment file. Please run the './server.sh setup' command and try again." && exit 1; fi 13 | if [ ! -f "docker/api-gateway.env" ]; then echo "Could not find api-gateway environment file. Please run the './server.sh setup' command and try again." && exit 1; fi 14 | if [ ! -f "docker/auth.env" ]; then echo "Could not find auth environment file. Please run the './server.sh setup' command and try again." && exit 1; fi 15 | } 16 | 17 | checkForConfigFileChanges() { 18 | checkConfigFiles 19 | compareLineCount 20 | } 21 | 22 | compareLineCount() { 23 | MAIN_ENV_FILE_SAMPLE_LINES=$(wc -l .env.sample | awk '{ print $1 }') 24 | MAIN_ENV_FILE_LINES=$(wc -l .env | awk '{ print $1 }') 25 | if [ "$MAIN_ENV_FILE_SAMPLE_LINES" -ne "$MAIN_ENV_FILE_LINES" ]; then echo "The .env file contains different amount of lines than .env.sample. This may be caused by the fact that there is a new environment variable to configure. Please update your environment file and try again." && exit 1; fi 26 | 27 | API_GATEWAY_ENV_FILE_SAMPLE_LINES=$(wc -l docker/api-gateway.env.sample | awk '{ print $1 }') 28 | API_GATEWAY_ENV_FILE_LINES=$(wc -l docker/api-gateway.env | awk '{ print $1 }') 29 | if [ "$API_GATEWAY_ENV_FILE_SAMPLE_LINES" -ne "$API_GATEWAY_ENV_FILE_LINES" ]; then echo "The docker/api-gateway.env file contains different amount of lines than docker/api-gateway.env.sample. This may be caused by the fact that there is a new environment variable to configure. Please update your environment file and try again." && exit 1; fi 30 | 31 | AUTH_ENV_FILE_SAMPLE_LINES=$(wc -l docker/auth.env.sample | awk '{ print $1 }') 32 | AUTH_ENV_FILE_LINES=$(wc -l docker/auth.env | awk '{ print $1 }') 33 | if [ "$AUTH_ENV_FILE_SAMPLE_LINES" -ne "$AUTH_ENV_FILE_LINES" ]; then echo "The docker/auth.env file contains different amount of lines than docker/auth.env.sample. This may be caused by the fact that there is a new environment variable to configure. Please update your environment file and try again." && exit 1; fi 34 | } 35 | 36 | COMMAND=$1 && shift 1 37 | 38 | case "$COMMAND" in 39 | 'setup' ) 40 | echo "Initializing default configuration" 41 | if [ ! -f ".env" ]; then cp .env.sample .env; fi 42 | if [ ! -f "docker/api-gateway.env" ]; then cp docker/api-gateway.env.sample docker/api-gateway.env; fi 43 | if [ ! -f "docker/auth.env" ]; then cp docker/auth.env.sample docker/auth.env; fi 44 | echo "Default configuration files created as .env and docker/*.env files. Feel free to modify values if needed." 45 | ;; 46 | 'start' ) 47 | checkForConfigFileChanges 48 | echo "Starting up infrastructure" 49 | docker-compose up -d 50 | echo "Infrastructure started. Give it a moment to warm up. If you wish please run the './server.sh logs' command to see details." 51 | ;; 52 | 'status' ) 53 | echo "Services State:" 54 | docker-compose ps 55 | ;; 56 | 'logs' ) 57 | docker-compose logs -f 58 | ;; 59 | 'update' ) 60 | echo "Stopping all services." 61 | docker-compose kill 62 | echo "Pulling changes from Git." 63 | git pull origin $(git rev-parse --abbrev-ref HEAD) 64 | echo "Checking for env file changes" 65 | checkForConfigFileChanges 66 | echo "Downloading latest images of Standard Notes services." 67 | docker-compose pull 68 | echo "Building latest image of Syncing Server." 69 | docker-compose build 70 | echo "Images up to date. Starting all services." 71 | docker-compose up -d 72 | echo "Infrastructure started. Give it a moment to warm up. If you wish please run the './server.sh logs' command to see details." 73 | ;; 74 | 'stop' ) 75 | echo "Stopping all service" 76 | docker-compose kill 77 | echo "Services stopped" 78 | ;; 79 | 'cleanup' ) 80 | echo "WARNING: This will permanently delete all of you data! Are you sure?" 81 | read -p "Continue (y/n)?" choice 82 | case "$choice" in 83 | y|Y ) 84 | docker-compose kill && docker-compose rm -fv 85 | rm -rf data/* 86 | echo "Cleanup performed. You can start your server with a clean environment." 87 | ;; 88 | n|N ) 89 | echo "Cleanup aborted" 90 | exit 0 91 | ;; 92 | * ) 93 | echo "Invalid option supplied. Aborted cleanup." 94 | ;; 95 | esac 96 | ;; 97 | * ) 98 | echo "Unknown command" 99 | ;; 100 | esac 101 | 102 | exec "$@" 103 | -------------------------------------------------------------------------------- /src/Bootstrap/DataSource.ts: -------------------------------------------------------------------------------- 1 | import { DataSource, LoggerOptions } from 'typeorm' 2 | import { Item } from '../Domain/Item/Item' 3 | import { Revision } from '../Domain/Revision/Revision' 4 | import { Env } from './Env' 5 | 6 | const env: Env = new Env() 7 | env.load() 8 | 9 | const maxQueryExecutionTime = env.get('DB_MAX_QUERY_EXECUTION_TIME', true) 10 | ? +env.get('DB_MAX_QUERY_EXECUTION_TIME', true) 11 | : 45_000 12 | 13 | export const AppDataSource = new DataSource({ 14 | type: 'mysql', 15 | supportBigNumbers: true, 16 | bigNumberStrings: false, 17 | maxQueryExecutionTime, 18 | replication: { 19 | master: { 20 | host: env.get('DB_HOST'), 21 | port: parseInt(env.get('DB_PORT')), 22 | username: env.get('DB_USERNAME'), 23 | password: env.get('DB_PASSWORD'), 24 | database: env.get('DB_DATABASE'), 25 | }, 26 | slaves: [ 27 | { 28 | host: env.get('DB_REPLICA_HOST'), 29 | port: parseInt(env.get('DB_PORT')), 30 | username: env.get('DB_USERNAME'), 31 | password: env.get('DB_PASSWORD'), 32 | database: env.get('DB_DATABASE'), 33 | }, 34 | ], 35 | removeNodeErrorCount: 10, 36 | restoreNodeTimeout: 5, 37 | }, 38 | entities: [Item, Revision], 39 | migrations: [env.get('DB_MIGRATIONS_PATH')], 40 | migrationsRun: true, 41 | logging: env.get('DB_DEBUG_LEVEL'), 42 | }) 43 | -------------------------------------------------------------------------------- /src/Bootstrap/Env.ts: -------------------------------------------------------------------------------- 1 | import { config, DotenvParseOutput } from 'dotenv' 2 | import { injectable } from 'inversify' 3 | 4 | @injectable() 5 | export class Env { 6 | private env?: DotenvParseOutput 7 | 8 | public load(): void { 9 | const output = config() 10 | this.env = output.parsed 11 | } 12 | 13 | public get(key: string, optional = false): string { 14 | if (!this.env) { 15 | this.load() 16 | } 17 | 18 | if (!process.env[key] && !optional) { 19 | throw new Error(`Environment variable ${key} not set`) 20 | } 21 | 22 | return process.env[key] 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Bootstrap/Types.ts: -------------------------------------------------------------------------------- 1 | const TYPES = { 2 | DBConnection: Symbol.for('DBConnection'), 3 | Logger: Symbol.for('Logger'), 4 | Redis: Symbol.for('Redis'), 5 | SNS: Symbol.for('SNS'), 6 | SQS: Symbol.for('SQS'), 7 | S3: Symbol.for('S3'), 8 | // Repositories 9 | RevisionRepository: Symbol.for('RevisionRepository'), 10 | ItemRepository: Symbol.for('ItemRepository'), 11 | // ORM 12 | ORMRevisionRepository: Symbol.for('ORMRevisionRepository'), 13 | ORMItemRepository: Symbol.for('ORMItemRepository'), 14 | // Middleware 15 | AuthMiddleware: Symbol.for('AuthMiddleware'), 16 | // Projectors 17 | RevisionProjector: Symbol.for('RevisionProjector'), 18 | ItemProjector: Symbol.for('ItemProjector'), 19 | SavedItemProjector: Symbol.for('SavedItemProjector'), 20 | ItemConflictProjector: Symbol.for('ItemConflictProjector'), 21 | // env vars 22 | REDIS_URL: Symbol.for('REDIS_URL'), 23 | SNS_TOPIC_ARN: Symbol.for('SNS_TOPIC_ARN'), 24 | SNS_AWS_REGION: Symbol.for('SNS_AWS_REGION'), 25 | SQS_QUEUE_URL: Symbol.for('SQS_QUEUE_URL'), 26 | SQS_AWS_REGION: Symbol.for('SQS_AWS_REGION'), 27 | REDIS_EVENTS_CHANNEL: Symbol.for('REDIS_EVENTS_CHANNEL'), 28 | AUTH_JWT_SECRET: Symbol.for('AUTH_JWT_SECRET'), 29 | INTERNAL_DNS_REROUTE_ENABLED: Symbol.for('INTERNAL_DNS_REROUTE_ENABLED'), 30 | EXTENSIONS_SERVER_URL: Symbol.for('EXTENSIONS_SERVER_URL'), 31 | AUTH_SERVER_URL: Symbol.for('AUTH_SERVER_URL'), 32 | S3_AWS_REGION: Symbol.for('S3_AWS_REGION'), 33 | S3_BACKUP_BUCKET_NAME: Symbol.for('S3_BACKUP_BUCKET_NAME'), 34 | EMAIL_ATTACHMENT_MAX_BYTE_SIZE: Symbol.for('EMAIL_ATTACHMENT_MAX_BYTE_SIZE'), 35 | REVISIONS_FREQUENCY: Symbol.for('REVISIONS_FREQUENCY'), 36 | NEW_RELIC_ENABLED: Symbol.for('NEW_RELIC_ENABLED'), 37 | VERSION: Symbol.for('VERSION'), 38 | CONTENT_SIZE_TRANSFER_LIMIT: Symbol.for('CONTENT_SIZE_TRANSFER_LIMIT'), 39 | // use cases 40 | SyncItems: Symbol.for('SyncItems'), 41 | CheckIntegrity: Symbol.for('CheckIntegrity'), 42 | GetItem: Symbol.for('GetItem'), 43 | // Handlers 44 | AccountDeletionRequestedEventHandler: Symbol.for('AccountDeletionRequestedEventHandler'), 45 | DuplicateItemSyncedEventHandler: Symbol.for('DuplicateItemSyncedEventHandler'), 46 | ItemsSyncedEventHandler: Symbol.for('ItemsSyncedEventHandler'), 47 | EmailArchiveExtensionSyncedEventHandler: Symbol.for('EmailArchiveExtensionSyncedEventHandler'), 48 | EmailBackupRequestedEventHandler: Symbol.for('EmailBackupRequestedEventHandler'), 49 | CloudBackupRequestedEventHandler: Symbol.for('CloudBackupRequestedEventHandler'), 50 | // Services 51 | ContentDecoder: Symbol.for('ContentDecoder'), 52 | DomainEventPublisher: Symbol.for('DomainEventPublisher'), 53 | DomainEventSubscriberFactory: Symbol.for('DomainEventSubscriberFactory'), 54 | DomainEventFactory: Symbol.for('DomainEventFactory'), 55 | DomainEventMessageHandler: Symbol.for('DomainEventMessageHandler'), 56 | HTTPClient: Symbol.for('HTTPClient'), 57 | ItemService: Symbol.for('ItemService'), 58 | Timer: Symbol.for('Timer'), 59 | SyncResponseFactory20161215: Symbol.for('SyncResponseFactory20161215'), 60 | SyncResponseFactory20200115: Symbol.for('SyncResponseFactory20200115'), 61 | SyncResponseFactoryResolver: Symbol.for('SyncResponseFactoryResolver'), 62 | AuthHttpService: Symbol.for('AuthHttpService'), 63 | ExtensionsHttpService: Symbol.for('ExtensionsHttpService'), 64 | ItemBackupService: Symbol.for('ItemBackupService'), 65 | RevisionService: Symbol.for('RevisionService'), 66 | ItemSaveValidator: Symbol.for('ItemSaveValidator'), 67 | OwnershipFilter: Symbol.for('OwnershipFilter'), 68 | TimeDifferenceFilter: Symbol.for('TimeDifferenceFilter'), 69 | UuidFilter: Symbol.for('UuidFilter'), 70 | ContentTypeFilter: Symbol.for('ContentTypeFilter'), 71 | ContentFilter: Symbol.for('ContentFilter'), 72 | ItemFactory: Symbol.for('ItemFactory'), 73 | AnalyticsStore: Symbol.for('AnalyticsStore'), 74 | StatisticsStore: Symbol.for('StatisticsStore'), 75 | ItemTransferCalculator: Symbol.for('ItemTransferCalculator'), 76 | } 77 | 78 | export default TYPES 79 | -------------------------------------------------------------------------------- /src/Controller/AuthMiddleware.spec.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | 3 | import * as winston from 'winston' 4 | 5 | import { AuthMiddleware } from './AuthMiddleware' 6 | import { NextFunction, Request, Response } from 'express' 7 | import { sign } from 'jsonwebtoken' 8 | import { RoleName } from '@standardnotes/common' 9 | 10 | describe('AuthMiddleware', () => { 11 | let logger: winston.Logger 12 | const jwtSecret = 'auth_jwt_secret' 13 | let request: Request 14 | let response: Response 15 | let next: NextFunction 16 | 17 | const createMiddleware = () => new AuthMiddleware(jwtSecret, logger) 18 | 19 | beforeEach(() => { 20 | logger = {} as jest.Mocked 21 | logger.info = jest.fn() 22 | logger.debug = jest.fn() 23 | logger.warn = jest.fn() 24 | logger.error = jest.fn() 25 | 26 | request = { 27 | headers: {}, 28 | } as jest.Mocked 29 | request.header = jest.fn() 30 | response = { 31 | locals: {}, 32 | } as jest.Mocked 33 | response.status = jest.fn().mockReturnThis() 34 | response.send = jest.fn() 35 | next = jest.fn() 36 | }) 37 | 38 | it('should authorize user from an auth JWT token if present', async () => { 39 | const authToken = sign( 40 | { 41 | user: { uuid: '123' }, 42 | session: { uuid: '234' }, 43 | roles: [ 44 | { 45 | uuid: '1-2-3', 46 | name: RoleName.CoreUser, 47 | }, 48 | { 49 | uuid: '2-3-4', 50 | name: RoleName.ProUser, 51 | }, 52 | ], 53 | analyticsId: 123, 54 | permissions: [], 55 | }, 56 | jwtSecret, 57 | { algorithm: 'HS256' }, 58 | ) 59 | 60 | request.header = jest.fn().mockReturnValue(authToken) 61 | 62 | await createMiddleware().handler(request, response, next) 63 | 64 | expect(response.locals.user).toEqual({ uuid: '123' }) 65 | expect(response.locals.roleNames).toEqual(['CORE_USER', 'PRO_USER']) 66 | expect(response.locals.session).toEqual({ uuid: '234' }) 67 | expect(response.locals.readOnlyAccess).toBeFalsy() 68 | expect(response.locals.analyticsId).toEqual(123) 69 | 70 | expect(next).toHaveBeenCalled() 71 | }) 72 | 73 | it('should authorize user from an auth JWT token if present with read only access', async () => { 74 | const authToken = sign( 75 | { 76 | user: { uuid: '123' }, 77 | session: { 78 | uuid: '234', 79 | readonly_access: true, 80 | }, 81 | roles: [ 82 | { 83 | uuid: '1-2-3', 84 | name: RoleName.CoreUser, 85 | }, 86 | { 87 | uuid: '2-3-4', 88 | name: RoleName.ProUser, 89 | }, 90 | ], 91 | analyticsId: 123, 92 | permissions: [], 93 | }, 94 | jwtSecret, 95 | { algorithm: 'HS256' }, 96 | ) 97 | 98 | request.header = jest.fn().mockReturnValue(authToken) 99 | 100 | await createMiddleware().handler(request, response, next) 101 | 102 | expect(response.locals.user).toEqual({ uuid: '123' }) 103 | expect(response.locals.roleNames).toEqual(['CORE_USER', 'PRO_USER']) 104 | expect(response.locals.session).toEqual({ uuid: '234', readonly_access: true }) 105 | expect(response.locals.readOnlyAccess).toBeTruthy() 106 | expect(response.locals.analyticsId).toEqual(123) 107 | 108 | expect(next).toHaveBeenCalled() 109 | }) 110 | 111 | it('should not authorize user from an auth JWT token if it is invalid', async () => { 112 | const authToken = sign( 113 | { 114 | user: { uuid: '123' }, 115 | session: { uuid: '234' }, 116 | roles: [], 117 | permissions: [], 118 | }, 119 | jwtSecret, 120 | { algorithm: 'HS256', notBefore: '2 days' }, 121 | ) 122 | 123 | request.header = jest.fn().mockReturnValue(authToken) 124 | 125 | await createMiddleware().handler(request, response, next) 126 | 127 | expect(response.status).toHaveBeenCalledWith(401) 128 | expect(next).not.toHaveBeenCalled() 129 | }) 130 | 131 | it('should not authorize if authorization header is missing', async () => { 132 | await createMiddleware().handler(request, response, next) 133 | 134 | expect(response.status).toHaveBeenCalledWith(401) 135 | expect(next).not.toHaveBeenCalled() 136 | }) 137 | }) 138 | -------------------------------------------------------------------------------- /src/Controller/AuthMiddleware.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from 'express' 2 | import { inject, injectable } from 'inversify' 3 | import { BaseMiddleware } from 'inversify-express-utils' 4 | import { verify } from 'jsonwebtoken' 5 | import { CrossServiceTokenData } from '@standardnotes/auth' 6 | import * as winston from 'winston' 7 | import TYPES from '../Bootstrap/Types' 8 | 9 | @injectable() 10 | export class AuthMiddleware extends BaseMiddleware { 11 | constructor( 12 | @inject(TYPES.AUTH_JWT_SECRET) private authJWTSecret: string, 13 | @inject(TYPES.Logger) private logger: winston.Logger, 14 | ) { 15 | super() 16 | } 17 | 18 | async handler(request: Request, response: Response, next: NextFunction): Promise { 19 | try { 20 | if (!request.header('X-Auth-Token')) { 21 | return this.sendInvalidAuthResponse(response) 22 | } 23 | 24 | const authToken = request.header('X-Auth-Token') 25 | 26 | const decodedToken = verify(authToken, this.authJWTSecret, { algorithms: ['HS256'] }) 27 | 28 | response.locals.user = decodedToken.user 29 | response.locals.roleNames = decodedToken.roles.map((role) => role.name) 30 | response.locals.session = decodedToken.session 31 | response.locals.readOnlyAccess = decodedToken.session?.readonly_access ?? false 32 | response.locals.analyticsId = decodedToken.analyticsId 33 | 34 | return next() 35 | } catch (error) { 36 | this.logger.error(`Could not verify JWT Auth Token ${(error as Error).message}`) 37 | 38 | return this.sendInvalidAuthResponse(response) 39 | } 40 | } 41 | 42 | private sendInvalidAuthResponse(response: Response) { 43 | response.status(401).send({ 44 | error: { 45 | tag: 'invalid-auth', 46 | message: 'Invalid login credentials.', 47 | }, 48 | }) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Controller/HealthCheckController.ts: -------------------------------------------------------------------------------- 1 | import { controller, httpGet } from 'inversify-express-utils' 2 | 3 | @controller('/healthcheck') 4 | export class HealthCheckController { 5 | @httpGet('/') 6 | public async get(): Promise { 7 | return 'OK' 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/Controller/ItemsController.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express' 2 | import { inject } from 'inversify' 3 | import { BaseHttpController, controller, httpGet, httpPost, results } from 'inversify-express-utils' 4 | import TYPES from '../Bootstrap/Types' 5 | import { ApiVersion } from '../Domain/Api/ApiVersion' 6 | import { Item } from '../Domain/Item/Item' 7 | import { SyncResponseFactoryResolverInterface } from '../Domain/Item/SyncResponse/SyncResponseFactoryResolverInterface' 8 | import { CheckIntegrity } from '../Domain/UseCase/CheckIntegrity/CheckIntegrity' 9 | import { GetItem } from '../Domain/UseCase/GetItem/GetItem' 10 | import { SyncItems } from '../Domain/UseCase/SyncItems' 11 | import { ItemProjection } from '../Projection/ItemProjection' 12 | import { ProjectorInterface } from '../Projection/ProjectorInterface' 13 | 14 | @controller('/items', TYPES.AuthMiddleware) 15 | export class ItemsController extends BaseHttpController { 16 | constructor( 17 | @inject(TYPES.SyncItems) private syncItems: SyncItems, 18 | @inject(TYPES.CheckIntegrity) private checkIntegrity: CheckIntegrity, 19 | @inject(TYPES.GetItem) private getItem: GetItem, 20 | @inject(TYPES.ItemProjector) private itemProjector: ProjectorInterface, 21 | @inject(TYPES.SyncResponseFactoryResolver) 22 | private syncResponseFactoryResolver: SyncResponseFactoryResolverInterface, 23 | ) { 24 | super() 25 | } 26 | 27 | @httpPost('/sync') 28 | public async sync(request: Request, response: Response): Promise { 29 | let itemHashes = [] 30 | if ('items' in request.body) { 31 | itemHashes = request.body.items 32 | } 33 | 34 | const syncResult = await this.syncItems.execute({ 35 | userUuid: response.locals.user.uuid, 36 | itemHashes, 37 | computeIntegrityHash: request.body.compute_integrity === true, 38 | syncToken: request.body.sync_token, 39 | cursorToken: request.body.cursor_token, 40 | limit: request.body.limit, 41 | contentType: request.body.content_type, 42 | apiVersion: request.body.api ?? ApiVersion.v20161215, 43 | readOnlyAccess: response.locals.readOnlyAccess, 44 | analyticsId: response.locals.analyticsId, 45 | sessionUuid: response.locals.session ? response.locals.session.uuid : null, 46 | }) 47 | 48 | const syncResponse = await this.syncResponseFactoryResolver 49 | .resolveSyncResponseFactoryVersion(request.body.api) 50 | .createResponse(syncResult) 51 | 52 | return this.json(syncResponse) 53 | } 54 | 55 | @httpPost('/check-integrity') 56 | public async checkItemsIntegrity(request: Request, response: Response): Promise { 57 | let integrityPayloads = [] 58 | if ('integrityPayloads' in request.body) { 59 | integrityPayloads = request.body.integrityPayloads 60 | } 61 | 62 | const result = await this.checkIntegrity.execute({ 63 | userUuid: response.locals.user.uuid, 64 | integrityPayloads, 65 | }) 66 | 67 | return this.json(result) 68 | } 69 | 70 | @httpGet('/:uuid') 71 | public async getSingleItem( 72 | request: Request, 73 | response: Response, 74 | ): Promise { 75 | const result = await this.getItem.execute({ 76 | userUuid: response.locals.user.uuid, 77 | itemUuid: request.params.uuid, 78 | }) 79 | 80 | if (!result.success) { 81 | return this.notFound() 82 | } 83 | 84 | return this.json({ item: await this.itemProjector.projectFull(result.item) }) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/Controller/RevisionsController.spec.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | 3 | import { Revision } from '../Domain/Revision/Revision' 4 | import * as express from 'express' 5 | 6 | import { RevisionsController } from './RevisionsController' 7 | import { results } from 'inversify-express-utils' 8 | import { ProjectorInterface } from '../Projection/ProjectorInterface' 9 | import { RevisionServiceInterface } from '../Domain/Revision/RevisionServiceInterface' 10 | import { RevisionProjection } from '../Projection/RevisionProjection' 11 | 12 | describe('RevisionsController', () => { 13 | let revisionProjector: ProjectorInterface 14 | let revisionService: RevisionServiceInterface 15 | let revision: Revision 16 | let request: express.Request 17 | let response: express.Response 18 | 19 | const createController = () => new RevisionsController(revisionService, revisionProjector) 20 | 21 | beforeEach(() => { 22 | revision = {} as jest.Mocked 23 | 24 | revisionProjector = {} as jest.Mocked> 25 | 26 | revisionService = {} as jest.Mocked 27 | revisionService.getRevisions = jest.fn().mockReturnValue([revision]) 28 | revisionService.getRevision = jest.fn().mockReturnValue(revision) 29 | revisionService.removeRevision = jest.fn().mockReturnValue(true) 30 | 31 | request = { 32 | params: {}, 33 | } as jest.Mocked 34 | 35 | response = { 36 | locals: {}, 37 | } as jest.Mocked 38 | response.locals.user = { 39 | uuid: '123', 40 | } 41 | response.locals.roleNames = ['BASIC_USER'] 42 | }) 43 | 44 | it('should return revisions for an item', async () => { 45 | revisionProjector.projectSimple = jest.fn().mockReturnValue({ foo: 'bar' }) 46 | 47 | const revisionResponse = await createController().getRevisions(request, response) 48 | 49 | expect(revisionResponse.json).toEqual([{ foo: 'bar' }]) 50 | }) 51 | 52 | it('should return a specific revision for an item', async () => { 53 | revisionProjector.projectFull = jest.fn().mockReturnValue({ foo: 'bar' }) 54 | 55 | const httpResponse = await createController().getRevision(request, response) 56 | 57 | expect(httpResponse.json).toEqual({ foo: 'bar' }) 58 | }) 59 | 60 | it('should remove a specific revision for an item', async () => { 61 | const httpResponse = await createController().deleteRevision(request, response) 62 | 63 | expect(httpResponse).toBeInstanceOf(results.OkResult) 64 | }) 65 | 66 | it('should not remove a specific revision for an item if it fails', async () => { 67 | revisionService.removeRevision = jest.fn().mockReturnValue(false) 68 | 69 | const httpResponse = await createController().deleteRevision(request, response) 70 | 71 | expect(httpResponse).toBeInstanceOf(results.BadRequestResult) 72 | }) 73 | 74 | it('should not remove a specific revision for an item the session is read only', async () => { 75 | response.locals.readOnlyAccess = true 76 | 77 | const httpResponse = await createController().deleteRevision(request, response) 78 | const result = await httpResponse.executeAsync() 79 | 80 | expect(result.statusCode).toEqual(401) 81 | }) 82 | 83 | it('should return a 404 for a not found specific revision in an item', async () => { 84 | revisionService.getRevision = jest.fn().mockReturnValue(null) 85 | 86 | const httpResponse = await createController().getRevision(request, response) 87 | 88 | expect(httpResponse).toBeInstanceOf(results.NotFoundResult) 89 | }) 90 | }) 91 | -------------------------------------------------------------------------------- /src/Controller/RevisionsController.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express' 2 | import { BaseHttpController, controller, httpDelete, httpGet, results } from 'inversify-express-utils' 3 | import { inject } from 'inversify' 4 | 5 | import TYPES from '../Bootstrap/Types' 6 | import { ProjectorInterface } from '../Projection/ProjectorInterface' 7 | import { Revision } from '../Domain/Revision/Revision' 8 | import { RevisionServiceInterface } from '../Domain/Revision/RevisionServiceInterface' 9 | import { ErrorTag } from '@standardnotes/common' 10 | import { RevisionProjection } from '../Projection/RevisionProjection' 11 | 12 | @controller('/items/:itemUuid/revisions', TYPES.AuthMiddleware) 13 | export class RevisionsController extends BaseHttpController { 14 | constructor( 15 | @inject(TYPES.RevisionService) private revisionService: RevisionServiceInterface, 16 | @inject(TYPES.RevisionProjector) private revisionProjector: ProjectorInterface, 17 | ) { 18 | super() 19 | } 20 | 21 | @httpGet('/') 22 | public async getRevisions(req: Request, response: Response): Promise { 23 | const revisions = await this.revisionService.getRevisions(response.locals.user.uuid, req.params.itemUuid) 24 | 25 | const revisionProjections = [] 26 | for (const revision of revisions) { 27 | revisionProjections.push(await this.revisionProjector.projectSimple(revision)) 28 | } 29 | 30 | return this.json(revisionProjections) 31 | } 32 | 33 | @httpGet('/:uuid') 34 | public async getRevision(request: Request, response: Response): Promise { 35 | const revision = await this.revisionService.getRevision({ 36 | userRoles: response.locals.roleNames, 37 | userUuid: response.locals.user.uuid, 38 | itemUuid: request.params.itemUuid, 39 | revisionUuid: request.params.uuid, 40 | }) 41 | 42 | if (!revision) { 43 | return this.notFound() 44 | } 45 | 46 | const revisionProjection = await this.revisionProjector.projectFull(revision) 47 | 48 | return this.json(revisionProjection) 49 | } 50 | 51 | @httpDelete('/:uuid') 52 | public async deleteRevision( 53 | request: Request, 54 | response: Response, 55 | ): Promise { 56 | if (response.locals.readOnlyAccess) { 57 | return this.json( 58 | { 59 | error: { 60 | tag: ErrorTag.ReadOnlyAccess, 61 | message: 'Session has read-only access.', 62 | }, 63 | }, 64 | 401, 65 | ) 66 | } 67 | 68 | const success = await this.revisionService.removeRevision({ 69 | userUuid: response.locals.user.uuid, 70 | itemUuid: request.params.itemUuid, 71 | revisionUuid: request.params.uuid, 72 | }) 73 | 74 | if (!success) { 75 | return this.badRequest() 76 | } 77 | 78 | return this.ok() 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Domain/Api/ApiVersion.ts: -------------------------------------------------------------------------------- 1 | export enum ApiVersion { 2 | v20161215 = '20161215', 3 | v20190520 = '20190520', 4 | v20200115 = '20200115', 5 | } 6 | -------------------------------------------------------------------------------- /src/Domain/Auth/AuthHttpServiceInterface.ts: -------------------------------------------------------------------------------- 1 | import { SettingName } from 'aws-sdk/clients/ecs' 2 | import { KeyParamsData } from '@standardnotes/responses' 3 | 4 | export interface AuthHttpServiceInterface { 5 | getUserKeyParams(dto: { email?: string; uuid?: string; authenticated: boolean }): Promise 6 | getUserSetting(userUuid: string, settingName: SettingName): Promise<{ uuid: string; value: string | null }> 7 | } 8 | -------------------------------------------------------------------------------- /src/Domain/Event/DomainEventFactory.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DropboxBackupFailedEvent, 3 | DuplicateItemSyncedEvent, 4 | EmailArchiveExtensionSyncedEvent, 5 | EmailBackupAttachmentCreatedEvent, 6 | GoogleDriveBackupFailedEvent, 7 | ItemsSyncedEvent, 8 | MailBackupAttachmentTooBigEvent, 9 | OneDriveBackupFailedEvent, 10 | UserRegisteredEvent, 11 | } from '@standardnotes/domain-events' 12 | import { TimerInterface } from '@standardnotes/time' 13 | import { inject, injectable } from 'inversify' 14 | import TYPES from '../../Bootstrap/Types' 15 | import { DomainEventFactoryInterface } from './DomainEventFactoryInterface' 16 | 17 | @injectable() 18 | export class DomainEventFactory implements DomainEventFactoryInterface { 19 | constructor(@inject(TYPES.Timer) private timer: TimerInterface) {} 20 | 21 | createDuplicateItemSyncedEvent(itemUuid: string, userUuid: string): DuplicateItemSyncedEvent { 22 | return { 23 | type: 'DUPLICATE_ITEM_SYNCED', 24 | createdAt: this.timer.getUTCDate(), 25 | meta: { 26 | correlation: { 27 | userIdentifier: userUuid, 28 | userIdentifierType: 'uuid', 29 | }, 30 | }, 31 | payload: { 32 | itemUuid, 33 | userUuid, 34 | }, 35 | } 36 | } 37 | 38 | createDropboxBackupFailedEvent(muteCloudEmailsSettingUuid: string, email: string): DropboxBackupFailedEvent { 39 | return { 40 | type: 'DROPBOX_BACKUP_FAILED', 41 | createdAt: this.timer.getUTCDate(), 42 | meta: { 43 | correlation: { 44 | userIdentifier: email, 45 | userIdentifierType: 'email', 46 | }, 47 | }, 48 | payload: { 49 | muteCloudEmailsSettingUuid, 50 | email, 51 | }, 52 | } 53 | } 54 | 55 | createGoogleDriveBackupFailedEvent(muteCloudEmailsSettingUuid: string, email: string): GoogleDriveBackupFailedEvent { 56 | return { 57 | type: 'GOOGLE_DRIVE_BACKUP_FAILED', 58 | createdAt: this.timer.getUTCDate(), 59 | meta: { 60 | correlation: { 61 | userIdentifier: email, 62 | userIdentifierType: 'email', 63 | }, 64 | }, 65 | payload: { 66 | muteCloudEmailsSettingUuid, 67 | email, 68 | }, 69 | } 70 | } 71 | 72 | createOneDriveBackupFailedEvent(muteCloudEmailsSettingUuid: string, email: string): OneDriveBackupFailedEvent { 73 | return { 74 | type: 'ONE_DRIVE_BACKUP_FAILED', 75 | createdAt: this.timer.getUTCDate(), 76 | meta: { 77 | correlation: { 78 | userIdentifier: email, 79 | userIdentifierType: 'email', 80 | }, 81 | }, 82 | payload: { 83 | muteCloudEmailsSettingUuid, 84 | email, 85 | }, 86 | } 87 | } 88 | 89 | createMailBackupAttachmentTooBigEvent(dto: { 90 | allowedSize: string 91 | attachmentSize: string 92 | muteEmailsSettingUuid: string 93 | email: string 94 | }): MailBackupAttachmentTooBigEvent { 95 | return { 96 | type: 'MAIL_BACKUP_ATTACHMENT_TOO_BIG', 97 | createdAt: this.timer.getUTCDate(), 98 | meta: { 99 | correlation: { 100 | userIdentifier: dto.email, 101 | userIdentifierType: 'email', 102 | }, 103 | }, 104 | payload: dto, 105 | } 106 | } 107 | 108 | createItemsSyncedEvent(dto: { 109 | userUuid: string 110 | extensionUrl: string 111 | extensionId: string 112 | itemUuids: Array 113 | forceMute: boolean 114 | skipFileBackup: boolean 115 | source: 'account-deletion' | 'realtime-extensions-sync' 116 | }): ItemsSyncedEvent { 117 | return { 118 | type: 'ITEMS_SYNCED', 119 | createdAt: this.timer.getUTCDate(), 120 | meta: { 121 | correlation: { 122 | userIdentifier: dto.userUuid, 123 | userIdentifierType: 'uuid', 124 | }, 125 | }, 126 | payload: dto, 127 | } 128 | } 129 | 130 | createUserRegisteredEvent(userUuid: string, email: string): UserRegisteredEvent { 131 | return { 132 | type: 'USER_REGISTERED', 133 | createdAt: this.timer.getUTCDate(), 134 | meta: { 135 | correlation: { 136 | userIdentifier: userUuid, 137 | userIdentifierType: 'uuid', 138 | }, 139 | }, 140 | payload: { 141 | userUuid, 142 | email, 143 | }, 144 | } 145 | } 146 | 147 | createEmailArchiveExtensionSyncedEvent(userUuid: string, extensionId: string): EmailArchiveExtensionSyncedEvent { 148 | return { 149 | type: 'EMAIL_ARCHIVE_EXTENSION_SYNCED', 150 | createdAt: this.timer.getUTCDate(), 151 | meta: { 152 | correlation: { 153 | userIdentifier: userUuid, 154 | userIdentifierType: 'uuid', 155 | }, 156 | }, 157 | payload: { 158 | userUuid, 159 | extensionId, 160 | }, 161 | } 162 | } 163 | 164 | createEmailBackupAttachmentCreatedEvent(dto: { 165 | backupFileName: string 166 | backupFileIndex: number 167 | backupFilesTotal: number 168 | email: string 169 | }): EmailBackupAttachmentCreatedEvent { 170 | return { 171 | type: 'EMAIL_BACKUP_ATTACHMENT_CREATED', 172 | createdAt: this.timer.getUTCDate(), 173 | meta: { 174 | correlation: { 175 | userIdentifier: dto.email, 176 | userIdentifierType: 'email', 177 | }, 178 | }, 179 | payload: dto, 180 | } 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/Domain/Event/DomainEventFactoryInterface.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DropboxBackupFailedEvent, 3 | DuplicateItemSyncedEvent, 4 | EmailArchiveExtensionSyncedEvent, 5 | EmailBackupAttachmentCreatedEvent, 6 | GoogleDriveBackupFailedEvent, 7 | ItemsSyncedEvent, 8 | MailBackupAttachmentTooBigEvent, 9 | OneDriveBackupFailedEvent, 10 | UserRegisteredEvent, 11 | } from '@standardnotes/domain-events' 12 | 13 | export interface DomainEventFactoryInterface { 14 | createUserRegisteredEvent(userUuid: string, email: string): UserRegisteredEvent 15 | createDropboxBackupFailedEvent(muteCloudEmailsSettingUuid: string, email: string): DropboxBackupFailedEvent 16 | createGoogleDriveBackupFailedEvent(muteCloudEmailsSettingUuid: string, email: string): GoogleDriveBackupFailedEvent 17 | createOneDriveBackupFailedEvent(muteCloudEmailsSettingUuid: string, email: string): OneDriveBackupFailedEvent 18 | createMailBackupAttachmentTooBigEvent(dto: { 19 | allowedSize: string 20 | attachmentSize: string 21 | muteEmailsSettingUuid: string 22 | email: string 23 | }): MailBackupAttachmentTooBigEvent 24 | createItemsSyncedEvent(dto: { 25 | userUuid: string 26 | extensionUrl: string 27 | extensionId: string 28 | itemUuids: Array 29 | forceMute: boolean 30 | skipFileBackup: boolean 31 | source: 'account-deletion' | 'realtime-extensions-sync' 32 | }): ItemsSyncedEvent 33 | createEmailArchiveExtensionSyncedEvent(userUuid: string, extensionId: string): EmailArchiveExtensionSyncedEvent 34 | createEmailBackupAttachmentCreatedEvent(dto: { 35 | backupFileName: string 36 | backupFileIndex: number 37 | backupFilesTotal: number 38 | email: string 39 | }): EmailBackupAttachmentCreatedEvent 40 | createDuplicateItemSyncedEvent(itemUuid: string, userUuid: string): DuplicateItemSyncedEvent 41 | } 42 | -------------------------------------------------------------------------------- /src/Domain/Extension/ExtensionName.ts: -------------------------------------------------------------------------------- 1 | export enum ExtensionName { 2 | Dropbox = 'Dropbox', 3 | GoogleDrive = 'Google Drive', 4 | OneDrive = 'OneDrive', 5 | } 6 | -------------------------------------------------------------------------------- /src/Domain/Extension/ExtensionsHttpServiceInterface.ts: -------------------------------------------------------------------------------- 1 | import { KeyParamsData } from '@standardnotes/responses' 2 | import { SendItemsToExtensionsServerDTO } from './SendItemsToExtensionsServerDTO' 3 | 4 | export interface ExtensionsHttpServiceInterface { 5 | triggerCloudBackupOnExtensionsServer(dto: { 6 | cloudProvider: 'DROPBOX' | 'GOOGLE_DRIVE' | 'ONE_DRIVE' 7 | extensionsServerUrl: string 8 | backupFilename: string 9 | authParams: KeyParamsData 10 | forceMute: boolean 11 | userUuid: string 12 | muteEmailsSettingUuid: string 13 | }): Promise 14 | sendItemsToExtensionsServer(dto: SendItemsToExtensionsServerDTO): Promise 15 | } 16 | -------------------------------------------------------------------------------- /src/Domain/Extension/SendItemsToExtensionsServerDTO.ts: -------------------------------------------------------------------------------- 1 | import { KeyParamsData } from '@standardnotes/responses' 2 | import { Item } from '../Item/Item' 3 | 4 | export type SendItemsToExtensionsServerDTO = { 5 | extensionsServerUrl: string 6 | extensionId: string 7 | backupFilename: string 8 | authParams: KeyParamsData 9 | forceMute: boolean 10 | userUuid: string 11 | muteEmailsSettingUuid?: string 12 | items?: Array 13 | } 14 | -------------------------------------------------------------------------------- /src/Domain/Handler/AccountDeletionRequestedEventHandler.spec.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | 3 | import { AccountDeletionRequestedEvent } from '@standardnotes/domain-events' 4 | import { Logger } from 'winston' 5 | import { Item } from '../Item/Item' 6 | import { ItemRepositoryInterface } from '../Item/ItemRepositoryInterface' 7 | import { AccountDeletionRequestedEventHandler } from './AccountDeletionRequestedEventHandler' 8 | 9 | describe('AccountDeletionRequestedEventHandler', () => { 10 | let itemRepository: ItemRepositoryInterface 11 | let logger: Logger 12 | let event: AccountDeletionRequestedEvent 13 | let item: Item 14 | 15 | const createHandler = () => new AccountDeletionRequestedEventHandler(itemRepository, logger) 16 | 17 | beforeEach(() => { 18 | item = { 19 | uuid: '1-2-3', 20 | content: 'test', 21 | } as jest.Mocked 22 | 23 | itemRepository = {} as jest.Mocked 24 | itemRepository.findAll = jest.fn().mockReturnValue([item]) 25 | itemRepository.deleteByUserUuid = jest.fn() 26 | 27 | logger = {} as jest.Mocked 28 | logger.info = jest.fn() 29 | 30 | event = {} as jest.Mocked 31 | event.createdAt = new Date(1) 32 | event.payload = { 33 | userUuid: '2-3-4', 34 | regularSubscriptionUuid: '1-2-3', 35 | } 36 | }) 37 | 38 | it('should remove all items and revision for a user', async () => { 39 | await createHandler().handle(event) 40 | 41 | expect(itemRepository.deleteByUserUuid).toHaveBeenCalledWith('2-3-4') 42 | }) 43 | }) 44 | -------------------------------------------------------------------------------- /src/Domain/Handler/AccountDeletionRequestedEventHandler.ts: -------------------------------------------------------------------------------- 1 | import { AccountDeletionRequestedEvent, DomainEventHandlerInterface } from '@standardnotes/domain-events' 2 | import { inject, injectable } from 'inversify' 3 | import { Logger } from 'winston' 4 | import TYPES from '../../Bootstrap/Types' 5 | import { ItemRepositoryInterface } from '../Item/ItemRepositoryInterface' 6 | 7 | @injectable() 8 | export class AccountDeletionRequestedEventHandler implements DomainEventHandlerInterface { 9 | constructor( 10 | @inject(TYPES.ItemRepository) private itemRepository: ItemRepositoryInterface, 11 | @inject(TYPES.Logger) private logger: Logger, 12 | ) {} 13 | 14 | async handle(event: AccountDeletionRequestedEvent): Promise { 15 | await this.itemRepository.deleteByUserUuid(event.payload.userUuid) 16 | 17 | this.logger.info(`Finished account cleanup for user: ${event.payload.userUuid}`) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Domain/Handler/CloudBackupRequestedEventHandler.ts: -------------------------------------------------------------------------------- 1 | import { DomainEventHandlerInterface, CloudBackupRequestedEvent } from '@standardnotes/domain-events' 2 | import { inject, injectable } from 'inversify' 3 | 4 | import TYPES from '../../Bootstrap/Types' 5 | import { ItemRepositoryInterface } from '../Item/ItemRepositoryInterface' 6 | import { ItemQuery } from '../Item/ItemQuery' 7 | import { AuthHttpServiceInterface } from '../Auth/AuthHttpServiceInterface' 8 | import { Item } from '../Item/Item' 9 | import { ExtensionsHttpServiceInterface } from '../Extension/ExtensionsHttpServiceInterface' 10 | import { ItemBackupServiceInterface } from '../Item/ItemBackupServiceInterface' 11 | import { Logger } from 'winston' 12 | import { KeyParamsData } from '@standardnotes/responses' 13 | 14 | @injectable() 15 | export class CloudBackupRequestedEventHandler implements DomainEventHandlerInterface { 16 | constructor( 17 | @inject(TYPES.ItemRepository) private itemRepository: ItemRepositoryInterface, 18 | @inject(TYPES.AuthHttpService) private authHttpService: AuthHttpServiceInterface, 19 | @inject(TYPES.ExtensionsHttpService) private extensionsHttpService: ExtensionsHttpServiceInterface, 20 | @inject(TYPES.ItemBackupService) private itemBackupService: ItemBackupServiceInterface, 21 | @inject(TYPES.EXTENSIONS_SERVER_URL) private extensionsServerUrl: string, 22 | @inject(TYPES.Logger) private logger: Logger, 23 | ) {} 24 | 25 | async handle(event: CloudBackupRequestedEvent): Promise { 26 | const items = await this.getItemsForPostingToExtension(event) 27 | 28 | let authParams: KeyParamsData 29 | try { 30 | authParams = await this.authHttpService.getUserKeyParams({ 31 | uuid: event.payload.userUuid, 32 | authenticated: false, 33 | }) 34 | } catch (error) { 35 | this.logger.warn(`Could not get user key params from auth service: ${(error as Error).message}`) 36 | 37 | return 38 | } 39 | 40 | const backupFilename = await this.itemBackupService.backup(items, authParams) 41 | 42 | this.logger.debug(`Sending ${items.length} items to extensions server for user ${event.payload.userUuid}`) 43 | 44 | await this.extensionsHttpService.triggerCloudBackupOnExtensionsServer({ 45 | cloudProvider: event.payload.cloudProvider, 46 | authParams, 47 | backupFilename, 48 | forceMute: event.payload.userHasEmailsMuted, 49 | muteEmailsSettingUuid: event.payload.muteEmailsSettingUuid, 50 | extensionsServerUrl: this.getExtensionsServerUrl(event), 51 | userUuid: event.payload.userUuid, 52 | }) 53 | } 54 | 55 | private getExtensionsServerUrl(event: CloudBackupRequestedEvent): string { 56 | switch (event.payload.cloudProvider) { 57 | case 'ONE_DRIVE': 58 | return `${this.extensionsServerUrl}/onedrive/sync?type=sf&key=${event.payload.cloudProviderToken}` 59 | case 'GOOGLE_DRIVE': 60 | return `${this.extensionsServerUrl}/gdrive/sync?key=${event.payload.cloudProviderToken}` 61 | case 'DROPBOX': 62 | return `${this.extensionsServerUrl}/dropbox/items/sync?type=sf&dbt=${event.payload.cloudProviderToken}` 63 | default: 64 | throw new Error(`Unsupported cloud provider ${event.payload.cloudProvider}`) 65 | } 66 | } 67 | 68 | private async getItemsForPostingToExtension(event: CloudBackupRequestedEvent): Promise { 69 | const itemQuery: ItemQuery = { 70 | userUuid: event.payload.userUuid, 71 | sortBy: 'updated_at_timestamp', 72 | sortOrder: 'ASC', 73 | deleted: false, 74 | } 75 | 76 | return this.itemRepository.findAll(itemQuery) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Domain/Handler/DuplicateItemSyncedEventHandler.spec.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | 3 | import { DuplicateItemSyncedEvent } from '@standardnotes/domain-events' 4 | import { Logger } from 'winston' 5 | import { Item } from '../Item/Item' 6 | import { ItemRepositoryInterface } from '../Item/ItemRepositoryInterface' 7 | import { DuplicateItemSyncedEventHandler } from './DuplicateItemSyncedEventHandler' 8 | import { RevisionServiceInterface } from '../Revision/RevisionServiceInterface' 9 | 10 | describe('DuplicateItemSyncedEventHandler', () => { 11 | let itemRepository: ItemRepositoryInterface 12 | let revisionService: RevisionServiceInterface 13 | let logger: Logger 14 | let duplicateItem: Item 15 | let originalItem: Item 16 | let event: DuplicateItemSyncedEvent 17 | 18 | const createHandler = () => new DuplicateItemSyncedEventHandler(itemRepository, revisionService, logger) 19 | 20 | beforeEach(() => { 21 | originalItem = { 22 | uuid: '1-2-3', 23 | } as jest.Mocked 24 | 25 | duplicateItem = { 26 | uuid: '2-3-4', 27 | duplicateOf: '1-2-3', 28 | } as jest.Mocked 29 | 30 | itemRepository = {} as jest.Mocked 31 | itemRepository.findByUuidAndUserUuid = jest 32 | .fn() 33 | .mockReturnValueOnce(duplicateItem) 34 | .mockReturnValueOnce(originalItem) 35 | 36 | logger = {} as jest.Mocked 37 | logger.warn = jest.fn() 38 | 39 | revisionService = {} as jest.Mocked 40 | revisionService.copyRevisions = jest.fn() 41 | 42 | event = {} as jest.Mocked 43 | event.createdAt = new Date(1) 44 | event.payload = { 45 | userUuid: '1-2-3', 46 | itemUuid: '2-3-4', 47 | } 48 | }) 49 | 50 | it('should copy revisions from original item to the duplicate item', async () => { 51 | await createHandler().handle(event) 52 | 53 | expect(revisionService.copyRevisions).toHaveBeenCalledWith('1-2-3', '2-3-4') 54 | }) 55 | 56 | it('should not copy revisions if original item does not exist', async () => { 57 | itemRepository.findByUuidAndUserUuid = jest.fn().mockReturnValueOnce(duplicateItem).mockReturnValueOnce(null) 58 | 59 | await createHandler().handle(event) 60 | 61 | expect(revisionService.copyRevisions).not.toHaveBeenCalled() 62 | }) 63 | 64 | it('should not copy revisions if duplicate item does not exist', async () => { 65 | itemRepository.findByUuidAndUserUuid = jest.fn().mockReturnValueOnce(null).mockReturnValueOnce(originalItem) 66 | 67 | await createHandler().handle(event) 68 | 69 | expect(revisionService.copyRevisions).not.toHaveBeenCalled() 70 | }) 71 | 72 | it('should not copy revisions if duplicate item is not pointing to duplicate anything', async () => { 73 | duplicateItem.duplicateOf = null 74 | await createHandler().handle(event) 75 | 76 | expect(revisionService.copyRevisions).not.toHaveBeenCalled() 77 | }) 78 | }) 79 | -------------------------------------------------------------------------------- /src/Domain/Handler/DuplicateItemSyncedEventHandler.ts: -------------------------------------------------------------------------------- 1 | import { DomainEventHandlerInterface, DuplicateItemSyncedEvent } from '@standardnotes/domain-events' 2 | import { inject, injectable } from 'inversify' 3 | import { Logger } from 'winston' 4 | import TYPES from '../../Bootstrap/Types' 5 | import { ItemRepositoryInterface } from '../Item/ItemRepositoryInterface' 6 | import { RevisionServiceInterface } from '../Revision/RevisionServiceInterface' 7 | 8 | @injectable() 9 | export class DuplicateItemSyncedEventHandler implements DomainEventHandlerInterface { 10 | constructor( 11 | @inject(TYPES.ItemRepository) private itemRepository: ItemRepositoryInterface, 12 | @inject(TYPES.RevisionService) private revisionService: RevisionServiceInterface, 13 | @inject(TYPES.Logger) private logger: Logger, 14 | ) {} 15 | 16 | async handle(event: DuplicateItemSyncedEvent): Promise { 17 | const item = await this.itemRepository.findByUuidAndUserUuid(event.payload.itemUuid, event.payload.userUuid) 18 | 19 | if (item === null) { 20 | this.logger.warn(`Could not find item with uuid ${event.payload.itemUuid}`) 21 | 22 | return 23 | } 24 | 25 | if (!item.duplicateOf) { 26 | this.logger.warn(`Item ${event.payload.itemUuid} does not point to any duplicate`) 27 | 28 | return 29 | } 30 | 31 | const existingOriginalItem = await this.itemRepository.findByUuidAndUserUuid( 32 | item.duplicateOf, 33 | event.payload.userUuid, 34 | ) 35 | 36 | if (existingOriginalItem !== null) { 37 | await this.revisionService.copyRevisions(existingOriginalItem.uuid, item.uuid) 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Domain/Handler/EmailArchiveExtensionSyncedEventHandler.spec.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | 3 | import { 4 | DomainEventPublisherInterface, 5 | EmailArchiveExtensionSyncedEvent, 6 | EmailBackupAttachmentCreatedEvent, 7 | } from '@standardnotes/domain-events' 8 | import { Logger } from 'winston' 9 | import { AuthHttpServiceInterface } from '../Auth/AuthHttpServiceInterface' 10 | import { DomainEventFactoryInterface } from '../Event/DomainEventFactoryInterface' 11 | import { Item } from '../Item/Item' 12 | import { ItemBackupServiceInterface } from '../Item/ItemBackupServiceInterface' 13 | import { ItemRepositoryInterface } from '../Item/ItemRepositoryInterface' 14 | import { EmailArchiveExtensionSyncedEventHandler } from './EmailArchiveExtensionSyncedEventHandler' 15 | import { ItemTransferCalculatorInterface } from '../Item/ItemTransferCalculatorInterface' 16 | 17 | describe('EmailArchiveExtensionSyncedEventHandler', () => { 18 | let itemRepository: ItemRepositoryInterface 19 | let authHttpService: AuthHttpServiceInterface 20 | let itemBackupService: ItemBackupServiceInterface 21 | let domainEventPublisher: DomainEventPublisherInterface 22 | let domainEventFactory: DomainEventFactoryInterface 23 | const emailAttachmentMaxByteSize = 100 24 | let itemTransferCalculator: ItemTransferCalculatorInterface 25 | let item: Item 26 | let event: EmailArchiveExtensionSyncedEvent 27 | let logger: Logger 28 | 29 | const createHandler = () => 30 | new EmailArchiveExtensionSyncedEventHandler( 31 | itemRepository, 32 | authHttpService, 33 | itemBackupService, 34 | domainEventPublisher, 35 | domainEventFactory, 36 | emailAttachmentMaxByteSize, 37 | itemTransferCalculator, 38 | logger, 39 | ) 40 | 41 | beforeEach(() => { 42 | item = {} as jest.Mocked 43 | 44 | itemRepository = {} as jest.Mocked 45 | itemRepository.findAll = jest.fn().mockReturnValue([item]) 46 | 47 | authHttpService = {} as jest.Mocked 48 | authHttpService.getUserKeyParams = jest.fn().mockReturnValue({ identifier: 'test@test.com' }) 49 | authHttpService.getUserSetting = jest.fn().mockReturnValue({ uuid: '3-4-5', value: 'not_muted' }) 50 | 51 | event = {} as jest.Mocked 52 | event.createdAt = new Date(1) 53 | event.payload = { 54 | userUuid: '1-2-3', 55 | extensionId: '2-3-4', 56 | } 57 | 58 | itemBackupService = {} as jest.Mocked 59 | itemBackupService.backup = jest.fn().mockReturnValue('backup-file-name') 60 | 61 | domainEventPublisher = {} as jest.Mocked 62 | domainEventPublisher.publish = jest.fn() 63 | 64 | domainEventFactory = {} as jest.Mocked 65 | domainEventFactory.createEmailBackupAttachmentCreatedEvent = jest 66 | .fn() 67 | .mockReturnValue({} as jest.Mocked) 68 | 69 | itemTransferCalculator = {} as jest.Mocked 70 | itemTransferCalculator.computeItemUuidBundlesToFetch = jest.fn().mockReturnValue([['1-2-3']]) 71 | 72 | logger = {} as jest.Mocked 73 | logger.debug = jest.fn() 74 | logger.warn = jest.fn() 75 | }) 76 | 77 | it('should inform that backup attachment for email was created', async () => { 78 | await createHandler().handle(event) 79 | 80 | expect(domainEventPublisher.publish).toHaveBeenCalledTimes(1) 81 | expect(domainEventFactory.createEmailBackupAttachmentCreatedEvent).toHaveBeenCalledWith({ 82 | backupFileIndex: 1, 83 | backupFileName: 'backup-file-name', 84 | backupFilesTotal: 1, 85 | email: 'test@test.com', 86 | }) 87 | }) 88 | 89 | it('should inform that multipart backup attachment for email was created', async () => { 90 | itemBackupService.backup = jest 91 | .fn() 92 | .mockReturnValueOnce('backup-file-name-1') 93 | .mockReturnValueOnce('backup-file-name-2') 94 | itemTransferCalculator.computeItemUuidBundlesToFetch = jest.fn().mockReturnValue([['1-2-3'], ['2-3-4']]) 95 | 96 | await createHandler().handle(event) 97 | 98 | expect(domainEventPublisher.publish).toHaveBeenCalledTimes(2) 99 | expect(domainEventFactory.createEmailBackupAttachmentCreatedEvent).toHaveBeenNthCalledWith(1, { 100 | backupFileIndex: 1, 101 | backupFileName: 'backup-file-name-1', 102 | backupFilesTotal: 2, 103 | email: 'test@test.com', 104 | }) 105 | expect(domainEventFactory.createEmailBackupAttachmentCreatedEvent).toHaveBeenNthCalledWith(2, { 106 | backupFileIndex: 2, 107 | backupFileName: 'backup-file-name-2', 108 | backupFilesTotal: 2, 109 | email: 'test@test.com', 110 | }) 111 | }) 112 | 113 | it('should not inform that backup attachment for email was created if user key params cannot be obtained', async () => { 114 | authHttpService.getUserKeyParams = jest.fn().mockImplementation(() => { 115 | throw new Error('Oops!') 116 | }) 117 | 118 | await createHandler().handle(event) 119 | 120 | expect(domainEventPublisher.publish).not.toHaveBeenCalled() 121 | expect(domainEventFactory.createEmailBackupAttachmentCreatedEvent).not.toHaveBeenCalled() 122 | }) 123 | 124 | it('should not inform that backup attachment for email was created if backup file name is empty', async () => { 125 | itemBackupService.backup = jest.fn().mockReturnValue('') 126 | 127 | await createHandler().handle(event) 128 | 129 | expect(domainEventPublisher.publish).not.toHaveBeenCalled() 130 | expect(domainEventFactory.createEmailBackupAttachmentCreatedEvent).not.toHaveBeenCalled() 131 | }) 132 | }) 133 | -------------------------------------------------------------------------------- /src/Domain/Handler/EmailArchiveExtensionSyncedEventHandler.ts: -------------------------------------------------------------------------------- 1 | import { KeyParamsData } from '@standardnotes/responses' 2 | import { 3 | DomainEventHandlerInterface, 4 | DomainEventPublisherInterface, 5 | EmailArchiveExtensionSyncedEvent, 6 | } from '@standardnotes/domain-events' 7 | import { inject, injectable } from 'inversify' 8 | import { Logger } from 'winston' 9 | import TYPES from '../../Bootstrap/Types' 10 | import { AuthHttpServiceInterface } from '../Auth/AuthHttpServiceInterface' 11 | import { DomainEventFactoryInterface } from '../Event/DomainEventFactoryInterface' 12 | import { ItemBackupServiceInterface } from '../Item/ItemBackupServiceInterface' 13 | import { ItemRepositoryInterface } from '../Item/ItemRepositoryInterface' 14 | import { ItemQuery } from '../Item/ItemQuery' 15 | import { ItemTransferCalculatorInterface } from '../Item/ItemTransferCalculatorInterface' 16 | 17 | @injectable() 18 | export class EmailArchiveExtensionSyncedEventHandler implements DomainEventHandlerInterface { 19 | constructor( 20 | @inject(TYPES.ItemRepository) private itemRepository: ItemRepositoryInterface, 21 | @inject(TYPES.AuthHttpService) private authHttpService: AuthHttpServiceInterface, 22 | @inject(TYPES.ItemBackupService) private itemBackupService: ItemBackupServiceInterface, 23 | @inject(TYPES.DomainEventPublisher) private domainEventPublisher: DomainEventPublisherInterface, 24 | @inject(TYPES.DomainEventFactory) private domainEventFactory: DomainEventFactoryInterface, 25 | @inject(TYPES.EMAIL_ATTACHMENT_MAX_BYTE_SIZE) private emailAttachmentMaxByteSize: number, 26 | @inject(TYPES.ItemTransferCalculator) private itemTransferCalculator: ItemTransferCalculatorInterface, 27 | @inject(TYPES.Logger) private logger: Logger, 28 | ) {} 29 | 30 | async handle(event: EmailArchiveExtensionSyncedEvent): Promise { 31 | let authParams: KeyParamsData 32 | try { 33 | authParams = await this.authHttpService.getUserKeyParams({ 34 | uuid: event.payload.userUuid, 35 | authenticated: false, 36 | }) 37 | } catch (error) { 38 | this.logger.warn(`Could not get user key params from auth service: ${(error as Error).message}`) 39 | 40 | return 41 | } 42 | 43 | const itemQuery: ItemQuery = { 44 | userUuid: event.payload.userUuid, 45 | sortBy: 'updated_at_timestamp', 46 | sortOrder: 'ASC', 47 | deleted: false, 48 | } 49 | const itemUuidBundles = await this.itemTransferCalculator.computeItemUuidBundlesToFetch( 50 | itemQuery, 51 | this.emailAttachmentMaxByteSize, 52 | ) 53 | 54 | let bundleIndex = 1 55 | for (const itemUuidBundle of itemUuidBundles) { 56 | const items = await this.itemRepository.findAll({ 57 | uuids: itemUuidBundle, 58 | sortBy: 'updated_at_timestamp', 59 | sortOrder: 'ASC', 60 | }) 61 | 62 | const backupFileName = await this.itemBackupService.backup(items, authParams) 63 | 64 | this.logger.debug(`Data backed up into: ${backupFileName}`) 65 | 66 | if (backupFileName.length !== 0) { 67 | this.logger.debug('Publishing EMAIL_BACKUP_ATTACHMENT_CREATED event') 68 | 69 | await this.domainEventPublisher.publish( 70 | this.domainEventFactory.createEmailBackupAttachmentCreatedEvent({ 71 | backupFileName, 72 | backupFileIndex: bundleIndex++, 73 | backupFilesTotal: itemUuidBundles.length, 74 | email: authParams.identifier as string, 75 | }), 76 | ) 77 | } 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Domain/Handler/EmailBackupRequestedEventHandler.ts: -------------------------------------------------------------------------------- 1 | import { KeyParamsData } from '@standardnotes/responses' 2 | import { 3 | DomainEventHandlerInterface, 4 | DomainEventPublisherInterface, 5 | EmailBackupRequestedEvent, 6 | } from '@standardnotes/domain-events' 7 | import { inject, injectable } from 'inversify' 8 | import { Logger } from 'winston' 9 | import TYPES from '../../Bootstrap/Types' 10 | import { AuthHttpServiceInterface } from '../Auth/AuthHttpServiceInterface' 11 | import { DomainEventFactoryInterface } from '../Event/DomainEventFactoryInterface' 12 | import { ItemBackupServiceInterface } from '../Item/ItemBackupServiceInterface' 13 | import { ItemRepositoryInterface } from '../Item/ItemRepositoryInterface' 14 | import { ItemTransferCalculatorInterface } from '../Item/ItemTransferCalculatorInterface' 15 | import { ItemQuery } from '../Item/ItemQuery' 16 | 17 | @injectable() 18 | export class EmailBackupRequestedEventHandler implements DomainEventHandlerInterface { 19 | constructor( 20 | @inject(TYPES.ItemRepository) private itemRepository: ItemRepositoryInterface, 21 | @inject(TYPES.AuthHttpService) private authHttpService: AuthHttpServiceInterface, 22 | @inject(TYPES.ItemBackupService) private itemBackupService: ItemBackupServiceInterface, 23 | @inject(TYPES.DomainEventPublisher) private domainEventPublisher: DomainEventPublisherInterface, 24 | @inject(TYPES.DomainEventFactory) private domainEventFactory: DomainEventFactoryInterface, 25 | @inject(TYPES.EMAIL_ATTACHMENT_MAX_BYTE_SIZE) private emailAttachmentMaxByteSize: number, 26 | @inject(TYPES.ItemTransferCalculator) private itemTransferCalculator: ItemTransferCalculatorInterface, 27 | @inject(TYPES.Logger) private logger: Logger, 28 | ) {} 29 | 30 | async handle(event: EmailBackupRequestedEvent): Promise { 31 | let authParams: KeyParamsData 32 | try { 33 | authParams = await this.authHttpService.getUserKeyParams({ 34 | uuid: event.payload.userUuid, 35 | authenticated: false, 36 | }) 37 | } catch (error) { 38 | this.logger.warn(`Could not get user key params from auth service: ${(error as Error).message}`) 39 | 40 | return 41 | } 42 | 43 | const itemQuery: ItemQuery = { 44 | userUuid: event.payload.userUuid, 45 | sortBy: 'updated_at_timestamp', 46 | sortOrder: 'ASC', 47 | deleted: false, 48 | } 49 | const itemUuidBundles = await this.itemTransferCalculator.computeItemUuidBundlesToFetch( 50 | itemQuery, 51 | this.emailAttachmentMaxByteSize, 52 | ) 53 | 54 | let bundleIndex = 1 55 | for (const itemUuidBundle of itemUuidBundles) { 56 | const items = await this.itemRepository.findAll({ 57 | uuids: itemUuidBundle, 58 | sortBy: 'updated_at_timestamp', 59 | sortOrder: 'ASC', 60 | }) 61 | 62 | const backupFileName = await this.itemBackupService.backup(items, authParams) 63 | 64 | this.logger.debug(`Data backed up into: ${backupFileName}`) 65 | 66 | if (backupFileName.length !== 0) { 67 | this.logger.debug('Publishing EMAIL_BACKUP_ATTACHMENT_CREATED event') 68 | 69 | await this.domainEventPublisher.publish( 70 | this.domainEventFactory.createEmailBackupAttachmentCreatedEvent({ 71 | backupFileName, 72 | backupFileIndex: bundleIndex++, 73 | backupFilesTotal: itemUuidBundles.length, 74 | email: authParams.identifier as string, 75 | }), 76 | ) 77 | } 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Domain/Handler/ItemsSyncedEventHandler.ts: -------------------------------------------------------------------------------- 1 | import { DomainEventHandlerInterface, ItemsSyncedEvent } from '@standardnotes/domain-events' 2 | import { inject, injectable } from 'inversify' 3 | 4 | import TYPES from '../../Bootstrap/Types' 5 | import { ItemRepositoryInterface } from '../Item/ItemRepositoryInterface' 6 | import { ItemQuery } from '../Item/ItemQuery' 7 | import { AuthHttpServiceInterface } from '../Auth/AuthHttpServiceInterface' 8 | import { Item } from '../Item/Item' 9 | import { ExtensionsHttpServiceInterface } from '../Extension/ExtensionsHttpServiceInterface' 10 | import { ItemBackupServiceInterface } from '../Item/ItemBackupServiceInterface' 11 | import { Logger } from 'winston' 12 | import { KeyParamsData } from '@standardnotes/responses' 13 | 14 | @injectable() 15 | export class ItemsSyncedEventHandler implements DomainEventHandlerInterface { 16 | constructor( 17 | @inject(TYPES.ItemRepository) private itemRepository: ItemRepositoryInterface, 18 | @inject(TYPES.AuthHttpService) private authHttpService: AuthHttpServiceInterface, 19 | @inject(TYPES.ExtensionsHttpService) private extensionsHttpService: ExtensionsHttpServiceInterface, 20 | @inject(TYPES.ItemBackupService) private itemBackupService: ItemBackupServiceInterface, 21 | @inject(TYPES.INTERNAL_DNS_REROUTE_ENABLED) private internalDNSRerouteEnabled: boolean, 22 | @inject(TYPES.EXTENSIONS_SERVER_URL) private extensionsServerUrl: string, 23 | @inject(TYPES.Logger) private logger: Logger, 24 | ) {} 25 | 26 | async handle(event: ItemsSyncedEvent): Promise { 27 | const items = await this.getItemsForPostingToExtension(event) 28 | 29 | let authParams: KeyParamsData 30 | try { 31 | authParams = await this.authHttpService.getUserKeyParams({ 32 | uuid: event.payload.userUuid, 33 | authenticated: false, 34 | }) 35 | } catch (error) { 36 | this.logger.warn(`Could not get user key params from auth service: ${(error as Error).message}`) 37 | 38 | return 39 | } 40 | 41 | let backupFilename = '' 42 | if (!event.payload.skipFileBackup) { 43 | backupFilename = await this.itemBackupService.backup(items, authParams) 44 | } 45 | const backingUpViaProxyFile = backupFilename !== '' 46 | 47 | this.logger.debug(`Sending ${items.length} items to extensions server for user ${event.payload.userUuid}`) 48 | 49 | await this.extensionsHttpService.sendItemsToExtensionsServer({ 50 | items: backingUpViaProxyFile ? undefined : items, 51 | authParams, 52 | backupFilename, 53 | forceMute: event.payload.forceMute, 54 | extensionsServerUrl: this.getExtensionsServerUrl(event), 55 | userUuid: event.payload.userUuid, 56 | extensionId: event.payload.extensionId, 57 | }) 58 | } 59 | 60 | private getExtensionsServerUrl(event: ItemsSyncedEvent): string { 61 | if (this.internalDNSRerouteEnabled) { 62 | return event.payload.extensionUrl.replace('https://extensions.standardnotes.org', this.extensionsServerUrl) 63 | } 64 | 65 | return event.payload.extensionUrl 66 | } 67 | 68 | private async getItemsForPostingToExtension(event: ItemsSyncedEvent): Promise { 69 | const itemQuery: ItemQuery = { 70 | userUuid: event.payload.userUuid, 71 | sortBy: 'updated_at_timestamp', 72 | sortOrder: 'ASC', 73 | } 74 | if (event.payload.itemUuids.length) { 75 | itemQuery.uuids = event.payload.itemUuids 76 | } else { 77 | itemQuery.deleted = false 78 | } 79 | 80 | return this.itemRepository.findAll(itemQuery) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Domain/Item/ContentDecoder.spec.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | 3 | import { ContentDecoder } from './ContentDecoder' 4 | 5 | describe('ContentDecoder', () => { 6 | const createDecoder = () => new ContentDecoder() 7 | 8 | it('should decode content', () => { 9 | const content = '000eyJmb28iOiJiYXIifQ==' 10 | 11 | expect(createDecoder().decode(content)).toEqual({ 12 | foo: 'bar', 13 | }) 14 | }) 15 | 16 | it('should encode content', () => { 17 | expect( 18 | createDecoder().encode({ 19 | foo: 'bar', 20 | }), 21 | ).toEqual('000eyJmb28iOiJiYXIifQ==') 22 | }) 23 | 24 | it('should return empty object on decoding failure', () => { 25 | const content = '032400eyJmb28iOiJiYXIifQ==' 26 | 27 | expect(createDecoder().decode(content)).toEqual({}) 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /src/Domain/Item/ContentDecoder.ts: -------------------------------------------------------------------------------- 1 | import { injectable } from 'inversify' 2 | import { ContentDecoderInterface } from './ContentDecoderInterface' 3 | 4 | @injectable() 5 | export class ContentDecoder implements ContentDecoderInterface { 6 | decode(content: string): Record { 7 | try { 8 | const contentBuffer = Buffer.from(content.substring(3), 'base64') 9 | const decodedContent = contentBuffer.toString() 10 | 11 | return JSON.parse(decodedContent) 12 | } catch (error) { 13 | return {} 14 | } 15 | } 16 | 17 | encode(content: Record): string | undefined { 18 | const stringifiedContent = JSON.stringify(content) 19 | 20 | return `000${Buffer.from(stringifiedContent).toString('base64')}` 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Domain/Item/ContentDecoderInterface.ts: -------------------------------------------------------------------------------- 1 | export interface ContentDecoderInterface { 2 | decode(content: string): Record 3 | encode(content: Record): string | undefined 4 | } 5 | -------------------------------------------------------------------------------- /src/Domain/Item/ExtendedIntegrityPayload.ts: -------------------------------------------------------------------------------- 1 | import { ContentType } from '@standardnotes/common' 2 | import { IntegrityPayload } from '@standardnotes/payloads' 3 | 4 | export type ExtendedIntegrityPayload = IntegrityPayload & { 5 | content_type: ContentType 6 | } 7 | -------------------------------------------------------------------------------- /src/Domain/Item/GetItemsDTO.ts: -------------------------------------------------------------------------------- 1 | export type GetItemsDTO = { 2 | userUuid: string 3 | syncToken?: string | null 4 | cursorToken?: string | null 5 | limit?: number 6 | contentType?: string 7 | } 8 | -------------------------------------------------------------------------------- /src/Domain/Item/GetItemsResult.ts: -------------------------------------------------------------------------------- 1 | import { Item } from './Item' 2 | 3 | export type GetItemsResult = { 4 | items: Array 5 | cursorToken?: string 6 | } 7 | -------------------------------------------------------------------------------- /src/Domain/Item/Item.ts: -------------------------------------------------------------------------------- 1 | import { ContentType, Uuid } from '@standardnotes/common' 2 | import { Column, Entity, Index, OneToMany, PrimaryGeneratedColumn } from 'typeorm' 3 | import { Revision } from '../Revision/Revision' 4 | 5 | @Entity({ name: 'items' }) 6 | @Index('index_items_on_user_uuid_and_content_type', ['userUuid', 'contentType']) 7 | @Index('user_uuid_and_updated_at_timestamp_and_created_at_timestamp', [ 8 | 'userUuid', 9 | 'updatedAtTimestamp', 10 | 'createdAtTimestamp', 11 | ]) 12 | @Index('user_uuid_and_deleted', ['userUuid', 'deleted']) 13 | export class Item { 14 | @PrimaryGeneratedColumn('uuid') 15 | declare uuid: string 16 | 17 | @Column({ 18 | type: 'varchar', 19 | name: 'duplicate_of', 20 | length: 36, 21 | nullable: true, 22 | }) 23 | declare duplicateOf: string | null 24 | 25 | @Column({ 26 | type: 'varchar', 27 | name: 'items_key_id', 28 | length: 255, 29 | nullable: true, 30 | }) 31 | declare itemsKeyId: string | null 32 | 33 | @Column({ 34 | type: 'mediumtext', 35 | nullable: true, 36 | }) 37 | declare content: string | null 38 | 39 | @Column({ 40 | name: 'content_type', 41 | type: 'varchar', 42 | length: 255, 43 | nullable: true, 44 | }) 45 | @Index('index_items_on_content_type') 46 | declare contentType: ContentType | null 47 | 48 | @Column({ 49 | name: 'content_size', 50 | type: 'int', 51 | nullable: true, 52 | }) 53 | declare contentSize: number | null 54 | 55 | @Column({ 56 | name: 'enc_item_key', 57 | type: 'text', 58 | nullable: true, 59 | }) 60 | declare encItemKey: string | null 61 | 62 | @Column({ 63 | name: 'auth_hash', 64 | type: 'varchar', 65 | length: 255, 66 | nullable: true, 67 | }) 68 | declare authHash: string | null 69 | 70 | @Column({ 71 | name: 'user_uuid', 72 | length: 36, 73 | }) 74 | @Index('index_items_on_user_uuid') 75 | declare userUuid: string 76 | 77 | @Column({ 78 | type: 'tinyint', 79 | precision: 1, 80 | nullable: true, 81 | default: 0, 82 | }) 83 | @Index('index_items_on_deleted') 84 | declare deleted: boolean 85 | 86 | @Column({ 87 | name: 'created_at', 88 | type: 'datetime', 89 | precision: 6, 90 | }) 91 | declare createdAt: Date 92 | 93 | @Column({ 94 | name: 'updated_at', 95 | type: 'datetime', 96 | precision: 6, 97 | }) 98 | declare updatedAt: Date 99 | 100 | @Column({ 101 | name: 'created_at_timestamp', 102 | type: 'bigint', 103 | }) 104 | declare createdAtTimestamp: number 105 | 106 | @Column({ 107 | name: 'updated_at_timestamp', 108 | type: 'bigint', 109 | }) 110 | @Index('updated_at_timestamp') 111 | declare updatedAtTimestamp: number 112 | 113 | @OneToMany( 114 | /* istanbul ignore next */ 115 | () => Revision, 116 | /* istanbul ignore next */ 117 | (revision) => revision.item, 118 | ) 119 | declare revisions: Promise 120 | 121 | @Column({ 122 | name: 'updated_with_session', 123 | type: 'varchar', 124 | length: 36, 125 | nullable: true, 126 | }) 127 | declare updatedWithSession: Uuid | null 128 | } 129 | -------------------------------------------------------------------------------- /src/Domain/Item/ItemBackupServiceInterface.ts: -------------------------------------------------------------------------------- 1 | import { KeyParamsData } from '@standardnotes/responses' 2 | import { Item } from './Item' 3 | 4 | export interface ItemBackupServiceInterface { 5 | backup(items: Array, authParams: KeyParamsData): Promise 6 | } 7 | -------------------------------------------------------------------------------- /src/Domain/Item/ItemConflict.ts: -------------------------------------------------------------------------------- 1 | import { ConflictType } from '@standardnotes/responses' 2 | import { Item } from './Item' 3 | import { ItemHash } from './ItemHash' 4 | 5 | export type ItemConflict = { 6 | serverItem?: Item 7 | unsavedItem?: ItemHash 8 | type: ConflictType 9 | } 10 | -------------------------------------------------------------------------------- /src/Domain/Item/ItemFactory.ts: -------------------------------------------------------------------------------- 1 | import { Uuid } from '@standardnotes/common' 2 | import { TimerInterface } from '@standardnotes/time' 3 | import { inject, injectable } from 'inversify' 4 | 5 | import TYPES from '../../Bootstrap/Types' 6 | import { Item } from './Item' 7 | import { ItemFactoryInterface } from './ItemFactoryInterface' 8 | import { ItemHash } from './ItemHash' 9 | 10 | @injectable() 11 | export class ItemFactory implements ItemFactoryInterface { 12 | constructor(@inject(TYPES.Timer) private timer: TimerInterface) {} 13 | 14 | createStub(dto: { userUuid: string; itemHash: ItemHash; sessionUuid: Uuid | null }): Item { 15 | const item = this.create(dto) 16 | 17 | if (dto.itemHash.content === undefined) { 18 | item.content = null 19 | } 20 | 21 | if (dto.itemHash.updated_at_timestamp) { 22 | item.updatedAtTimestamp = dto.itemHash.updated_at_timestamp 23 | item.updatedAt = this.timer.convertMicrosecondsToDate(dto.itemHash.updated_at_timestamp) 24 | } else if (dto.itemHash.updated_at) { 25 | item.updatedAtTimestamp = this.timer.convertStringDateToMicroseconds(dto.itemHash.updated_at) 26 | item.updatedAt = this.timer.convertStringDateToDate(dto.itemHash.updated_at) 27 | } 28 | 29 | return item 30 | } 31 | 32 | create(dto: { userUuid: string; itemHash: ItemHash; sessionUuid: Uuid | null }): Item { 33 | const newItem = new Item() 34 | newItem.uuid = dto.itemHash.uuid 35 | newItem.updatedWithSession = dto.sessionUuid 36 | newItem.contentSize = 0 37 | if (dto.itemHash.content) { 38 | newItem.content = dto.itemHash.content 39 | newItem.contentSize = Buffer.byteLength(dto.itemHash.content) 40 | } 41 | newItem.userUuid = dto.userUuid 42 | if (dto.itemHash.content_type) { 43 | newItem.contentType = dto.itemHash.content_type 44 | } 45 | if (dto.itemHash.enc_item_key) { 46 | newItem.encItemKey = dto.itemHash.enc_item_key 47 | } 48 | if (dto.itemHash.items_key_id) { 49 | newItem.itemsKeyId = dto.itemHash.items_key_id 50 | } 51 | if (dto.itemHash.duplicate_of) { 52 | newItem.duplicateOf = dto.itemHash.duplicate_of 53 | } 54 | if (dto.itemHash.deleted !== undefined) { 55 | newItem.deleted = dto.itemHash.deleted 56 | } 57 | if (dto.itemHash.auth_hash) { 58 | newItem.authHash = dto.itemHash.auth_hash 59 | } 60 | 61 | const now = this.timer.getTimestampInMicroseconds() 62 | const nowDate = this.timer.convertMicrosecondsToDate(now) 63 | 64 | newItem.updatedAtTimestamp = now 65 | newItem.updatedAt = nowDate 66 | 67 | newItem.createdAtTimestamp = now 68 | newItem.createdAt = nowDate 69 | 70 | if (dto.itemHash.created_at_timestamp) { 71 | newItem.createdAtTimestamp = dto.itemHash.created_at_timestamp 72 | newItem.createdAt = this.timer.convertMicrosecondsToDate(dto.itemHash.created_at_timestamp) 73 | } else if (dto.itemHash.created_at) { 74 | newItem.createdAtTimestamp = this.timer.convertStringDateToMicroseconds(dto.itemHash.created_at) 75 | newItem.createdAt = this.timer.convertStringDateToDate(dto.itemHash.created_at) 76 | } 77 | 78 | return newItem 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Domain/Item/ItemFactoryInterface.ts: -------------------------------------------------------------------------------- 1 | import { Uuid } from '@standardnotes/common' 2 | 3 | import { Item } from './Item' 4 | import { ItemHash } from './ItemHash' 5 | 6 | export interface ItemFactoryInterface { 7 | create(dto: { userUuid: string; itemHash: ItemHash; sessionUuid: Uuid | null }): Item 8 | createStub(dto: { userUuid: string; itemHash: ItemHash; sessionUuid: Uuid | null }): Item 9 | } 10 | -------------------------------------------------------------------------------- /src/Domain/Item/ItemHash.ts: -------------------------------------------------------------------------------- 1 | import { ContentType } from '@standardnotes/common' 2 | 3 | export type ItemHash = { 4 | uuid: string 5 | content?: string 6 | content_type: ContentType 7 | deleted?: boolean 8 | duplicate_of?: string | null 9 | auth_hash?: string 10 | enc_item_key?: string 11 | items_key_id?: string 12 | created_at?: string 13 | created_at_timestamp?: number 14 | updated_at?: string 15 | updated_at_timestamp?: number 16 | } 17 | -------------------------------------------------------------------------------- /src/Domain/Item/ItemQuery.ts: -------------------------------------------------------------------------------- 1 | export type ItemQuery = { 2 | userUuid?: string 3 | sortBy: string 4 | sortOrder: 'ASC' | 'DESC' 5 | uuids?: Array 6 | lastSyncTime?: number 7 | syncTimeComparison?: '>' | '>=' 8 | contentType?: string 9 | deleted?: boolean 10 | offset?: number 11 | limit?: number 12 | } 13 | -------------------------------------------------------------------------------- /src/Domain/Item/ItemRepositoryInterface.ts: -------------------------------------------------------------------------------- 1 | import { Item } from './Item' 2 | import { ItemQuery } from './ItemQuery' 3 | import { ReadStream } from 'fs' 4 | import { ExtendedIntegrityPayload } from './ExtendedIntegrityPayload' 5 | 6 | export interface ItemRepositoryInterface { 7 | deleteByUserUuid(userUuid: string): Promise 8 | findAll(query: ItemQuery): Promise 9 | streamAll(query: ItemQuery): Promise 10 | countAll(query: ItemQuery): Promise 11 | findContentSizeForComputingTransferLimit( 12 | query: ItemQuery, 13 | ): Promise> 14 | findDatesForComputingIntegrityHash(userUuid: string): Promise> 15 | findItemsForComputingIntegrityPayloads(userUuid: string): Promise 16 | findByUuidAndUserUuid(uuid: string, userUuid: string): Promise 17 | findByUuid(uuid: string): Promise 18 | remove(item: Item): Promise 19 | save(item: Item): Promise 20 | markItemsAsDeleted(itemUuids: Array, updatedAtTimestamp: number): Promise 21 | updateContentSize(itemUuid: string, contentSize: number): Promise 22 | } 23 | -------------------------------------------------------------------------------- /src/Domain/Item/ItemServiceInterface.ts: -------------------------------------------------------------------------------- 1 | import { GetItemsDTO } from './GetItemsDTO' 2 | import { GetItemsResult } from './GetItemsResult' 3 | import { Item } from './Item' 4 | import { SaveItemsDTO } from './SaveItemsDTO' 5 | import { SaveItemsResult } from './SaveItemsResult' 6 | 7 | export interface ItemServiceInterface { 8 | getItems(dto: GetItemsDTO): Promise 9 | saveItems(dto: SaveItemsDTO): Promise 10 | frontLoadKeysItemsToTop(userUuid: string, retrievedItems: Array): Promise> 11 | } 12 | -------------------------------------------------------------------------------- /src/Domain/Item/ItemTransferCalculator.ts: -------------------------------------------------------------------------------- 1 | import { inject, injectable } from 'inversify' 2 | import { Uuid } from '@standardnotes/common' 3 | import { Logger } from 'winston' 4 | 5 | import TYPES from '../../Bootstrap/Types' 6 | 7 | import { ItemTransferCalculatorInterface } from './ItemTransferCalculatorInterface' 8 | import { ItemQuery } from './ItemQuery' 9 | import { ItemRepositoryInterface } from './ItemRepositoryInterface' 10 | 11 | @injectable() 12 | export class ItemTransferCalculator implements ItemTransferCalculatorInterface { 13 | constructor( 14 | @inject(TYPES.ItemRepository) private itemRepository: ItemRepositoryInterface, 15 | @inject(TYPES.Logger) private logger: Logger, 16 | ) {} 17 | 18 | async computeItemUuidsToFetch(itemQuery: ItemQuery, bytesTransferLimit: number): Promise> { 19 | const itemUuidsToFetch = [] 20 | const itemContentSizes = await this.itemRepository.findContentSizeForComputingTransferLimit(itemQuery) 21 | let totalContentSizeInBytes = 0 22 | for (const itemContentSize of itemContentSizes) { 23 | const contentSize = itemContentSize.contentSize ?? 0 24 | 25 | itemUuidsToFetch.push(itemContentSize.uuid) 26 | totalContentSizeInBytes += contentSize 27 | 28 | const transferLimitBreached = this.isTransferLimitBreached({ 29 | totalContentSizeInBytes, 30 | bytesTransferLimit, 31 | itemUuidsToFetch, 32 | itemContentSizes, 33 | }) 34 | 35 | if (transferLimitBreached) { 36 | break 37 | } 38 | } 39 | 40 | return itemUuidsToFetch 41 | } 42 | 43 | async computeItemUuidBundlesToFetch(itemQuery: ItemQuery, bytesTransferLimit: number): Promise>> { 44 | let itemUuidsToFetch = [] 45 | const itemContentSizes = await this.itemRepository.findContentSizeForComputingTransferLimit(itemQuery) 46 | let totalContentSizeInBytes = 0 47 | const bundles = [] 48 | for (const itemContentSize of itemContentSizes) { 49 | const contentSize = itemContentSize.contentSize ?? 0 50 | 51 | itemUuidsToFetch.push(itemContentSize.uuid) 52 | totalContentSizeInBytes += contentSize 53 | 54 | const transferLimitBreached = this.isTransferLimitBreached({ 55 | totalContentSizeInBytes, 56 | bytesTransferLimit, 57 | itemUuidsToFetch, 58 | itemContentSizes, 59 | }) 60 | 61 | if (transferLimitBreached) { 62 | bundles.push(Object.assign([], itemUuidsToFetch)) 63 | totalContentSizeInBytes = 0 64 | itemUuidsToFetch = [] 65 | } 66 | } 67 | 68 | if (itemUuidsToFetch.length > 0) { 69 | bundles.push(itemUuidsToFetch) 70 | } 71 | 72 | return bundles 73 | } 74 | 75 | private isTransferLimitBreached(dto: { 76 | totalContentSizeInBytes: number 77 | bytesTransferLimit: number 78 | itemUuidsToFetch: Array 79 | itemContentSizes: Array<{ uuid: string; contentSize: number | null }> 80 | }): boolean { 81 | const transferLimitBreached = dto.totalContentSizeInBytes >= dto.bytesTransferLimit 82 | const transferLimitBreachedAtFirstItem = 83 | transferLimitBreached && dto.itemUuidsToFetch.length === 1 && dto.itemContentSizes.length > 1 84 | 85 | if (transferLimitBreachedAtFirstItem) { 86 | this.logger.warn( 87 | `Item ${dto.itemUuidsToFetch[0]} is breaching the content size transfer limit: ${dto.bytesTransferLimit}`, 88 | ) 89 | } 90 | 91 | return transferLimitBreached && !transferLimitBreachedAtFirstItem 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/Domain/Item/ItemTransferCalculatorInterface.ts: -------------------------------------------------------------------------------- 1 | import { Uuid } from '@standardnotes/common' 2 | 3 | import { ItemQuery } from './ItemQuery' 4 | 5 | export interface ItemTransferCalculatorInterface { 6 | computeItemUuidsToFetch(itemQuery: ItemQuery, bytesTransferLimit: number): Promise> 7 | computeItemUuidBundlesToFetch(itemQuery: ItemQuery, bytesTransferLimit: number): Promise>> 8 | } 9 | -------------------------------------------------------------------------------- /src/Domain/Item/SaveItemsDTO.ts: -------------------------------------------------------------------------------- 1 | import { Uuid } from '@standardnotes/common' 2 | 3 | import { ItemHash } from './ItemHash' 4 | 5 | export type SaveItemsDTO = { 6 | itemHashes: ItemHash[] 7 | userUuid: string 8 | apiVersion: string 9 | readOnlyAccess: boolean 10 | sessionUuid: Uuid | null 11 | } 12 | -------------------------------------------------------------------------------- /src/Domain/Item/SaveItemsResult.ts: -------------------------------------------------------------------------------- 1 | import { Item } from './Item' 2 | import { ItemConflict } from './ItemConflict' 3 | 4 | export type SaveItemsResult = { 5 | savedItems: Array 6 | conflicts: Array 7 | syncToken: string 8 | } 9 | -------------------------------------------------------------------------------- /src/Domain/Item/SaveRule/ContentFilter.spec.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | 3 | import { ContentType } from '@standardnotes/common' 4 | 5 | import { ApiVersion } from '../../Api/ApiVersion' 6 | import { Item } from '../Item' 7 | 8 | import { ContentFilter } from './ContentFilter' 9 | 10 | describe('ContentFilter', () => { 11 | let existingItem: Item 12 | const createFilter = () => new ContentFilter() 13 | 14 | it('should filter out items with invalid content', async () => { 15 | const invalidContents = [[], { foo: 'bar' }, [{ foo: 'bar' }], 123, new Date(1)] 16 | 17 | for (const invalidContent of invalidContents) { 18 | const result = await createFilter().check({ 19 | userUuid: '1-2-3', 20 | apiVersion: ApiVersion.v20200115, 21 | itemHash: { 22 | uuid: '123e4567-e89b-12d3-a456-426655440000', 23 | content: invalidContent as unknown as string, 24 | content_type: ContentType.Note, 25 | }, 26 | existingItem: null, 27 | }) 28 | 29 | expect(result).toEqual({ 30 | passed: false, 31 | conflict: { 32 | unsavedItem: { 33 | uuid: '123e4567-e89b-12d3-a456-426655440000', 34 | content: invalidContent, 35 | content_type: ContentType.Note, 36 | }, 37 | type: 'content_error', 38 | }, 39 | }) 40 | } 41 | }) 42 | 43 | it('should leave items with valid content', async () => { 44 | const validContents = ['string', null, undefined] 45 | 46 | for (const validContent of validContents) { 47 | const result = await createFilter().check({ 48 | userUuid: '1-2-3', 49 | apiVersion: ApiVersion.v20200115, 50 | itemHash: { 51 | uuid: '123e4567-e89b-12d3-a456-426655440000', 52 | content: validContent as unknown as string, 53 | content_type: ContentType.Note, 54 | }, 55 | existingItem, 56 | }) 57 | 58 | expect(result).toEqual({ 59 | passed: true, 60 | }) 61 | } 62 | }) 63 | }) 64 | -------------------------------------------------------------------------------- /src/Domain/Item/SaveRule/ContentFilter.ts: -------------------------------------------------------------------------------- 1 | import { injectable } from 'inversify' 2 | import { ItemSaveValidationDTO } from '../SaveValidator/ItemSaveValidationDTO' 3 | import { ItemSaveRuleResult } from './ItemSaveRuleResult' 4 | import { ItemSaveRuleInterface } from './ItemSaveRuleInterface' 5 | import { ConflictType } from '@standardnotes/responses' 6 | 7 | @injectable() 8 | export class ContentFilter implements ItemSaveRuleInterface { 9 | async check(dto: ItemSaveValidationDTO): Promise { 10 | if (dto.itemHash.content === undefined || dto.itemHash.content === null) { 11 | return { 12 | passed: true, 13 | } 14 | } 15 | 16 | const validContent = typeof dto.itemHash.content === 'string' 17 | 18 | if (!validContent) { 19 | return { 20 | passed: false, 21 | conflict: { 22 | unsavedItem: dto.itemHash, 23 | type: ConflictType.ContentError, 24 | }, 25 | } 26 | } 27 | 28 | return { 29 | passed: true, 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Domain/Item/SaveRule/ContentTypeFilter.spec.ts: -------------------------------------------------------------------------------- 1 | import { ContentType } from '@standardnotes/common' 2 | import 'reflect-metadata' 3 | import { ApiVersion } from '../../Api/ApiVersion' 4 | import { Item } from '../Item' 5 | 6 | import { ContentTypeFilter } from './ContentTypeFilter' 7 | 8 | describe('ContentTypeFilter', () => { 9 | let existingItem: Item 10 | const createFilter = () => new ContentTypeFilter() 11 | 12 | it('should filter out items with invalid content type', async () => { 13 | const invalidContentTypes = [ 14 | '', 15 | 'c73bcdcc26694bf681d3e4ae73fb11fd', 16 | 'definitely-not-a-content-type', 17 | '1-2-3', 18 | 'test', 19 | "(select load_file('\\\\\\\\iugt7mazsk477", 20 | '/etc/passwd', 21 | "eval(compile('for x in range(1):\\n i", 22 | ] 23 | 24 | for (const invalidContentType of invalidContentTypes) { 25 | const result = await createFilter().check({ 26 | userUuid: '1-2-3', 27 | apiVersion: ApiVersion.v20200115, 28 | itemHash: { 29 | uuid: '123e4567-e89b-12d3-a456-426655440000', 30 | content_type: invalidContentType as ContentType, 31 | }, 32 | existingItem: null, 33 | }) 34 | 35 | expect(result).toEqual({ 36 | passed: false, 37 | conflict: { 38 | unsavedItem: { 39 | uuid: '123e4567-e89b-12d3-a456-426655440000', 40 | content_type: invalidContentType, 41 | }, 42 | type: 'content_type_error', 43 | }, 44 | }) 45 | } 46 | }) 47 | 48 | it('should leave items with valid content type', async () => { 49 | const validContentTypes = ['Note', 'SN|ItemsKey', 'SN|Component', 'SN|Editor', 'SN|ExtensionRepo', 'Tag'] 50 | 51 | for (const validContentType of validContentTypes) { 52 | const result = await createFilter().check({ 53 | userUuid: '1-2-3', 54 | apiVersion: ApiVersion.v20200115, 55 | itemHash: { 56 | uuid: '123e4567-e89b-12d3-a456-426655440000', 57 | content_type: validContentType as ContentType, 58 | }, 59 | existingItem, 60 | }) 61 | 62 | expect(result).toEqual({ 63 | passed: true, 64 | }) 65 | } 66 | }) 67 | }) 68 | -------------------------------------------------------------------------------- /src/Domain/Item/SaveRule/ContentTypeFilter.ts: -------------------------------------------------------------------------------- 1 | import { ContentType } from '@standardnotes/common' 2 | import { injectable } from 'inversify' 3 | import { ItemSaveValidationDTO } from '../SaveValidator/ItemSaveValidationDTO' 4 | import { ItemSaveRuleResult } from './ItemSaveRuleResult' 5 | import { ItemSaveRuleInterface } from './ItemSaveRuleInterface' 6 | import { ConflictType } from '@standardnotes/responses' 7 | 8 | @injectable() 9 | export class ContentTypeFilter implements ItemSaveRuleInterface { 10 | async check(dto: ItemSaveValidationDTO): Promise { 11 | const validContentType = Object.values(ContentType).includes(dto.itemHash.content_type as ContentType) 12 | 13 | if (!validContentType) { 14 | return { 15 | passed: false, 16 | conflict: { 17 | unsavedItem: dto.itemHash, 18 | type: ConflictType.ContentTypeError, 19 | }, 20 | } 21 | } 22 | 23 | return { 24 | passed: true, 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Domain/Item/SaveRule/ItemSaveRuleInterface.ts: -------------------------------------------------------------------------------- 1 | import { ItemSaveValidationDTO } from '../SaveValidator/ItemSaveValidationDTO' 2 | import { ItemSaveRuleResult } from './ItemSaveRuleResult' 3 | 4 | export interface ItemSaveRuleInterface { 5 | check(dto: ItemSaveValidationDTO): Promise 6 | } 7 | -------------------------------------------------------------------------------- /src/Domain/Item/SaveRule/ItemSaveRuleResult.ts: -------------------------------------------------------------------------------- 1 | import { Item } from '../Item' 2 | import { ItemConflict } from '../ItemConflict' 3 | 4 | export type ItemSaveRuleResult = { 5 | passed: boolean 6 | conflict?: ItemConflict 7 | skipped?: Item 8 | } 9 | -------------------------------------------------------------------------------- /src/Domain/Item/SaveRule/OwnershipFilter.spec.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | 3 | import { ContentType } from '@standardnotes/common' 4 | 5 | import { ApiVersion } from '../../Api/ApiVersion' 6 | import { Item } from '../Item' 7 | 8 | import { OwnershipFilter } from './OwnershipFilter' 9 | 10 | describe('OwnershipFilter', () => { 11 | let existingItem: Item 12 | const createFilter = () => new OwnershipFilter() 13 | 14 | beforeEach(() => { 15 | existingItem = {} as jest.Mocked 16 | existingItem.userUuid = '2-3-4' 17 | }) 18 | 19 | it('should filter out items belonging to a different user', async () => { 20 | const result = await createFilter().check({ 21 | userUuid: '1-2-3', 22 | apiVersion: ApiVersion.v20200115, 23 | itemHash: { 24 | uuid: '2-3-4', 25 | content_type: ContentType.Note, 26 | }, 27 | existingItem, 28 | }) 29 | 30 | expect(result).toEqual({ 31 | passed: false, 32 | conflict: { 33 | unsavedItem: { 34 | uuid: '2-3-4', 35 | content_type: ContentType.Note, 36 | }, 37 | type: 'uuid_conflict', 38 | }, 39 | }) 40 | }) 41 | 42 | it('should leave items belonging to the same user', async () => { 43 | existingItem.userUuid = '1-2-3' 44 | 45 | const result = await createFilter().check({ 46 | userUuid: '1-2-3', 47 | apiVersion: ApiVersion.v20200115, 48 | itemHash: { 49 | uuid: '2-3-4', 50 | content_type: ContentType.Note, 51 | }, 52 | existingItem, 53 | }) 54 | 55 | expect(result).toEqual({ 56 | passed: true, 57 | }) 58 | }) 59 | 60 | it('should leave non existing items', async () => { 61 | const result = await createFilter().check({ 62 | userUuid: '1-2-3', 63 | apiVersion: ApiVersion.v20200115, 64 | itemHash: { 65 | uuid: '2-3-4', 66 | content_type: ContentType.Note, 67 | }, 68 | existingItem: null, 69 | }) 70 | 71 | expect(result).toEqual({ 72 | passed: true, 73 | }) 74 | }) 75 | }) 76 | -------------------------------------------------------------------------------- /src/Domain/Item/SaveRule/OwnershipFilter.ts: -------------------------------------------------------------------------------- 1 | import { injectable } from 'inversify' 2 | import { ItemSaveValidationDTO } from '../SaveValidator/ItemSaveValidationDTO' 3 | import { ItemSaveRuleResult } from './ItemSaveRuleResult' 4 | import { ItemSaveRuleInterface } from './ItemSaveRuleInterface' 5 | import { ConflictType } from '@standardnotes/responses' 6 | 7 | @injectable() 8 | export class OwnershipFilter implements ItemSaveRuleInterface { 9 | async check(dto: ItemSaveValidationDTO): Promise { 10 | const itemBelongsToADifferentUser = dto.existingItem !== null && dto.existingItem.userUuid !== dto.userUuid 11 | if (itemBelongsToADifferentUser) { 12 | return { 13 | passed: false, 14 | conflict: { 15 | unsavedItem: dto.itemHash, 16 | type: ConflictType.UuidConflict, 17 | }, 18 | } 19 | } 20 | 21 | return { 22 | passed: true, 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Domain/Item/SaveRule/TimeDifferenceFilter.ts: -------------------------------------------------------------------------------- 1 | import { Time, TimerInterface } from '@standardnotes/time' 2 | import { inject, injectable } from 'inversify' 3 | import TYPES from '../../../Bootstrap/Types' 4 | import { ApiVersion } from '../../Api/ApiVersion' 5 | import { ItemHash } from '../ItemHash' 6 | import { ItemSaveValidationDTO } from '../SaveValidator/ItemSaveValidationDTO' 7 | import { ItemSaveRuleResult } from './ItemSaveRuleResult' 8 | import { ItemSaveRuleInterface } from './ItemSaveRuleInterface' 9 | import { ConflictType } from '@standardnotes/responses' 10 | 11 | @injectable() 12 | export class TimeDifferenceFilter implements ItemSaveRuleInterface { 13 | constructor(@inject(TYPES.Timer) private timer: TimerInterface) {} 14 | 15 | async check(dto: ItemSaveValidationDTO): Promise { 16 | if (!dto.existingItem) { 17 | return { 18 | passed: true, 19 | } 20 | } 21 | 22 | let incomingUpdatedAtTimestamp = dto.itemHash.updated_at_timestamp 23 | if (incomingUpdatedAtTimestamp === undefined) { 24 | incomingUpdatedAtTimestamp = 25 | dto.itemHash.updated_at !== undefined 26 | ? this.timer.convertStringDateToMicroseconds(dto.itemHash.updated_at) 27 | : this.timer.convertStringDateToMicroseconds(new Date(0).toString()) 28 | } 29 | 30 | if (this.itemWasSentFromALegacyClient(incomingUpdatedAtTimestamp, dto.apiVersion)) { 31 | return { 32 | passed: true, 33 | } 34 | } 35 | 36 | const ourUpdatedAtTimestamp = dto.existingItem.updatedAtTimestamp 37 | const difference = incomingUpdatedAtTimestamp - ourUpdatedAtTimestamp 38 | 39 | if (this.itemHashHasMicrosecondsPrecision(dto.itemHash)) { 40 | const passed = difference === 0 41 | 42 | return { 43 | passed, 44 | conflict: passed 45 | ? undefined 46 | : { 47 | serverItem: dto.existingItem, 48 | type: ConflictType.ConflictingData, 49 | }, 50 | } 51 | } 52 | 53 | const passed = Math.abs(difference) < this.getMinimalConflictIntervalMicroseconds(dto.apiVersion) 54 | 55 | return { 56 | passed, 57 | conflict: passed 58 | ? undefined 59 | : { 60 | serverItem: dto.existingItem, 61 | type: ConflictType.ConflictingData, 62 | }, 63 | } 64 | } 65 | 66 | private itemWasSentFromALegacyClient(incomingUpdatedAtTimestamp: number, apiVersion: string) { 67 | return incomingUpdatedAtTimestamp === 0 && apiVersion === ApiVersion.v20161215 68 | } 69 | 70 | private itemHashHasMicrosecondsPrecision(itemHash: ItemHash) { 71 | return itemHash.updated_at_timestamp !== undefined 72 | } 73 | 74 | private getMinimalConflictIntervalMicroseconds(apiVersion?: string): number { 75 | switch (apiVersion) { 76 | case ApiVersion.v20161215: 77 | return Time.MicrosecondsInASecond 78 | default: 79 | return Time.MicrosecondsInAMillisecond 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Domain/Item/SaveRule/UuidFilter.spec.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | 3 | import { ContentType } from '@standardnotes/common' 4 | 5 | import { ApiVersion } from '../../Api/ApiVersion' 6 | import { Item } from '../Item' 7 | 8 | import { UuidFilter } from './UuidFilter' 9 | 10 | describe('UuidFilter', () => { 11 | const createFilter = () => new UuidFilter() 12 | 13 | it('should filter out items with invalid uuid', async () => { 14 | const invalidUuids = [ 15 | 'c73bcdcc-2669-4bf6-81d3-e4an73fb11fd', 16 | 'c73bcdcc26694bf681d3e4ae73fb11fd', 17 | 'definitely-not-a-uuid', 18 | '1-2-3', 19 | 'test', 20 | "(select load_file('\\\\\\\\iugt7mazsk477", 21 | '/etc/passwd', 22 | "eval(compile('for x in range(1):\\n i", 23 | ] 24 | 25 | for (const invalidUuid of invalidUuids) { 26 | const result = await createFilter().check({ 27 | userUuid: '1-2-3', 28 | apiVersion: ApiVersion.v20200115, 29 | itemHash: { 30 | uuid: invalidUuid, 31 | content_type: ContentType.Note, 32 | }, 33 | existingItem: null, 34 | }) 35 | 36 | expect(result).toEqual({ 37 | passed: false, 38 | conflict: { 39 | unsavedItem: { 40 | uuid: invalidUuid, 41 | content_type: ContentType.Note, 42 | }, 43 | type: 'uuid_error', 44 | }, 45 | }) 46 | } 47 | }) 48 | 49 | it('should leave items with valid uuid', async () => { 50 | const validUuids = [ 51 | '123e4567-e89b-12d3-a456-426655440000', 52 | 'c73bcdcc-2669-4bf6-81d3-e4ae73fb11fd', 53 | 'C73BCDCC-2669-4Bf6-81d3-E4AE73FB11FD', 54 | ] 55 | 56 | for (const validUuid of validUuids) { 57 | const result = await createFilter().check({ 58 | userUuid: '1-2-3', 59 | apiVersion: ApiVersion.v20200115, 60 | itemHash: { 61 | uuid: validUuid, 62 | content_type: ContentType.Note, 63 | }, 64 | existingItem: {} as jest.Mocked, 65 | }) 66 | 67 | expect(result).toEqual({ 68 | passed: true, 69 | }) 70 | } 71 | }) 72 | }) 73 | -------------------------------------------------------------------------------- /src/Domain/Item/SaveRule/UuidFilter.ts: -------------------------------------------------------------------------------- 1 | import { injectable } from 'inversify' 2 | import { validate } from 'uuid' 3 | import { ItemSaveValidationDTO } from '../SaveValidator/ItemSaveValidationDTO' 4 | import { ItemSaveRuleResult } from './ItemSaveRuleResult' 5 | import { ItemSaveRuleInterface } from './ItemSaveRuleInterface' 6 | import { ConflictType } from '@standardnotes/responses' 7 | 8 | @injectable() 9 | export class UuidFilter implements ItemSaveRuleInterface { 10 | async check(dto: ItemSaveValidationDTO): Promise { 11 | const validUuid = validate(dto.itemHash.uuid) 12 | 13 | if (!validUuid) { 14 | return { 15 | passed: false, 16 | conflict: { 17 | unsavedItem: dto.itemHash, 18 | type: ConflictType.UuidError, 19 | }, 20 | } 21 | } 22 | 23 | return { 24 | passed: true, 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Domain/Item/SaveValidator/ItemSaveValidationDTO.ts: -------------------------------------------------------------------------------- 1 | import { Item } from '../Item' 2 | import { ItemHash } from '../ItemHash' 3 | 4 | export type ItemSaveValidationDTO = { 5 | userUuid: string 6 | apiVersion: string 7 | itemHash: ItemHash 8 | existingItem: Item | null 9 | } 10 | -------------------------------------------------------------------------------- /src/Domain/Item/SaveValidator/ItemSaveValidationResult.ts: -------------------------------------------------------------------------------- 1 | import { Item } from '../Item' 2 | import { ItemConflict } from '../ItemConflict' 3 | 4 | export type ItemSaveValidationResult = { 5 | passed: boolean 6 | conflict?: ItemConflict 7 | skipped?: Item 8 | } 9 | -------------------------------------------------------------------------------- /src/Domain/Item/SaveValidator/ItemSaveValidator.spec.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | import { ApiVersion } from '../../Api/ApiVersion' 3 | import { ItemHash } from '../ItemHash' 4 | import { ItemSaveRuleInterface } from '../SaveRule/ItemSaveRuleInterface' 5 | 6 | import { ItemSaveValidator } from './ItemSaveValidator' 7 | 8 | describe('ItemSaveValidator', () => { 9 | let rule: ItemSaveRuleInterface 10 | let itemHash: ItemHash 11 | 12 | const createProcessor = () => new ItemSaveValidator([rule]) 13 | 14 | beforeEach(() => { 15 | rule = {} as jest.Mocked 16 | rule.check = jest.fn().mockReturnValue({ passed: true }) 17 | 18 | itemHash = {} as jest.Mocked 19 | }) 20 | 21 | it('should run item through all filters with passing', async () => { 22 | const result = await createProcessor().validate({ 23 | apiVersion: ApiVersion.v20200115, 24 | userUuid: '1-2-3', 25 | itemHash, 26 | existingItem: null, 27 | }) 28 | 29 | expect(result).toEqual({ 30 | passed: true, 31 | }) 32 | }) 33 | 34 | it('should run item through all filters with not passing', async () => { 35 | rule.check = jest.fn().mockReturnValue({ passed: false }) 36 | 37 | const result = await createProcessor().validate({ 38 | apiVersion: ApiVersion.v20200115, 39 | userUuid: '1-2-3', 40 | itemHash, 41 | existingItem: null, 42 | }) 43 | 44 | expect(result).toEqual({ 45 | passed: false, 46 | }) 47 | }) 48 | }) 49 | -------------------------------------------------------------------------------- /src/Domain/Item/SaveValidator/ItemSaveValidator.ts: -------------------------------------------------------------------------------- 1 | import { injectable } from 'inversify' 2 | import { ItemSaveRuleInterface } from '../SaveRule/ItemSaveRuleInterface' 3 | import { ItemSaveValidationDTO } from './ItemSaveValidationDTO' 4 | import { ItemSaveValidationResult } from './ItemSaveValidationResult' 5 | import { ItemSaveValidatorInterface } from './ItemSaveValidatorInterface' 6 | 7 | @injectable() 8 | export class ItemSaveValidator implements ItemSaveValidatorInterface { 9 | constructor(private rules: Array) {} 10 | 11 | async validate(dto: ItemSaveValidationDTO): Promise { 12 | for (const rule of this.rules) { 13 | const result = await rule.check(dto) 14 | if (!result.passed) { 15 | return { 16 | passed: false, 17 | conflict: result.conflict, 18 | skipped: result.skipped, 19 | } 20 | } 21 | } 22 | 23 | return { 24 | passed: true, 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Domain/Item/SaveValidator/ItemSaveValidatorInterface.ts: -------------------------------------------------------------------------------- 1 | import { ItemSaveValidationDTO } from './ItemSaveValidationDTO' 2 | import { ItemSaveValidationResult } from './ItemSaveValidationResult' 3 | 4 | export interface ItemSaveValidatorInterface { 5 | validate(dto: ItemSaveValidationDTO): Promise 6 | } 7 | -------------------------------------------------------------------------------- /src/Domain/Item/SyncResponse/SyncResponse20161215.ts: -------------------------------------------------------------------------------- 1 | import { ConflictType } from '@standardnotes/responses' 2 | 3 | import { ItemHash } from '../ItemHash' 4 | import { ItemProjection } from '../../../Projection/ItemProjection' 5 | 6 | export type SyncResponse20161215 = { 7 | retrieved_items: Array 8 | saved_items: Array 9 | unsaved: Array<{ 10 | item: ItemProjection | ItemHash 11 | error: { 12 | tag: ConflictType 13 | } 14 | }> 15 | sync_token: string 16 | cursor_token?: string 17 | } 18 | -------------------------------------------------------------------------------- /src/Domain/Item/SyncResponse/SyncResponse20200115.ts: -------------------------------------------------------------------------------- 1 | import { ItemConflictProjection } from '../../../Projection/ItemConflictProjection' 2 | import { ItemProjection } from '../../../Projection/ItemProjection' 3 | import { SavedItemProjection } from '../../../Projection/SavedItemProjection' 4 | 5 | export type SyncResponse20200115 = { 6 | retrieved_items: Array 7 | saved_items: Array 8 | conflicts: Array 9 | sync_token: string 10 | cursor_token?: string 11 | } 12 | -------------------------------------------------------------------------------- /src/Domain/Item/SyncResponse/SyncResponseFactory20161215.spec.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | import { ProjectorInterface } from '../../../Projection/ProjectorInterface' 3 | 4 | import { Item } from '../Item' 5 | import { ItemHash } from '../ItemHash' 6 | import { ItemProjection } from '../../../Projection/ItemProjection' 7 | import { SyncResponseFactory20161215 } from './SyncResponseFactory20161215' 8 | import { ConflictType } from '@standardnotes/responses' 9 | 10 | describe('SyncResponseFactory20161215', () => { 11 | let itemProjector: ProjectorInterface 12 | let item1Projection: ItemProjection 13 | let item2Projection: ItemProjection 14 | let item1: Item 15 | let item2: Item 16 | 17 | const createFactory = () => new SyncResponseFactory20161215(itemProjector) 18 | 19 | beforeEach(() => { 20 | item1Projection = { 21 | uuid: '1-2-3', 22 | } as jest.Mocked 23 | item2Projection = { 24 | uuid: '2-3-4', 25 | } as jest.Mocked 26 | 27 | itemProjector = {} as jest.Mocked> 28 | itemProjector.projectFull = jest.fn().mockImplementation((item: Item) => { 29 | if (item.uuid === '1-2-3') { 30 | return item1Projection 31 | } else if (item.uuid === '2-3-4') { 32 | return item2Projection 33 | } 34 | 35 | return undefined 36 | }) 37 | 38 | item1 = { 39 | uuid: '1-2-3', 40 | updatedAtTimestamp: 100, 41 | } as jest.Mocked 42 | 43 | item2 = { 44 | uuid: '2-3-4', 45 | } as jest.Mocked 46 | }) 47 | 48 | it('should turn sync items response into a sync response for API Version 20161215', async () => { 49 | const itemHash1 = {} as jest.Mocked 50 | expect( 51 | await createFactory().createResponse({ 52 | retrievedItems: [item1], 53 | savedItems: [item2], 54 | conflicts: [ 55 | { 56 | serverItem: item1, 57 | type: ConflictType.ConflictingData, 58 | }, 59 | { 60 | unsavedItem: itemHash1, 61 | type: ConflictType.UuidConflict, 62 | }, 63 | ], 64 | syncToken: 'sync-test', 65 | cursorToken: 'cursor-test', 66 | }), 67 | ).toEqual({ 68 | retrieved_items: [item1Projection], 69 | saved_items: [item2Projection], 70 | unsaved: [ 71 | { 72 | item: itemHash1, 73 | error: { 74 | tag: 'uuid_conflict', 75 | }, 76 | }, 77 | ], 78 | sync_token: 'sync-test', 79 | cursor_token: 'cursor-test', 80 | }) 81 | }) 82 | 83 | it('should pick out conflicts between saved and retrieved items and remove them from the later', async () => { 84 | const itemHash1 = {} as jest.Mocked 85 | 86 | const duplicateItem1 = Object.assign({}, item1) 87 | duplicateItem1.updatedAtTimestamp = item1.updatedAtTimestamp + 21_000_000 88 | 89 | const duplicateItem2 = Object.assign({}, item2) 90 | 91 | expect( 92 | await createFactory().createResponse({ 93 | retrievedItems: [duplicateItem1, duplicateItem2], 94 | savedItems: [item1, item2], 95 | conflicts: [ 96 | { 97 | unsavedItem: itemHash1, 98 | type: ConflictType.UuidConflict, 99 | }, 100 | ], 101 | syncToken: 'sync-test', 102 | cursorToken: 'cursor-test', 103 | }), 104 | ).toEqual({ 105 | retrieved_items: [], 106 | saved_items: [item1Projection, item2Projection], 107 | unsaved: [ 108 | { 109 | error: { 110 | tag: 'uuid_conflict', 111 | }, 112 | item: itemHash1, 113 | }, 114 | { 115 | error: { 116 | tag: 'sync_conflict', 117 | }, 118 | item: item1Projection, 119 | }, 120 | ], 121 | sync_token: 'sync-test', 122 | cursor_token: 'cursor-test', 123 | }) 124 | }) 125 | }) 126 | -------------------------------------------------------------------------------- /src/Domain/Item/SyncResponse/SyncResponseFactory20161215.ts: -------------------------------------------------------------------------------- 1 | import { inject, injectable } from 'inversify' 2 | import TYPES from '../../../Bootstrap/Types' 3 | import { ProjectorInterface } from '../../../Projection/ProjectorInterface' 4 | import { SyncItemsResponse } from '../../UseCase/SyncItemsResponse' 5 | import { Item } from '../Item' 6 | import { ItemConflict } from '../ItemConflict' 7 | import { ItemHash } from '../ItemHash' 8 | import { ItemProjection } from '../../../Projection/ItemProjection' 9 | import { SyncResponse20161215 } from './SyncResponse20161215' 10 | import { SyncResponseFactoryInterface } from './SyncResponseFactoryInterface' 11 | import { ConflictType } from '@standardnotes/responses' 12 | 13 | @injectable() 14 | export class SyncResponseFactory20161215 implements SyncResponseFactoryInterface { 15 | private readonly LEGACY_MIN_CONFLICT_INTERVAL = 20_000_000 16 | 17 | constructor(@inject(TYPES.ItemProjector) private itemProjector: ProjectorInterface) {} 18 | 19 | async createResponse(syncItemsResponse: SyncItemsResponse): Promise { 20 | const conflicts = syncItemsResponse.conflicts.filter( 21 | (itemConflict: ItemConflict) => itemConflict.type === ConflictType.UuidConflict, 22 | ) 23 | 24 | const pickOutConflictsResult = this.pickOutConflicts( 25 | syncItemsResponse.savedItems, 26 | syncItemsResponse.retrievedItems, 27 | conflicts, 28 | ) 29 | 30 | const unsaved = [] 31 | for (const conflict of pickOutConflictsResult.unsavedItems) { 32 | unsaved.push({ 33 | item: conflict.serverItem 34 | ? await this.itemProjector.projectFull(conflict.serverItem) 35 | : conflict.unsavedItem, 36 | error: { 37 | tag: conflict.type, 38 | }, 39 | }) 40 | } 41 | 42 | const retrievedItems = [] 43 | for (const item of pickOutConflictsResult.retrievedItems) { 44 | retrievedItems.push(await this.itemProjector.projectFull(item)) 45 | } 46 | 47 | const savedItems = [] 48 | for (const item of syncItemsResponse.savedItems) { 49 | savedItems.push(await this.itemProjector.projectFull(item)) 50 | } 51 | 52 | return { 53 | retrieved_items: retrievedItems, 54 | saved_items: savedItems, 55 | unsaved, 56 | sync_token: syncItemsResponse.syncToken, 57 | cursor_token: syncItemsResponse.cursorToken, 58 | } 59 | } 60 | 61 | private pickOutConflicts( 62 | savedItems: Array, 63 | retrievedItems: Array, 64 | unsavedItems: Array, 65 | ): { 66 | unsavedItems: Array 67 | retrievedItems: Array 68 | } { 69 | const savedIds: Array = savedItems.map((savedItem: Item) => savedItem.uuid) 70 | const retrievedIds: Array = retrievedItems.map((retrievedItem: Item) => retrievedItem.uuid) 71 | 72 | const conflictingIds = savedIds.filter((savedId) => retrievedIds.includes(savedId)) 73 | 74 | for (const conflictingId of conflictingIds) { 75 | const savedItem = savedItems.find((item) => item.uuid === conflictingId) 76 | const conflictedItem = retrievedItems.find((item) => item.uuid === conflictingId) 77 | 78 | const difference = savedItem.updatedAtTimestamp - conflictedItem.updatedAtTimestamp 79 | 80 | if (Math.abs(difference) > this.LEGACY_MIN_CONFLICT_INTERVAL) { 81 | unsavedItems.push({ 82 | serverItem: conflictedItem, 83 | type: ConflictType.ConflictingData, 84 | }) 85 | } 86 | 87 | retrievedItems = retrievedItems.filter((retrievedItem: Item) => retrievedItem.uuid !== conflictingId) 88 | } 89 | 90 | return { 91 | retrievedItems, 92 | unsavedItems, 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/Domain/Item/SyncResponse/SyncResponseFactory20200115.spec.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | import { ProjectorInterface } from '../../../Projection/ProjectorInterface' 3 | import { Item } from '../Item' 4 | import { ItemConflict } from '../ItemConflict' 5 | import { ItemConflictProjection } from '../../../Projection/ItemConflictProjection' 6 | import { ItemProjection } from '../../../Projection/ItemProjection' 7 | 8 | import { SyncResponseFactory20200115 } from './SyncResponseFactory20200115' 9 | import { SavedItemProjection } from '../../../Projection/SavedItemProjection' 10 | 11 | describe('SyncResponseFactory20200115', () => { 12 | let itemProjector: ProjectorInterface 13 | let savedItemProjector: ProjectorInterface 14 | let itemConflictProjector: ProjectorInterface 15 | let itemProjection: ItemProjection 16 | let savedItemProjection: SavedItemProjection 17 | let itemConflictProjection: ItemConflictProjection 18 | let item1: Item 19 | let item2: Item 20 | let itemConflict: ItemConflict 21 | 22 | const createFactory = () => new SyncResponseFactory20200115(itemProjector, itemConflictProjector, savedItemProjector) 23 | 24 | beforeEach(() => { 25 | itemProjection = { 26 | uuid: '2-3-4', 27 | } as jest.Mocked 28 | 29 | itemProjector = {} as jest.Mocked> 30 | itemProjector.projectFull = jest.fn().mockReturnValue(itemProjection) 31 | 32 | itemConflictProjector = {} as jest.Mocked> 33 | itemConflictProjector.projectFull = jest.fn().mockReturnValue(itemConflictProjection) 34 | 35 | savedItemProjection = { 36 | uuid: '1-2-3', 37 | } as jest.Mocked 38 | 39 | savedItemProjector = {} as jest.Mocked> 40 | savedItemProjector.projectFull = jest.fn().mockReturnValue(savedItemProjection) 41 | 42 | item1 = {} as jest.Mocked 43 | 44 | item2 = {} as jest.Mocked 45 | 46 | itemConflict = {} as jest.Mocked 47 | }) 48 | 49 | it('should turn sync items response into a sync response for API Version 20200115', async () => { 50 | expect( 51 | await createFactory().createResponse({ 52 | retrievedItems: [item1], 53 | savedItems: [item2], 54 | conflicts: [itemConflict], 55 | syncToken: 'sync-test', 56 | cursorToken: 'cursor-test', 57 | }), 58 | ).toEqual({ 59 | retrieved_items: [itemProjection], 60 | saved_items: [savedItemProjection], 61 | conflicts: [itemConflictProjection], 62 | sync_token: 'sync-test', 63 | cursor_token: 'cursor-test', 64 | }) 65 | }) 66 | }) 67 | -------------------------------------------------------------------------------- /src/Domain/Item/SyncResponse/SyncResponseFactory20200115.ts: -------------------------------------------------------------------------------- 1 | import { inject, injectable } from 'inversify' 2 | import TYPES from '../../../Bootstrap/Types' 3 | import { ProjectorInterface } from '../../../Projection/ProjectorInterface' 4 | import { SyncItemsResponse } from '../../UseCase/SyncItemsResponse' 5 | import { Item } from '../Item' 6 | import { ItemConflict } from '../ItemConflict' 7 | import { ItemConflictProjection } from '../../../Projection/ItemConflictProjection' 8 | import { ItemProjection } from '../../../Projection/ItemProjection' 9 | import { SyncResponse20200115 } from './SyncResponse20200115' 10 | import { SyncResponseFactoryInterface } from './SyncResponseFactoryInterface' 11 | import { SavedItemProjection } from '../../../Projection/SavedItemProjection' 12 | 13 | @injectable() 14 | export class SyncResponseFactory20200115 implements SyncResponseFactoryInterface { 15 | constructor( 16 | @inject(TYPES.ItemProjector) private itemProjector: ProjectorInterface, 17 | @inject(TYPES.ItemConflictProjector) 18 | private itemConflictProjector: ProjectorInterface, 19 | @inject(TYPES.SavedItemProjector) private savedItemProjector: ProjectorInterface, 20 | ) {} 21 | 22 | async createResponse(syncItemsResponse: SyncItemsResponse): Promise { 23 | const retrievedItems = [] 24 | for (const item of syncItemsResponse.retrievedItems) { 25 | retrievedItems.push(await this.itemProjector.projectFull(item)) 26 | } 27 | 28 | const savedItems = [] 29 | for (const item of syncItemsResponse.savedItems) { 30 | savedItems.push(await this.savedItemProjector.projectFull(item)) 31 | } 32 | 33 | const conflicts = [] 34 | for (const itemConflict of syncItemsResponse.conflicts) { 35 | conflicts.push(await this.itemConflictProjector.projectFull(itemConflict)) 36 | } 37 | 38 | return { 39 | retrieved_items: retrievedItems, 40 | saved_items: savedItems, 41 | conflicts, 42 | sync_token: syncItemsResponse.syncToken, 43 | cursor_token: syncItemsResponse.cursorToken, 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Domain/Item/SyncResponse/SyncResponseFactoryInterface.ts: -------------------------------------------------------------------------------- 1 | import { SyncItemsResponse } from '../../UseCase/SyncItemsResponse' 2 | import { SyncResponse20161215 } from './SyncResponse20161215' 3 | import { SyncResponse20200115 } from './SyncResponse20200115' 4 | 5 | export interface SyncResponseFactoryInterface { 6 | createResponse(syncItemsResponse: SyncItemsResponse): Promise 7 | } 8 | -------------------------------------------------------------------------------- /src/Domain/Item/SyncResponse/SyncResponseFactoryResolver.spec.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | 3 | import { ApiVersion } from '../../Api/ApiVersion' 4 | import { SyncResponseFactory20161215 } from './SyncResponseFactory20161215' 5 | import { SyncResponseFactory20200115 } from './SyncResponseFactory20200115' 6 | import { SyncResponseFactoryResolver } from './SyncResponseFactoryResolver' 7 | 8 | describe('SyncResponseFactoryResolver', () => { 9 | let syncResponseFactory20161215: SyncResponseFactory20161215 10 | let syncResponseFactory20200115: SyncResponseFactory20200115 11 | 12 | const createResolver = () => new SyncResponseFactoryResolver(syncResponseFactory20161215, syncResponseFactory20200115) 13 | 14 | beforeEach(() => { 15 | syncResponseFactory20161215 = {} as jest.Mocked 16 | 17 | syncResponseFactory20200115 = {} as jest.Mocked 18 | }) 19 | 20 | it('should resolve factory for API Version 20161215', () => { 21 | expect(createResolver().resolveSyncResponseFactoryVersion(ApiVersion.v20161215)).toEqual( 22 | syncResponseFactory20161215, 23 | ) 24 | }) 25 | 26 | it('should resolve factory for API Version 20200115', () => { 27 | expect(createResolver().resolveSyncResponseFactoryVersion(ApiVersion.v20200115)).toEqual( 28 | syncResponseFactory20200115, 29 | ) 30 | }) 31 | 32 | it('should resolve factory for API Version 20190520', () => { 33 | expect(createResolver().resolveSyncResponseFactoryVersion(ApiVersion.v20190520)).toEqual( 34 | syncResponseFactory20200115, 35 | ) 36 | }) 37 | 38 | it('should resolve factory for undefined API Version', () => { 39 | expect(createResolver().resolveSyncResponseFactoryVersion()).toEqual(syncResponseFactory20161215) 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /src/Domain/Item/SyncResponse/SyncResponseFactoryResolver.ts: -------------------------------------------------------------------------------- 1 | import { inject, injectable } from 'inversify' 2 | import TYPES from '../../../Bootstrap/Types' 3 | import { ApiVersion } from '../../Api/ApiVersion' 4 | import { SyncResponseFactory20161215 } from './SyncResponseFactory20161215' 5 | import { SyncResponseFactory20200115 } from './SyncResponseFactory20200115' 6 | import { SyncResponseFactoryInterface } from './SyncResponseFactoryInterface' 7 | import { SyncResponseFactoryResolverInterface } from './SyncResponseFactoryResolverInterface' 8 | 9 | @injectable() 10 | export class SyncResponseFactoryResolver implements SyncResponseFactoryResolverInterface { 11 | constructor( 12 | @inject(TYPES.SyncResponseFactory20161215) private syncResponseFactory20161215: SyncResponseFactory20161215, 13 | @inject(TYPES.SyncResponseFactory20200115) private syncResponseFactory20200115: SyncResponseFactory20200115, 14 | ) {} 15 | 16 | resolveSyncResponseFactoryVersion(apiVersion?: string): SyncResponseFactoryInterface { 17 | switch (apiVersion) { 18 | case ApiVersion.v20190520: 19 | case ApiVersion.v20200115: 20 | return this.syncResponseFactory20200115 21 | default: 22 | return this.syncResponseFactory20161215 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Domain/Item/SyncResponse/SyncResponseFactoryResolverInterface.ts: -------------------------------------------------------------------------------- 1 | import { SyncResponseFactoryInterface } from './SyncResponseFactoryInterface' 2 | 3 | export interface SyncResponseFactoryResolverInterface { 4 | resolveSyncResponseFactoryVersion(apiVersion?: string): SyncResponseFactoryInterface 5 | } 6 | -------------------------------------------------------------------------------- /src/Domain/Revision/Revision.spec.ts: -------------------------------------------------------------------------------- 1 | import { Revision } from './Revision' 2 | 3 | describe('Revision', () => { 4 | it('should instantiate', () => { 5 | expect(new Revision()).toBeInstanceOf(Revision) 6 | }) 7 | }) 8 | -------------------------------------------------------------------------------- /src/Domain/Revision/Revision.ts: -------------------------------------------------------------------------------- 1 | import { ContentType } from '@standardnotes/common' 2 | import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm' 3 | import { Item } from '../Item/Item' 4 | 5 | @Entity({ name: 'revisions' }) 6 | export class Revision { 7 | @PrimaryGeneratedColumn('uuid') 8 | declare uuid: string 9 | 10 | @ManyToOne( 11 | /* istanbul ignore next */ 12 | () => Item, 13 | /* istanbul ignore next */ 14 | (item) => item.revisions, 15 | { onDelete: 'CASCADE' }, 16 | ) 17 | @JoinColumn({ name: 'item_uuid', referencedColumnName: 'uuid' }) 18 | declare item: Promise 19 | 20 | @Column({ 21 | type: 'mediumtext', 22 | nullable: true, 23 | }) 24 | declare content: string | null 25 | 26 | @Column({ 27 | name: 'content_type', 28 | type: 'varchar', 29 | length: 255, 30 | nullable: true, 31 | }) 32 | declare contentType: ContentType | null 33 | 34 | @Column({ 35 | type: 'varchar', 36 | name: 'items_key_id', 37 | length: 255, 38 | nullable: true, 39 | }) 40 | declare itemsKeyId: string | null 41 | 42 | @Column({ 43 | name: 'enc_item_key', 44 | type: 'text', 45 | nullable: true, 46 | }) 47 | declare encItemKey: string | null 48 | 49 | @Column({ 50 | name: 'auth_hash', 51 | type: 'varchar', 52 | length: 255, 53 | nullable: true, 54 | }) 55 | declare authHash: string | null 56 | 57 | @Column({ 58 | name: 'creation_date', 59 | type: 'date', 60 | nullable: true, 61 | }) 62 | @Index('index_revisions_on_creation_date') 63 | declare creationDate: Date 64 | 65 | @Column({ 66 | name: 'created_at', 67 | type: 'datetime', 68 | precision: 6, 69 | nullable: true, 70 | }) 71 | @Index('index_revisions_on_created_at') 72 | declare createdAt: Date 73 | 74 | @Column({ 75 | name: 'updated_at', 76 | type: 'datetime', 77 | precision: 6, 78 | nullable: true, 79 | }) 80 | declare updatedAt: Date 81 | } 82 | -------------------------------------------------------------------------------- /src/Domain/Revision/RevisionRepositoryInterface.ts: -------------------------------------------------------------------------------- 1 | import { Revision } from './Revision' 2 | 3 | export interface RevisionRepositoryInterface { 4 | findByItemId(parameters: { itemUuid: string; afterDate?: Date }): Promise> 5 | findOneById(itemId: string, id: string): Promise 6 | save(revision: Revision): Promise 7 | removeByUuid(itemUuid: string, revisionUuid: string): Promise 8 | } 9 | -------------------------------------------------------------------------------- /src/Domain/Revision/RevisionService.ts: -------------------------------------------------------------------------------- 1 | import { inject, injectable } from 'inversify' 2 | import { RoleName, ContentType } from '@standardnotes/common' 3 | import { TimerInterface } from '@standardnotes/time' 4 | 5 | import TYPES from '../../Bootstrap/Types' 6 | import { Item } from '../Item/Item' 7 | import { Revision } from './Revision' 8 | import { RevisionRepositoryInterface } from './RevisionRepositoryInterface' 9 | import { RevisionServiceInterface } from './RevisionServiceInterface' 10 | import { ItemRepositoryInterface } from '../Item/ItemRepositoryInterface' 11 | 12 | @injectable() 13 | export class RevisionService implements RevisionServiceInterface { 14 | constructor( 15 | @inject(TYPES.RevisionRepository) private revisionRepository: RevisionRepositoryInterface, 16 | @inject(TYPES.ItemRepository) private itemRepository: ItemRepositoryInterface, 17 | @inject(TYPES.Timer) private timer: TimerInterface, 18 | ) {} 19 | 20 | async removeRevision(dto: { userUuid: string; itemUuid: string; revisionUuid: string }): Promise { 21 | const userItem = await this.itemRepository.findByUuid(dto.itemUuid) 22 | if (userItem === null || userItem.userUuid !== dto.userUuid) { 23 | return false 24 | } 25 | 26 | await this.revisionRepository.removeByUuid(dto.itemUuid, dto.revisionUuid) 27 | 28 | return true 29 | } 30 | 31 | async getRevisions(userUuid: string, itemUuid: string): Promise { 32 | const userItem = await this.itemRepository.findByUuid(itemUuid) 33 | if (userItem === null || userItem.userUuid !== userUuid) { 34 | return [] 35 | } 36 | 37 | const revisions = await this.revisionRepository.findByItemId({ itemUuid }) 38 | 39 | return revisions 40 | } 41 | 42 | async getRevision(dto: { 43 | userUuid: string 44 | userRoles: RoleName[] 45 | itemUuid: string 46 | revisionUuid: string 47 | }): Promise { 48 | const userItem = await this.itemRepository.findByUuid(dto.itemUuid) 49 | if (userItem === null || userItem.userUuid !== dto.userUuid) { 50 | return null 51 | } 52 | 53 | const revision = await this.revisionRepository.findOneById(dto.itemUuid, dto.revisionUuid) 54 | 55 | if (revision !== null && !this.userHasEnoughPermissionsToSeeRevision(dto.userRoles, revision.createdAt)) { 56 | return null 57 | } 58 | 59 | return revision 60 | } 61 | 62 | async copyRevisions(fromItemUuid: string, toItemUuid: string): Promise { 63 | const revisions = await this.revisionRepository.findByItemId({ 64 | itemUuid: fromItemUuid, 65 | }) 66 | 67 | const toItem = await this.itemRepository.findByUuid(toItemUuid) 68 | if (toItem === null) { 69 | throw Error(`Item ${toItemUuid} does not exist`) 70 | } 71 | 72 | for (const existingRevision of revisions) { 73 | const revisionCopy = new Revision() 74 | revisionCopy.authHash = existingRevision.authHash 75 | revisionCopy.content = existingRevision.content 76 | revisionCopy.contentType = existingRevision.contentType 77 | revisionCopy.encItemKey = existingRevision.encItemKey 78 | revisionCopy.item = Promise.resolve(toItem) 79 | revisionCopy.itemsKeyId = existingRevision.itemsKeyId 80 | revisionCopy.creationDate = existingRevision.creationDate 81 | revisionCopy.createdAt = existingRevision.createdAt 82 | revisionCopy.updatedAt = existingRevision.updatedAt 83 | 84 | await this.revisionRepository.save(revisionCopy) 85 | } 86 | } 87 | 88 | async createRevision(item: Item): Promise { 89 | if (![ContentType.Note, ContentType.File].includes(item.contentType as ContentType)) { 90 | return 91 | } 92 | 93 | const now = new Date() 94 | 95 | const revision = new Revision() 96 | revision.authHash = item.authHash 97 | revision.content = item.content 98 | revision.contentType = item.contentType 99 | revision.encItemKey = item.encItemKey 100 | revision.item = Promise.resolve(item) 101 | revision.itemsKeyId = item.itemsKeyId 102 | revision.creationDate = now 103 | revision.createdAt = now 104 | revision.updatedAt = now 105 | 106 | await this.revisionRepository.save(revision) 107 | } 108 | 109 | calculateRequiredRoleBasedOnRevisionDate(createdAt: Date): RoleName { 110 | const revisionCreatedNDaysAgo = this.timer.dateWasNDaysAgo(createdAt) 111 | 112 | if (revisionCreatedNDaysAgo > 30 && revisionCreatedNDaysAgo < 365) { 113 | return RoleName.PlusUser 114 | } 115 | 116 | if (revisionCreatedNDaysAgo > 365) { 117 | return RoleName.ProUser 118 | } 119 | 120 | return RoleName.CoreUser 121 | } 122 | 123 | private userHasEnoughPermissionsToSeeRevision(userRoles: RoleName[], revisionCreatedAt: Date): boolean { 124 | const roleRequired = this.calculateRequiredRoleBasedOnRevisionDate(revisionCreatedAt) 125 | 126 | switch (roleRequired) { 127 | case RoleName.PlusUser: 128 | return userRoles.filter((userRole) => [RoleName.PlusUser, RoleName.ProUser].includes(userRole)).length > 0 129 | case RoleName.ProUser: 130 | return userRoles.includes(RoleName.ProUser) 131 | default: 132 | return true 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/Domain/Revision/RevisionServiceInterface.ts: -------------------------------------------------------------------------------- 1 | import { RoleName } from '@standardnotes/common' 2 | import { Item } from '../Item/Item' 3 | import { Revision } from './Revision' 4 | 5 | export interface RevisionServiceInterface { 6 | createRevision(item: Item): Promise 7 | copyRevisions(fromItemUuid: string, toItemUuid: string): Promise 8 | getRevisions(userUuid: string, itemUuid: string): Promise 9 | getRevision(dto: { 10 | userUuid: string 11 | userRoles: RoleName[] 12 | itemUuid: string 13 | revisionUuid: string 14 | }): Promise 15 | removeRevision(dto: { userUuid: string; itemUuid: string; revisionUuid: string }): Promise 16 | calculateRequiredRoleBasedOnRevisionDate(createdAt: Date): RoleName 17 | } 18 | -------------------------------------------------------------------------------- /src/Domain/UseCase/CheckIntegrity/CheckIntegrity.spec.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | 3 | import { StatisticsStoreInterface } from '@standardnotes/analytics' 4 | 5 | import { ItemRepositoryInterface } from '../../Item/ItemRepositoryInterface' 6 | 7 | import { CheckIntegrity } from './CheckIntegrity' 8 | import { ContentType } from '@standardnotes/common' 9 | 10 | describe('CheckIntegrity', () => { 11 | let itemRepository: ItemRepositoryInterface 12 | let statisticsStore: StatisticsStoreInterface 13 | 14 | const createUseCase = () => new CheckIntegrity(itemRepository, statisticsStore) 15 | 16 | beforeEach(() => { 17 | itemRepository = {} as jest.Mocked 18 | itemRepository.findItemsForComputingIntegrityPayloads = jest.fn().mockReturnValue([ 19 | { 20 | uuid: '1-2-3', 21 | updated_at_timestamp: 1, 22 | content_type: ContentType.Note, 23 | }, 24 | { 25 | uuid: '2-3-4', 26 | updated_at_timestamp: 2, 27 | content_type: ContentType.Note, 28 | }, 29 | { 30 | uuid: '3-4-5', 31 | updated_at_timestamp: 3, 32 | content_type: ContentType.Note, 33 | }, 34 | { 35 | uuid: '4-5-6', 36 | updated_at_timestamp: 4, 37 | content_type: ContentType.ItemsKey, 38 | }, 39 | ]) 40 | 41 | statisticsStore = {} as jest.Mocked 42 | statisticsStore.incrementOutOfSyncIncidents = jest.fn() 43 | }) 44 | 45 | it('should return an empty result if there are no integrity mismatches', async () => { 46 | expect( 47 | await createUseCase().execute({ 48 | userUuid: '1-2-3', 49 | integrityPayloads: [ 50 | { 51 | uuid: '1-2-3', 52 | updated_at_timestamp: 1, 53 | }, 54 | { 55 | uuid: '2-3-4', 56 | updated_at_timestamp: 2, 57 | }, 58 | { 59 | uuid: '3-4-5', 60 | updated_at_timestamp: 3, 61 | }, 62 | ], 63 | }), 64 | ).toEqual({ 65 | mismatches: [], 66 | }) 67 | }) 68 | 69 | it('should return a mismatch item that has a different update at timemstap', async () => { 70 | expect( 71 | await createUseCase().execute({ 72 | userUuid: '1-2-3', 73 | integrityPayloads: [ 74 | { 75 | uuid: '1-2-3', 76 | updated_at_timestamp: 1, 77 | }, 78 | { 79 | uuid: '2-3-4', 80 | updated_at_timestamp: 1, 81 | }, 82 | { 83 | uuid: '3-4-5', 84 | updated_at_timestamp: 3, 85 | }, 86 | ], 87 | }), 88 | ).toEqual({ 89 | mismatches: [ 90 | { 91 | uuid: '2-3-4', 92 | updated_at_timestamp: 2, 93 | }, 94 | ], 95 | }) 96 | 97 | expect(statisticsStore.incrementOutOfSyncIncidents).toHaveBeenCalled() 98 | }) 99 | 100 | it('should return a mismatch item that is missing on the client side', async () => { 101 | expect( 102 | await createUseCase().execute({ 103 | userUuid: '1-2-3', 104 | integrityPayloads: [ 105 | { 106 | uuid: '1-2-3', 107 | updated_at_timestamp: 1, 108 | }, 109 | { 110 | uuid: '2-3-4', 111 | updated_at_timestamp: 2, 112 | }, 113 | ], 114 | }), 115 | ).toEqual({ 116 | mismatches: [ 117 | { 118 | uuid: '3-4-5', 119 | updated_at_timestamp: 3, 120 | }, 121 | ], 122 | }) 123 | }) 124 | }) 125 | -------------------------------------------------------------------------------- /src/Domain/UseCase/CheckIntegrity/CheckIntegrity.ts: -------------------------------------------------------------------------------- 1 | import { inject, injectable } from 'inversify' 2 | import { IntegrityPayload } from '@standardnotes/payloads' 3 | import { StatisticsStoreInterface } from '@standardnotes/analytics' 4 | 5 | import TYPES from '../../../Bootstrap/Types' 6 | import { ItemRepositoryInterface } from '../../Item/ItemRepositoryInterface' 7 | import { UseCaseInterface } from '../UseCaseInterface' 8 | import { CheckIntegrityDTO } from './CheckIntegrityDTO' 9 | import { CheckIntegrityResponse } from './CheckIntegrityResponse' 10 | import { ExtendedIntegrityPayload } from '../../Item/ExtendedIntegrityPayload' 11 | import { ContentType } from '@standardnotes/common' 12 | 13 | @injectable() 14 | export class CheckIntegrity implements UseCaseInterface { 15 | constructor( 16 | @inject(TYPES.ItemRepository) private itemRepository: ItemRepositoryInterface, 17 | @inject(TYPES.StatisticsStore) private statisticsStore: StatisticsStoreInterface, 18 | ) {} 19 | 20 | async execute(dto: CheckIntegrityDTO): Promise { 21 | const serverItemIntegrityPayloads = await this.itemRepository.findItemsForComputingIntegrityPayloads(dto.userUuid) 22 | 23 | const serverItemIntegrityPayloadsMap = new Map() 24 | for (const serverItemIntegrityPayload of serverItemIntegrityPayloads) { 25 | serverItemIntegrityPayloadsMap.set(serverItemIntegrityPayload.uuid, serverItemIntegrityPayload) 26 | } 27 | 28 | const clientItemIntegrityPayloadsMap = new Map() 29 | for (const clientItemIntegrityPayload of dto.integrityPayloads) { 30 | clientItemIntegrityPayloadsMap.set( 31 | clientItemIntegrityPayload.uuid, 32 | clientItemIntegrityPayload.updated_at_timestamp, 33 | ) 34 | } 35 | 36 | const mismatches: IntegrityPayload[] = [] 37 | 38 | for (const serverItemIntegrityPayloadUuid of serverItemIntegrityPayloadsMap.keys()) { 39 | const serverItemIntegrityPayload = serverItemIntegrityPayloadsMap.get( 40 | serverItemIntegrityPayloadUuid, 41 | ) as ExtendedIntegrityPayload 42 | 43 | if (!clientItemIntegrityPayloadsMap.has(serverItemIntegrityPayloadUuid)) { 44 | if (serverItemIntegrityPayload.content_type !== ContentType.ItemsKey) { 45 | mismatches.unshift({ 46 | uuid: serverItemIntegrityPayloadUuid, 47 | updated_at_timestamp: serverItemIntegrityPayload.updated_at_timestamp, 48 | }) 49 | } 50 | 51 | continue 52 | } 53 | 54 | const serverItemIntegrityPayloadUpdatedAtTimestamp = serverItemIntegrityPayload.updated_at_timestamp 55 | const clientItemIntegrityPayloadUpdatedAtTimestamp = clientItemIntegrityPayloadsMap.get( 56 | serverItemIntegrityPayloadUuid, 57 | ) as number 58 | if ( 59 | serverItemIntegrityPayloadUpdatedAtTimestamp !== clientItemIntegrityPayloadUpdatedAtTimestamp && 60 | serverItemIntegrityPayload.content_type !== ContentType.ItemsKey 61 | ) { 62 | mismatches.unshift({ 63 | uuid: serverItemIntegrityPayloadUuid, 64 | updated_at_timestamp: serverItemIntegrityPayloadUpdatedAtTimestamp, 65 | }) 66 | } 67 | } 68 | 69 | if (mismatches.length > 0) { 70 | await this.statisticsStore.incrementOutOfSyncIncidents() 71 | } 72 | 73 | return { 74 | mismatches, 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Domain/UseCase/CheckIntegrity/CheckIntegrityDTO.ts: -------------------------------------------------------------------------------- 1 | import { Uuid } from '@standardnotes/common' 2 | import { IntegrityPayload } from '@standardnotes/payloads' 3 | 4 | export type CheckIntegrityDTO = { 5 | userUuid: Uuid 6 | integrityPayloads: IntegrityPayload[] 7 | } 8 | -------------------------------------------------------------------------------- /src/Domain/UseCase/CheckIntegrity/CheckIntegrityResponse.ts: -------------------------------------------------------------------------------- 1 | import { IntegrityPayload } from '@standardnotes/payloads' 2 | 3 | export type CheckIntegrityResponse = { 4 | mismatches: IntegrityPayload[] 5 | } 6 | -------------------------------------------------------------------------------- /src/Domain/UseCase/GetItem/GetItem.spec.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | import { Item } from '../../Item/Item' 3 | import { ItemRepositoryInterface } from '../../Item/ItemRepositoryInterface' 4 | 5 | import { GetItem } from './GetItem' 6 | 7 | describe('GetItem', () => { 8 | let itemRepository: ItemRepositoryInterface 9 | 10 | const createUseCase = () => new GetItem(itemRepository) 11 | 12 | beforeEach(() => { 13 | itemRepository = {} as jest.Mocked 14 | itemRepository.findByUuidAndUserUuid = jest.fn().mockReturnValue(null) 15 | }) 16 | 17 | it('should fail if item is not found', async () => { 18 | expect( 19 | await createUseCase().execute({ 20 | userUuid: '1-2-3', 21 | itemUuid: '2-3-4', 22 | }), 23 | ).toEqual({ success: false, message: 'Could not find item with uuid 2-3-4' }) 24 | }) 25 | 26 | it('should succeed if item is found', async () => { 27 | const item = {} as jest.Mocked 28 | itemRepository.findByUuidAndUserUuid = jest.fn().mockReturnValue(item) 29 | 30 | expect( 31 | await createUseCase().execute({ 32 | userUuid: '1-2-3', 33 | itemUuid: '2-3-4', 34 | }), 35 | ).toEqual({ success: true, item }) 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /src/Domain/UseCase/GetItem/GetItem.ts: -------------------------------------------------------------------------------- 1 | import { inject, injectable } from 'inversify' 2 | import TYPES from '../../../Bootstrap/Types' 3 | import { ItemRepositoryInterface } from '../../Item/ItemRepositoryInterface' 4 | import { UseCaseInterface } from '../UseCaseInterface' 5 | import { GetItemDTO } from './GetItemDTO' 6 | import { GetItemResponse } from './GetItemResponse' 7 | 8 | @injectable() 9 | export class GetItem implements UseCaseInterface { 10 | constructor(@inject(TYPES.ItemRepository) private itemRepository: ItemRepositoryInterface) {} 11 | 12 | async execute(dto: GetItemDTO): Promise { 13 | const item = await this.itemRepository.findByUuidAndUserUuid(dto.itemUuid, dto.userUuid) 14 | 15 | if (item === null) { 16 | return { 17 | success: false, 18 | message: `Could not find item with uuid ${dto.itemUuid}`, 19 | } 20 | } 21 | 22 | return { 23 | success: true, 24 | item, 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Domain/UseCase/GetItem/GetItemDTO.ts: -------------------------------------------------------------------------------- 1 | import { Uuid } from '@standardnotes/common' 2 | 3 | export type GetItemDTO = { 4 | userUuid: Uuid 5 | itemUuid: Uuid 6 | } 7 | -------------------------------------------------------------------------------- /src/Domain/UseCase/GetItem/GetItemResponse.ts: -------------------------------------------------------------------------------- 1 | import { Item } from '../../Item/Item' 2 | 3 | export type GetItemResponse = 4 | | { 5 | success: true 6 | item: Item 7 | } 8 | | { 9 | success: false 10 | message: string 11 | } 12 | -------------------------------------------------------------------------------- /src/Domain/UseCase/SyncItems.ts: -------------------------------------------------------------------------------- 1 | import { AnalyticsActivity, AnalyticsStoreInterface, Period } from '@standardnotes/analytics' 2 | import { inject, injectable } from 'inversify' 3 | import TYPES from '../../Bootstrap/Types' 4 | import { Item } from '../Item/Item' 5 | import { ItemConflict } from '../Item/ItemConflict' 6 | import { ItemServiceInterface } from '../Item/ItemServiceInterface' 7 | import { SyncItemsDTO } from './SyncItemsDTO' 8 | import { SyncItemsResponse } from './SyncItemsResponse' 9 | import { UseCaseInterface } from './UseCaseInterface' 10 | 11 | @injectable() 12 | export class SyncItems implements UseCaseInterface { 13 | constructor( 14 | @inject(TYPES.ItemService) private itemService: ItemServiceInterface, 15 | @inject(TYPES.AnalyticsStore) private analyticsStore: AnalyticsStoreInterface, 16 | ) {} 17 | 18 | async execute(dto: SyncItemsDTO): Promise { 19 | const getItemsResult = await this.itemService.getItems({ 20 | userUuid: dto.userUuid, 21 | syncToken: dto.syncToken, 22 | cursorToken: dto.cursorToken, 23 | limit: dto.limit, 24 | contentType: dto.contentType, 25 | }) 26 | 27 | const saveItemsResult = await this.itemService.saveItems({ 28 | itemHashes: dto.itemHashes, 29 | userUuid: dto.userUuid, 30 | apiVersion: dto.apiVersion, 31 | readOnlyAccess: dto.readOnlyAccess, 32 | sessionUuid: dto.sessionUuid, 33 | }) 34 | 35 | let retrievedItems = this.filterOutSyncConflictsForConsecutiveSyncs(getItemsResult.items, saveItemsResult.conflicts) 36 | if (this.isFirstSync(dto)) { 37 | retrievedItems = await this.itemService.frontLoadKeysItemsToTop(dto.userUuid, retrievedItems) 38 | } 39 | 40 | if (dto.analyticsId && saveItemsResult.savedItems.length > 0) { 41 | await this.analyticsStore.markActivity([AnalyticsActivity.EditingItems], dto.analyticsId, [ 42 | Period.Today, 43 | Period.ThisWeek, 44 | Period.ThisMonth, 45 | ]) 46 | 47 | await this.analyticsStore.markActivity([AnalyticsActivity.EmailUnbackedUpData], dto.analyticsId, [ 48 | Period.Today, 49 | Period.ThisWeek, 50 | ]) 51 | } 52 | 53 | const syncResponse: SyncItemsResponse = { 54 | retrievedItems, 55 | syncToken: saveItemsResult.syncToken, 56 | savedItems: saveItemsResult.savedItems, 57 | conflicts: saveItemsResult.conflicts, 58 | cursorToken: getItemsResult.cursorToken, 59 | } 60 | 61 | return syncResponse 62 | } 63 | 64 | private isFirstSync(dto: SyncItemsDTO): boolean { 65 | return dto.syncToken === undefined || dto.syncToken === null 66 | } 67 | 68 | private filterOutSyncConflictsForConsecutiveSyncs( 69 | retrievedItems: Array, 70 | conflicts: Array, 71 | ): Array { 72 | const syncConflictIds: Array = [] 73 | conflicts.forEach((conflict: ItemConflict) => { 74 | if (conflict.type === 'sync_conflict' && conflict.serverItem) { 75 | syncConflictIds.push(conflict.serverItem.uuid) 76 | } 77 | }) 78 | 79 | return retrievedItems.filter((item: Item) => syncConflictIds.indexOf(item.uuid) === -1) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Domain/UseCase/SyncItemsDTO.ts: -------------------------------------------------------------------------------- 1 | import { Uuid } from '@standardnotes/common' 2 | import { ItemHash } from '../Item/ItemHash' 3 | 4 | export type SyncItemsDTO = { 5 | userUuid: string 6 | itemHashes: Array 7 | computeIntegrityHash: boolean 8 | limit: number 9 | syncToken?: string | null 10 | cursorToken?: string | null 11 | contentType?: string 12 | analyticsId?: number 13 | apiVersion: string 14 | readOnlyAccess: boolean 15 | sessionUuid: Uuid | null 16 | } 17 | -------------------------------------------------------------------------------- /src/Domain/UseCase/SyncItemsResponse.ts: -------------------------------------------------------------------------------- 1 | import { Item } from '../Item/Item' 2 | import { ItemConflict } from '../Item/ItemConflict' 3 | 4 | export type SyncItemsResponse = { 5 | retrievedItems: Array 6 | savedItems: Array 7 | conflicts: Array 8 | syncToken: string 9 | cursorToken?: string 10 | } 11 | -------------------------------------------------------------------------------- /src/Domain/UseCase/UseCaseInterface.ts: -------------------------------------------------------------------------------- 1 | export interface UseCaseInterface { 2 | execute(...args: any[]): Promise> 3 | } 4 | -------------------------------------------------------------------------------- /src/Infra/HTTP/AuthHttpService.spec.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | 3 | import { AxiosInstance } from 'axios' 4 | 5 | import { AuthHttpService } from './AuthHttpService' 6 | import { SettingName } from '@standardnotes/settings' 7 | 8 | describe('AuthHttpService', () => { 9 | let httpClient: AxiosInstance 10 | 11 | const authServerUrl = 'https://auth-server' 12 | 13 | const createService = () => new AuthHttpService(httpClient, authServerUrl) 14 | 15 | beforeEach(() => { 16 | httpClient = {} as jest.Mocked 17 | httpClient.request = jest.fn().mockReturnValue({ data: { foo: 'bar' } }) 18 | }) 19 | 20 | it('should send a request to auth service in order to get user key params', async () => { 21 | await createService().getUserKeyParams({ 22 | email: 'test@test.com', 23 | authenticated: false, 24 | }) 25 | 26 | expect(httpClient.request).toHaveBeenCalledWith({ 27 | method: 'GET', 28 | headers: { 29 | Accept: 'application/json', 30 | }, 31 | url: 'https://auth-server/users/params', 32 | params: { 33 | authenticated: false, 34 | email: 'test@test.com', 35 | }, 36 | validateStatus: expect.any(Function), 37 | }) 38 | }) 39 | 40 | it('should send a request to auth service in order to get user setting', async () => { 41 | httpClient.request = jest.fn().mockReturnValue({ 42 | data: { 43 | setting: [ 44 | { 45 | uuid: '1-2-3', 46 | value: 'yes', 47 | }, 48 | ], 49 | }, 50 | }) 51 | 52 | await createService().getUserSetting('1-2-3', SettingName.MuteFailedBackupsEmails) 53 | 54 | expect(httpClient.request).toHaveBeenCalledWith({ 55 | method: 'GET', 56 | headers: { 57 | Accept: 'application/json', 58 | }, 59 | url: 'https://auth-server/internal/users/1-2-3/settings/MUTE_FAILED_BACKUPS_EMAILS', 60 | validateStatus: expect.any(Function), 61 | }) 62 | }) 63 | 64 | it('should throw an error if a request to auth service in order to get user setting fails', async () => { 65 | let error = null 66 | try { 67 | await createService().getUserSetting('1-2-3', SettingName.MuteFailedCloudBackupsEmails) 68 | } catch (caughtError) { 69 | error = caughtError 70 | } 71 | 72 | expect(error).not.toBeNull() 73 | }) 74 | }) 75 | -------------------------------------------------------------------------------- /src/Infra/HTTP/AuthHttpService.ts: -------------------------------------------------------------------------------- 1 | import { KeyParamsData } from '@standardnotes/responses' 2 | import { AxiosInstance } from 'axios' 3 | import { inject, injectable } from 'inversify' 4 | import TYPES from '../../Bootstrap/Types' 5 | import { AuthHttpServiceInterface } from '../../Domain/Auth/AuthHttpServiceInterface' 6 | 7 | @injectable() 8 | export class AuthHttpService implements AuthHttpServiceInterface { 9 | constructor( 10 | @inject(TYPES.HTTPClient) private httpClient: AxiosInstance, 11 | @inject(TYPES.AUTH_SERVER_URL) private authServerUrl: string, 12 | ) {} 13 | 14 | async getUserSetting(userUuid: string, settingName: string): Promise<{ uuid: string; value: string | null }> { 15 | const response = await this.httpClient.request({ 16 | method: 'GET', 17 | headers: { 18 | Accept: 'application/json', 19 | }, 20 | url: `${this.authServerUrl}/internal/users/${userUuid}/settings/${settingName}`, 21 | validateStatus: 22 | /* istanbul ignore next */ 23 | (status: number) => status >= 200 && status < 500, 24 | }) 25 | 26 | if (!response.data.setting) { 27 | throw new Error('Missing user setting from auth service response') 28 | } 29 | 30 | return response.data.setting 31 | } 32 | 33 | async getUserKeyParams(dto: { email?: string; uuid?: string; authenticated: boolean }): Promise { 34 | const keyParamsResponse = await this.httpClient.request({ 35 | method: 'GET', 36 | headers: { 37 | Accept: 'application/json', 38 | }, 39 | url: `${this.authServerUrl}/users/params`, 40 | params: dto, 41 | validateStatus: 42 | /* istanbul ignore next */ 43 | (status: number) => status >= 200 && status < 500, 44 | }) 45 | 46 | return keyParamsResponse.data 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Infra/MySQL/MySQLRevisionRepository.spec.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | 3 | import { Repository, SelectQueryBuilder } from 'typeorm' 4 | import { Revision } from '../../Domain/Revision/Revision' 5 | import { MySQLRevisionRepository } from './MySQLRevisionRepository' 6 | 7 | describe('MySQLRevisionRepository', () => { 8 | let ormRepository: Repository 9 | let queryBuilder: SelectQueryBuilder 10 | let revision: Revision 11 | 12 | const createRepository = () => new MySQLRevisionRepository(ormRepository) 13 | 14 | beforeEach(() => { 15 | queryBuilder = {} as jest.Mocked> 16 | 17 | revision = {} as jest.Mocked 18 | 19 | ormRepository = {} as jest.Mocked> 20 | ormRepository.save = jest.fn() 21 | ormRepository.createQueryBuilder = jest.fn().mockImplementation(() => queryBuilder) 22 | }) 23 | 24 | it('should save', async () => { 25 | await createRepository().save(revision) 26 | 27 | expect(ormRepository.save).toHaveBeenCalledWith(revision) 28 | }) 29 | 30 | it('should delete a revision for an item', async () => { 31 | queryBuilder.where = jest.fn().mockReturnThis() 32 | queryBuilder.delete = jest.fn().mockReturnThis() 33 | queryBuilder.from = jest.fn().mockReturnThis() 34 | queryBuilder.execute = jest.fn() 35 | 36 | await createRepository().removeByUuid('1-2-3', '3-4-5') 37 | 38 | expect(queryBuilder.delete).toHaveBeenCalled() 39 | 40 | expect(queryBuilder.from).toHaveBeenCalledWith('revisions') 41 | expect(queryBuilder.where).toHaveBeenCalledWith('uuid = :revisionUuid AND item_uuid = :itemUuid', { 42 | itemUuid: '1-2-3', 43 | revisionUuid: '3-4-5', 44 | }) 45 | 46 | expect(queryBuilder.execute).toHaveBeenCalled() 47 | }) 48 | 49 | it('should find revisions by item id', async () => { 50 | queryBuilder.where = jest.fn().mockReturnThis() 51 | queryBuilder.orderBy = jest.fn().mockReturnThis() 52 | queryBuilder.getMany = jest.fn().mockReturnValue([revision]) 53 | 54 | const result = await createRepository().findByItemId({ itemUuid: '123' }) 55 | 56 | expect(queryBuilder.where).toHaveBeenCalledWith('revision.item_uuid = :item_uuid', { item_uuid: '123' }) 57 | expect(queryBuilder.orderBy).toHaveBeenCalledWith('revision.created_at', 'DESC') 58 | expect(result).toEqual([revision]) 59 | }) 60 | 61 | it('should find revisions by item id after certain date', async () => { 62 | queryBuilder.where = jest.fn().mockReturnThis() 63 | queryBuilder.andWhere = jest.fn().mockReturnThis() 64 | queryBuilder.orderBy = jest.fn().mockReturnThis() 65 | queryBuilder.getMany = jest.fn().mockReturnValue([revision]) 66 | 67 | const result = await createRepository().findByItemId({ itemUuid: '123', afterDate: new Date(2) }) 68 | 69 | expect(queryBuilder.where).toHaveBeenCalledWith('revision.item_uuid = :item_uuid', { item_uuid: '123' }) 70 | expect(queryBuilder.andWhere).toHaveBeenCalledWith('revision.creation_date >= :after_date', { 71 | after_date: new Date(2), 72 | }) 73 | expect(queryBuilder.orderBy).toHaveBeenCalledWith('revision.created_at', 'DESC') 74 | expect(result).toEqual([revision]) 75 | }) 76 | 77 | it('should find one revision by id and item id', async () => { 78 | queryBuilder.where = jest.fn().mockReturnThis() 79 | queryBuilder.getOne = jest.fn().mockReturnValue(revision) 80 | 81 | const result = await createRepository().findOneById('123', '234') 82 | 83 | expect(queryBuilder.where).toHaveBeenCalledWith('revision.uuid = :uuid AND revision.item_uuid = :item_uuid', { 84 | uuid: '234', 85 | item_uuid: '123', 86 | }) 87 | expect(result).toEqual(revision) 88 | }) 89 | }) 90 | -------------------------------------------------------------------------------- /src/Infra/MySQL/MySQLRevisionRepository.ts: -------------------------------------------------------------------------------- 1 | import { inject, injectable } from 'inversify' 2 | import { Repository } from 'typeorm' 3 | import TYPES from '../../Bootstrap/Types' 4 | import { Revision } from '../../Domain/Revision/Revision' 5 | import { RevisionRepositoryInterface } from '../../Domain/Revision/RevisionRepositoryInterface' 6 | 7 | @injectable() 8 | export class MySQLRevisionRepository implements RevisionRepositoryInterface { 9 | constructor( 10 | @inject(TYPES.ORMRevisionRepository) 11 | private ormRepository: Repository, 12 | ) {} 13 | 14 | async save(revision: Revision): Promise { 15 | return this.ormRepository.save(revision) 16 | } 17 | 18 | async removeByUuid(itemUuid: string, revisionUuid: string): Promise { 19 | await this.ormRepository 20 | .createQueryBuilder('revision') 21 | .delete() 22 | .from('revisions') 23 | .where('uuid = :revisionUuid AND item_uuid = :itemUuid', { itemUuid, revisionUuid }) 24 | .execute() 25 | } 26 | 27 | async findByItemId(parameters: { itemUuid: string; afterDate?: Date }): Promise> { 28 | const queryBuilder = this.ormRepository.createQueryBuilder('revision').where('revision.item_uuid = :item_uuid', { 29 | item_uuid: parameters.itemUuid, 30 | }) 31 | 32 | if (parameters.afterDate !== undefined) { 33 | queryBuilder.andWhere('revision.creation_date >= :after_date', { after_date: parameters.afterDate }) 34 | } 35 | 36 | return queryBuilder.orderBy('revision.created_at', 'DESC').getMany() 37 | } 38 | 39 | async findOneById(itemId: string, id: string): Promise { 40 | return this.ormRepository 41 | .createQueryBuilder('revision') 42 | .where('revision.uuid = :uuid AND revision.item_uuid = :item_uuid', { uuid: id, item_uuid: itemId }) 43 | .getOne() 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Infra/S3/S3ItemBackupService.spec.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | 3 | import { KeyParamsData } from '@standardnotes/responses' 4 | import { S3 } from 'aws-sdk' 5 | import { Logger } from 'winston' 6 | import { Item } from '../../Domain/Item/Item' 7 | import { S3ItemBackupService } from './S3ItemBackupService' 8 | import { ProjectorInterface } from '../../Projection/ProjectorInterface' 9 | import { ItemProjection } from '../../Projection/ItemProjection' 10 | 11 | describe('S3ItemBackupService', () => { 12 | let s3Client: S3 | undefined 13 | let itemProjector: ProjectorInterface 14 | let s3BackupBucketName = 'backup-bucket' 15 | let logger: Logger 16 | let item: Item 17 | let keyParams: KeyParamsData 18 | 19 | const createService = () => new S3ItemBackupService(s3BackupBucketName, itemProjector, logger, s3Client) 20 | 21 | beforeEach(() => { 22 | s3Client = {} as jest.Mocked 23 | s3Client.upload = jest.fn().mockReturnValue({ 24 | promise: jest.fn().mockReturnValue(Promise.resolve({ Key: 'test' })), 25 | }) 26 | 27 | logger = {} as jest.Mocked 28 | logger.warn = jest.fn() 29 | 30 | item = {} as jest.Mocked 31 | 32 | keyParams = {} as jest.Mocked 33 | 34 | itemProjector = {} as jest.Mocked> 35 | itemProjector.projectFull = jest.fn().mockReturnValue({ foo: 'bar' }) 36 | }) 37 | 38 | it('should upload items to S3 as a backup file', async () => { 39 | await createService().backup([item], keyParams) 40 | 41 | expect((s3Client).upload).toHaveBeenCalledWith({ 42 | Body: '{"items":[{"foo":"bar"}],"auth_params":{}}', 43 | Bucket: 'backup-bucket', 44 | Key: expect.any(String), 45 | }) 46 | }) 47 | 48 | it('should not upload items to S3 if bucket name is not configured', async () => { 49 | s3BackupBucketName = '' 50 | await createService().backup([item], keyParams) 51 | 52 | expect((s3Client).upload).not.toHaveBeenCalled() 53 | }) 54 | 55 | it('should not upload items to S3 if S3 client is not configured', async () => { 56 | s3Client = undefined 57 | expect(await createService().backup([item], keyParams)).toEqual('') 58 | }) 59 | }) 60 | -------------------------------------------------------------------------------- /src/Infra/S3/S3ItemBackupService.ts: -------------------------------------------------------------------------------- 1 | import * as uuid from 'uuid' 2 | import { KeyParamsData } from '@standardnotes/responses' 3 | import { S3 } from 'aws-sdk' 4 | import { inject, injectable } from 'inversify' 5 | import { Logger } from 'winston' 6 | import TYPES from '../../Bootstrap/Types' 7 | import { Item } from '../../Domain/Item/Item' 8 | import { ItemBackupServiceInterface } from '../../Domain/Item/ItemBackupServiceInterface' 9 | import { ProjectorInterface } from '../../Projection/ProjectorInterface' 10 | import { ItemProjection } from '../../Projection/ItemProjection' 11 | 12 | @injectable() 13 | export class S3ItemBackupService implements ItemBackupServiceInterface { 14 | constructor( 15 | @inject(TYPES.S3_BACKUP_BUCKET_NAME) private s3BackupBucketName: string, 16 | @inject(TYPES.ItemProjector) private itemProjector: ProjectorInterface, 17 | @inject(TYPES.Logger) private logger: Logger, 18 | @inject(TYPES.S3) private s3Client?: S3, 19 | ) {} 20 | 21 | async backup(items: Item[], authParams: KeyParamsData): Promise { 22 | if (!this.s3BackupBucketName || this.s3Client === undefined) { 23 | this.logger.warn('S3 backup not configured') 24 | 25 | return '' 26 | } 27 | 28 | const fileName = uuid.v4() 29 | 30 | const itemProjections = [] 31 | for (const item of items) { 32 | itemProjections.push(await this.itemProjector.projectFull(item)) 33 | } 34 | 35 | const uploadResult = await this.s3Client 36 | .upload({ 37 | Bucket: this.s3BackupBucketName, 38 | Key: fileName, 39 | Body: JSON.stringify({ 40 | items: itemProjections, 41 | auth_params: authParams, 42 | }), 43 | }) 44 | .promise() 45 | 46 | return uploadResult.Key 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Projection/ItemConflictProjection.ts: -------------------------------------------------------------------------------- 1 | import { ConflictType } from '@standardnotes/responses' 2 | 3 | import { ItemHash } from '../Domain/Item/ItemHash' 4 | import { ItemProjection } from './ItemProjection' 5 | 6 | export type ItemConflictProjection = { 7 | server_item?: ItemProjection 8 | unsaved_item?: ItemHash 9 | type: ConflictType 10 | } 11 | -------------------------------------------------------------------------------- /src/Projection/ItemConflictProjector.spec.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | 3 | import { ProjectorInterface } from './ProjectorInterface' 4 | import { Item } from '../Domain/Item/Item' 5 | import { ItemConflict } from '../Domain/Item/ItemConflict' 6 | import { ItemConflictProjector } from './ItemConflictProjector' 7 | import { ItemHash } from '../Domain/Item/ItemHash' 8 | import { ItemProjection } from './ItemProjection' 9 | import { ConflictType } from '@standardnotes/responses' 10 | 11 | describe('ItemConflictProjector', () => { 12 | let itemProjector: ProjectorInterface 13 | let itemProjection: ItemProjection 14 | let itemConflict1: ItemConflict 15 | let itemConflict2: ItemConflict 16 | let item: Item 17 | let itemHash: ItemHash 18 | 19 | const createProjector = () => new ItemConflictProjector(itemProjector) 20 | 21 | beforeEach(() => { 22 | itemProjection = {} as jest.Mocked 23 | 24 | itemProjector = {} as jest.Mocked> 25 | itemProjector.projectFull = jest.fn().mockReturnValue(itemProjection) 26 | 27 | item = {} as jest.Mocked 28 | 29 | itemHash = {} as jest.Mocked 30 | 31 | itemConflict1 = { 32 | serverItem: item, 33 | type: ConflictType.ConflictingData, 34 | } 35 | 36 | itemConflict2 = { 37 | unsavedItem: itemHash, 38 | type: ConflictType.UuidConflict, 39 | } 40 | }) 41 | 42 | it('should create a full projection of a server item conflict', async () => { 43 | expect(await createProjector().projectFull(itemConflict1)).toMatchObject({ 44 | server_item: itemProjection, 45 | type: ConflictType.ConflictingData, 46 | }) 47 | }) 48 | 49 | it('should create a full projection of an unsaved item conflict', async () => { 50 | expect(await createProjector().projectFull(itemConflict2)).toMatchObject({ 51 | unsaved_item: itemHash, 52 | type: 'uuid_conflict', 53 | }) 54 | }) 55 | 56 | it('should throw error on custom projection', async () => { 57 | let error = null 58 | try { 59 | await createProjector().projectCustom('test', itemConflict1) 60 | } catch (e) { 61 | error = e 62 | } 63 | expect((error as Error).message).toEqual('not implemented') 64 | }) 65 | 66 | it('should throw error on simple projection', async () => { 67 | let error = null 68 | try { 69 | await createProjector().projectSimple(itemConflict1) 70 | } catch (e) { 71 | error = e 72 | } 73 | expect((error as Error).message).toEqual('not implemented') 74 | }) 75 | }) 76 | -------------------------------------------------------------------------------- /src/Projection/ItemConflictProjector.ts: -------------------------------------------------------------------------------- 1 | import { inject, injectable } from 'inversify' 2 | import TYPES from '../Bootstrap/Types' 3 | import { ProjectorInterface } from './ProjectorInterface' 4 | 5 | import { Item } from '../Domain/Item/Item' 6 | import { ItemConflict } from '../Domain/Item/ItemConflict' 7 | import { ItemConflictProjection } from './ItemConflictProjection' 8 | import { ItemProjection } from './ItemProjection' 9 | 10 | @injectable() 11 | export class ItemConflictProjector implements ProjectorInterface { 12 | constructor(@inject(TYPES.ItemProjector) private itemProjector: ProjectorInterface) {} 13 | 14 | async projectSimple(_itemConflict: ItemConflict): Promise { 15 | throw Error('not implemented') 16 | } 17 | 18 | async projectCustom(_projectionType: string, _itemConflict: ItemConflict): Promise { 19 | throw Error('not implemented') 20 | } 21 | 22 | async projectFull(itemConflict: ItemConflict): Promise { 23 | const projection: ItemConflictProjection = { 24 | unsaved_item: itemConflict.unsavedItem, 25 | type: itemConflict.type, 26 | } 27 | 28 | if (itemConflict.serverItem) { 29 | projection.server_item = await this.itemProjector.projectFull(itemConflict.serverItem) 30 | } 31 | 32 | return projection 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Projection/ItemProjection.ts: -------------------------------------------------------------------------------- 1 | import { Uuid } from '@standardnotes/common' 2 | 3 | export type ItemProjection = { 4 | uuid: string 5 | items_key_id: string | null 6 | duplicate_of: string | null 7 | enc_item_key: string | null 8 | content: string | null 9 | content_type: string 10 | auth_hash: string | null 11 | deleted: boolean 12 | created_at: string 13 | created_at_timestamp: number 14 | updated_at: string 15 | updated_at_timestamp: number 16 | updated_with_session: Uuid | null 17 | } 18 | -------------------------------------------------------------------------------- /src/Projection/ItemProjector.spec.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | import { TimerInterface } from '@standardnotes/time' 3 | 4 | import { Item } from '../Domain/Item/Item' 5 | import { ItemProjector } from './ItemProjector' 6 | import { ContentType } from '@standardnotes/common' 7 | 8 | describe('ItemProjector', () => { 9 | let item: Item 10 | let timer: TimerInterface 11 | 12 | const createProjector = () => new ItemProjector(timer) 13 | 14 | beforeEach(() => { 15 | timer = {} as jest.Mocked 16 | timer.convertMicrosecondsToStringDate = jest.fn().mockReturnValue('2021-04-15T08:00:00.123456Z') 17 | 18 | item = new Item() 19 | item.uuid = '1-2-3' 20 | item.itemsKeyId = '2-3-4' 21 | item.duplicateOf = null 22 | item.encItemKey = '3-4-5' 23 | item.content = 'test' 24 | item.contentType = ContentType.Note 25 | item.authHash = 'asd' 26 | item.deleted = false 27 | item.createdAtTimestamp = 123 28 | item.updatedAtTimestamp = 123 29 | item.updatedWithSession = '7-6-5' 30 | }) 31 | 32 | it('should create a full projection of an item', async () => { 33 | expect(await createProjector().projectFull(item)).toMatchObject({ 34 | uuid: '1-2-3', 35 | items_key_id: '2-3-4', 36 | duplicate_of: null, 37 | enc_item_key: '3-4-5', 38 | content: 'test', 39 | content_type: 'Note', 40 | auth_hash: 'asd', 41 | deleted: false, 42 | created_at: '2021-04-15T08:00:00.123456Z', 43 | updated_at: '2021-04-15T08:00:00.123456Z', 44 | updated_with_session: '7-6-5', 45 | }) 46 | }) 47 | 48 | it('should throw error on custom projection', async () => { 49 | let error = null 50 | try { 51 | await createProjector().projectCustom('test', item) 52 | } catch (e) { 53 | error = e 54 | } 55 | expect((error as Error).message).toEqual('not implemented') 56 | }) 57 | 58 | it('should throw error on simple projection', async () => { 59 | let error = null 60 | try { 61 | await createProjector().projectSimple(item) 62 | } catch (e) { 63 | error = e 64 | } 65 | expect((error as Error).message).toEqual('not implemented') 66 | }) 67 | }) 68 | -------------------------------------------------------------------------------- /src/Projection/ItemProjector.ts: -------------------------------------------------------------------------------- 1 | import { TimerInterface } from '@standardnotes/time' 2 | import { inject, injectable } from 'inversify' 3 | import TYPES from '../Bootstrap/Types' 4 | import { ProjectorInterface } from './ProjectorInterface' 5 | 6 | import { Item } from '../Domain/Item/Item' 7 | import { ItemProjection } from './ItemProjection' 8 | 9 | @injectable() 10 | export class ItemProjector implements ProjectorInterface { 11 | constructor(@inject(TYPES.Timer) private timer: TimerInterface) {} 12 | 13 | async projectSimple(_item: Item): Promise { 14 | throw Error('not implemented') 15 | } 16 | 17 | async projectCustom(_projectionType: string, _item: Item): Promise { 18 | throw Error('not implemented') 19 | } 20 | 21 | async projectFull(item: Item): Promise { 22 | return { 23 | uuid: item.uuid, 24 | items_key_id: item.itemsKeyId, 25 | duplicate_of: item.duplicateOf, 26 | enc_item_key: item.encItemKey, 27 | content: item.content, 28 | content_type: item.contentType as string, 29 | auth_hash: item.authHash, 30 | deleted: !!item.deleted, 31 | created_at: this.timer.convertMicrosecondsToStringDate(item.createdAtTimestamp), 32 | created_at_timestamp: item.createdAtTimestamp, 33 | updated_at: this.timer.convertMicrosecondsToStringDate(item.updatedAtTimestamp), 34 | updated_at_timestamp: item.updatedAtTimestamp, 35 | updated_with_session: item.updatedWithSession, 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Projection/ProjectorInterface.ts: -------------------------------------------------------------------------------- 1 | export interface ProjectorInterface { 2 | projectSimple(object: T): Promise> 3 | projectFull(object: T): Promise 4 | projectCustom(projectionType: string, object: T, ...args: any[]): Promise 5 | } 6 | -------------------------------------------------------------------------------- /src/Projection/RevisionProjection.ts: -------------------------------------------------------------------------------- 1 | import { ContentType, RoleName } from '@standardnotes/common' 2 | 3 | export type RevisionProjection = { 4 | uuid: string 5 | item_uuid: string 6 | content: string | null 7 | content_type: ContentType | null 8 | items_key_id: string | null 9 | enc_item_key: string | null 10 | auth_hash: string | null 11 | creation_date: string 12 | required_role: RoleName 13 | created_at: string 14 | updated_at: string 15 | } 16 | -------------------------------------------------------------------------------- /src/Projection/RevisionProjector.spec.ts: -------------------------------------------------------------------------------- 1 | import { ContentType, RoleName } from '@standardnotes/common' 2 | import { TimerInterface } from '@standardnotes/time' 3 | import { Item } from '../Domain/Item/Item' 4 | 5 | import { Revision } from '../Domain/Revision/Revision' 6 | import { RevisionServiceInterface } from '../Domain/Revision/RevisionServiceInterface' 7 | import { RevisionProjector } from './RevisionProjector' 8 | 9 | describe('RevisionProjector', () => { 10 | let revision: Revision 11 | let timer: TimerInterface 12 | let revisionService: RevisionServiceInterface 13 | 14 | const createProjector = () => new RevisionProjector(timer, revisionService) 15 | 16 | beforeEach(() => { 17 | revision = new Revision() 18 | revision.content = 'test' 19 | revision.contentType = ContentType.Note 20 | ;(revision.uuid = '123'), 21 | (revision.itemsKeyId = '123'), 22 | (revision.item = Promise.resolve({ uuid: '1-2-3' } as Item)) 23 | 24 | timer = {} as jest.Mocked 25 | timer.convertDateToISOString = jest.fn().mockReturnValue('2020-11-26T13:34:00.000Z') 26 | timer.formatDate = jest.fn().mockReturnValue('2020-11-26') 27 | 28 | revisionService = {} as jest.Mocked 29 | revisionService.calculateRequiredRoleBasedOnRevisionDate = jest.fn().mockReturnValue(RoleName.CoreUser) 30 | 31 | revision.creationDate = new Date(1) 32 | revision.createdAt = new Date(1) 33 | revision.updatedAt = new Date(1) 34 | }) 35 | 36 | it('should create a simple projection of a revision', async () => { 37 | const projection = await createProjector().projectSimple(revision) 38 | expect(projection).toMatchObject({ 39 | content_type: 'Note', 40 | created_at: '2020-11-26T13:34:00.000Z', 41 | updated_at: '2020-11-26T13:34:00.000Z', 42 | required_role: 'CORE_USER', 43 | uuid: '123', 44 | }) 45 | }) 46 | 47 | it('should create a full projection of a revision', async () => { 48 | const projection = await createProjector().projectFull(revision) 49 | expect(projection).toMatchObject({ 50 | auth_hash: undefined, 51 | content: 'test', 52 | content_type: 'Note', 53 | created_at: '2020-11-26T13:34:00.000Z', 54 | creation_date: '2020-11-26', 55 | enc_item_key: undefined, 56 | required_role: 'CORE_USER', 57 | item_uuid: '1-2-3', 58 | items_key_id: '123', 59 | updated_at: '2020-11-26T13:34:00.000Z', 60 | uuid: '123', 61 | }) 62 | }) 63 | 64 | it('should throw error on not implemetned custom projection', async () => { 65 | let error = null 66 | try { 67 | await createProjector().projectCustom('test', revision) 68 | } catch (e) { 69 | error = e 70 | } 71 | expect((error as Error).message).toEqual('not implemented') 72 | }) 73 | }) 74 | -------------------------------------------------------------------------------- /src/Projection/RevisionProjector.ts: -------------------------------------------------------------------------------- 1 | import { TimerInterface } from '@standardnotes/time' 2 | import { inject, injectable } from 'inversify' 3 | import TYPES from '../Bootstrap/Types' 4 | 5 | import { Revision } from '../Domain/Revision/Revision' 6 | import { RevisionServiceInterface } from '../Domain/Revision/RevisionServiceInterface' 7 | import { ProjectorInterface } from './ProjectorInterface' 8 | import { RevisionProjection } from './RevisionProjection' 9 | import { SimpleRevisionProjection } from './SimpleRevisionProjection' 10 | 11 | @injectable() 12 | export class RevisionProjector implements ProjectorInterface { 13 | constructor( 14 | @inject(TYPES.Timer) private timer: TimerInterface, 15 | @inject(TYPES.RevisionService) private revisionService: RevisionServiceInterface, 16 | ) {} 17 | 18 | async projectSimple(revision: Revision): Promise { 19 | return { 20 | uuid: revision.uuid, 21 | content_type: revision.contentType, 22 | required_role: this.revisionService.calculateRequiredRoleBasedOnRevisionDate(revision.createdAt), 23 | created_at: this.timer.convertDateToISOString(revision.createdAt), 24 | updated_at: this.timer.convertDateToISOString(revision.updatedAt), 25 | } 26 | } 27 | 28 | async projectFull(revision: Revision): Promise { 29 | return { 30 | uuid: revision.uuid, 31 | item_uuid: (await revision.item).uuid, 32 | content: revision.content, 33 | content_type: revision.contentType, 34 | items_key_id: revision.itemsKeyId, 35 | enc_item_key: revision.encItemKey, 36 | auth_hash: revision.authHash, 37 | creation_date: this.timer.formatDate(revision.creationDate, 'YYYY-MM-DD'), 38 | required_role: this.revisionService.calculateRequiredRoleBasedOnRevisionDate(revision.createdAt), 39 | created_at: this.timer.convertDateToISOString(revision.createdAt), 40 | updated_at: this.timer.convertDateToISOString(revision.updatedAt), 41 | } 42 | } 43 | 44 | async projectCustom(_projectionType: string, _revision: Revision, ..._args: any[]): Promise { 45 | throw new Error('not implemented') 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Projection/SavedItemProjection.ts: -------------------------------------------------------------------------------- 1 | export type SavedItemProjection = { 2 | uuid: string 3 | duplicate_of: string | null 4 | content_type: string 5 | auth_hash: string | null 6 | deleted: boolean 7 | created_at: string 8 | created_at_timestamp: number 9 | updated_at: string 10 | updated_at_timestamp: number 11 | } 12 | -------------------------------------------------------------------------------- /src/Projection/SavedItemProjector.spec.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | import { TimerInterface } from '@standardnotes/time' 3 | 4 | import { Item } from '../Domain/Item/Item' 5 | import { SavedItemProjector } from './SavedItemProjector' 6 | import { ContentType } from '@standardnotes/common' 7 | 8 | describe('SavedItemProjector', () => { 9 | let item: Item 10 | let timer: TimerInterface 11 | 12 | const createProjector = () => new SavedItemProjector(timer) 13 | 14 | beforeEach(() => { 15 | timer = {} as jest.Mocked 16 | timer.convertMicrosecondsToStringDate = jest.fn().mockReturnValue('2021-04-15T08:00:00.123456Z') 17 | 18 | item = new Item() 19 | item.uuid = '1-2-3' 20 | item.itemsKeyId = '2-3-4' 21 | item.duplicateOf = null 22 | item.encItemKey = '3-4-5' 23 | item.content = 'test' 24 | item.contentType = ContentType.Note 25 | item.authHash = 'asd' 26 | item.deleted = false 27 | item.createdAtTimestamp = 123 28 | item.updatedAtTimestamp = 123 29 | }) 30 | 31 | it('should create a full projection of an item', async () => { 32 | expect(await createProjector().projectFull(item)).toEqual({ 33 | uuid: '1-2-3', 34 | duplicate_of: null, 35 | content_type: 'Note', 36 | auth_hash: 'asd', 37 | deleted: false, 38 | created_at: '2021-04-15T08:00:00.123456Z', 39 | created_at_timestamp: 123, 40 | updated_at: '2021-04-15T08:00:00.123456Z', 41 | updated_at_timestamp: 123, 42 | }) 43 | }) 44 | 45 | it('should throw error on custom projection', async () => { 46 | let error = null 47 | try { 48 | await createProjector().projectCustom('test', item) 49 | } catch (e) { 50 | error = e 51 | } 52 | expect((error as Error).message).toEqual('not implemented') 53 | }) 54 | 55 | it('should throw error on simple projection', async () => { 56 | let error = null 57 | try { 58 | await createProjector().projectSimple(item) 59 | } catch (e) { 60 | error = e 61 | } 62 | expect((error as Error).message).toEqual('not implemented') 63 | }) 64 | }) 65 | -------------------------------------------------------------------------------- /src/Projection/SavedItemProjector.ts: -------------------------------------------------------------------------------- 1 | import { TimerInterface } from '@standardnotes/time' 2 | import { inject, injectable } from 'inversify' 3 | import TYPES from '../Bootstrap/Types' 4 | import { ProjectorInterface } from './ProjectorInterface' 5 | 6 | import { Item } from '../Domain/Item/Item' 7 | import { SavedItemProjection } from './SavedItemProjection' 8 | 9 | @injectable() 10 | export class SavedItemProjector implements ProjectorInterface { 11 | constructor(@inject(TYPES.Timer) private timer: TimerInterface) {} 12 | 13 | async projectSimple(_item: Item): Promise { 14 | throw Error('not implemented') 15 | } 16 | 17 | async projectCustom(_projectionType: string, _item: Item): Promise { 18 | throw Error('not implemented') 19 | } 20 | 21 | async projectFull(item: Item): Promise { 22 | return { 23 | uuid: item.uuid, 24 | duplicate_of: item.duplicateOf, 25 | content_type: item.contentType as string, 26 | auth_hash: item.authHash, 27 | deleted: !!item.deleted, 28 | created_at: this.timer.convertMicrosecondsToStringDate(item.createdAtTimestamp), 29 | created_at_timestamp: item.createdAtTimestamp, 30 | updated_at: this.timer.convertMicrosecondsToStringDate(item.updatedAtTimestamp), 31 | updated_at_timestamp: item.updatedAtTimestamp, 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Projection/SimpleRevisionProjection.ts: -------------------------------------------------------------------------------- 1 | import { ContentType, RoleName } from '@standardnotes/common' 2 | 3 | export type SimpleRevisionProjection = { 4 | uuid: string 5 | content_type: ContentType | null 6 | required_role: RoleName 7 | created_at: string 8 | updated_at: string 9 | } 10 | -------------------------------------------------------------------------------- /test-setup.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/standardnotes/syncing-server-js/8ffb21d12bda80607b6c1c1fee834b2848251bd9/test-setup.ts -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./node_modules/@standardnotes/config/src/tsconfig.json", 3 | "compilerOptions": { 4 | "target": "esnext", 5 | "outDir": "dist", 6 | }, 7 | "include": [ 8 | "src/**/*", 9 | "bin/**/*", 10 | "migrations/**/*" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /wait-for.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | host="$1" 6 | shift 7 | port="$1" 8 | shift 9 | cmd="$@" 10 | 11 | while ! nc -vz $host $port; do 12 | >&2 echo "$host:$port is unavailable yet - waiting for it to start" 13 | sleep 10 14 | done 15 | 16 | >&2 echo "$host:$port is up - executing command" 17 | exec $cmd 18 | --------------------------------------------------------------------------------