├── .dockerignore ├── .eslintignore ├── .eslintrc.json ├── .github └── workflows │ ├── get-version.js │ ├── release-bot.yml │ ├── release-prepare.sh │ ├── release-success.sh │ ├── release.yml │ └── test.yml ├── .gitignore ├── .idea ├── .name ├── codeStyles │ └── codeStyleConfig.xml ├── encodings.xml ├── inspectionProfiles │ └── Project_Default.xml ├── jsLibraryMappings.xml ├── jsLinters │ └── eslint.xml ├── misc.xml ├── modules.xml ├── vcs.xml ├── watcherTasks.xml └── webResources.xml ├── .npmignore ├── CHANGELOG.md ├── Dockerfile ├── Privacy.md ├── Privacy_en.md ├── README.md ├── Terms.md ├── Terms_en.md ├── bin ├── database ├── plugin └── user ├── docker-compose.yml ├── helpers ├── config.js ├── database.js ├── errorResponse.js ├── httpRequestHandler.js ├── importer │ ├── csv.js │ ├── index.js │ ├── mt-940.js │ └── ofx.js ├── keychain.js ├── log.js ├── plugin │ ├── index.js │ ├── instance.js │ ├── runner.js │ └── tools.js ├── repository.js ├── server.js ├── socketRequestHandler.js └── socketSession.js ├── logic ├── _.js ├── account.js ├── budget-guess.js ├── budget.js ├── category.js ├── component.js ├── document.js ├── import.js ├── index.js ├── payee.js ├── plugin-config.js ├── plugin-instance.js ├── plugin-store.js ├── plugin.js ├── portion.js ├── session.js ├── setting.js ├── summary.js ├── transaction.js ├── unit.js └── user.js ├── migrations ├── 2017-10-01_04-00_kickstart.js ├── 2019-07-28_15-02_terms.js ├── 2019-07-30_14-57_ubud.js ├── 2019-08-03_19-00_payee-set-null.js ├── 2019-09-09_11-22_keychain-unlock-key.js ├── 2019-09-20_09-59_learnings.js ├── 2019-09-21_16-27_learnings.js ├── 2020-07-30_20-59_indices.js └── 2020-08-09_12-04_recalculate-summaries.js ├── models ├── account.js ├── budget.js ├── category.js ├── document.js ├── index.js ├── learning.js ├── payee.js ├── plugin-config.js ├── plugin-instance.js ├── plugin-store.js ├── portion.js ├── session.js ├── setting.js ├── share.js ├── summary.js ├── transaction.js ├── unit.js └── user.js ├── package-lock.json ├── package.json ├── release.config.js ├── server.js └── test └── migrations.js /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .github 3 | node_modules 4 | code-visualization 5 | .gitignore 6 | .npmignore 7 | .releaserc 8 | .docker-compose.yml 9 | *.md -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | code-visualization/** 2 | coverage/** 3 | node_modules/** -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true, 4 | "es6": true, 5 | "mocha": true 6 | }, 7 | "parserOptions": { 8 | "ecmaVersion": 2017, 9 | "sourceType": "module" 10 | }, 11 | "plugins": [ 12 | "security" 13 | ], 14 | "extends": [ 15 | "eslint:recommended", 16 | "plugin:security/recommended", 17 | "plugin:node/recommended" 18 | ], 19 | "rules": { 20 | "indent": "error", 21 | "linebreak-style": "error", 22 | "no-console": "error", 23 | "no-extra-parens": [ 24 | "error", 25 | "all", 26 | { 27 | "nestedBinaryExpressions": false 28 | } 29 | ], 30 | "no-loss-of-precision": "error", 31 | "no-unreachable-loop": "error", 32 | "node/callback-return": "warn", 33 | "node/exports-style": "error", 34 | "node/global-require": "off", 35 | "node/handle-callback-err": "error", 36 | "node/no-new-require": "error", 37 | "node/no-path-concat": "error", 38 | "node/no-process-env": "error", 39 | "node/no-process-exit": "error", 40 | "node/prefer-global/buffer": "error", 41 | "node/prefer-global/console": "error", 42 | "node/prefer-global/process": "error", 43 | "node/prefer-global/text-decoder": "error", 44 | "node/prefer-global/text-encoder": "error", 45 | "node/prefer-global/url": "error", 46 | "node/prefer-global/url-search-params": "error", 47 | "node/prefer-promises/dns": "error", 48 | "node/prefer-promises/fs": "error", 49 | "quotes": [ 50 | "error", 51 | "single" 52 | ], 53 | "security/detect-object-injection": "off", 54 | "require-atomic-updates": "off", 55 | "semi": "error" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /.github/workflows/get-version.js: -------------------------------------------------------------------------------- 1 | (async () => { 2 | 3 | // https://github.com/semantic-release/semantic-release/issues/753#issuecomment-550977000 4 | const { default: semanticRelease } = await import('semantic-release'); 5 | const result = await semanticRelease({dryRun: true}); 6 | 7 | 8 | if (result) { 9 | const {writeFileSync} = require('fs'); 10 | writeFileSync('./version', result.nextRelease.version); 11 | writeFileSync('./artifact/release.json', JSON.stringify(result.nextRelease, null, ' ')); 12 | } else { 13 | process.exit(1); 14 | } 15 | })(); 16 | -------------------------------------------------------------------------------- /.github/workflows/release-bot.yml: -------------------------------------------------------------------------------- 1 | name: ReleaseBot 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: ['develop'] 7 | schedule: 8 | - cron: '25 8 * * 3' 9 | 10 | jobs: 11 | release-bot: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: ☁️ Checkout Project 15 | uses: actions/checkout@v3 16 | - name: ☁️ Checkout ReleaseBot 17 | uses: actions/checkout@v3 18 | with: 19 | repository: sebbo2002/release-bot 20 | path: ./.actions/release-bot 21 | - name: 📦 Install Dependencies 22 | run: npm ci 23 | working-directory: ./.actions/release-bot 24 | - name: 🤖 Run ReleaseBot 25 | uses: ./.actions/release-bot 26 | with: 27 | token: ${{ secrets.GITHUB_TOKEN }} 28 | -------------------------------------------------------------------------------- /.github/workflows/release-prepare.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | ASSETVERSION=$(cat ./version/release.json| jq .version -r) 4 | 5 | # CHeck if versions match 6 | if [ "$VERSION" != "$ASSETVERSION" ]; then 7 | echo "Error: Version in assets and semantic-release version mismatch!"; 8 | echo " - Asset Version: $ASSETVERSION"; 9 | echo " - semantic-release Version: $VERSION"; 10 | exit 1; 11 | fi 12 | 13 | sentry-cli releases new -p server "${VERSION}" 14 | -------------------------------------------------------------------------------- /.github/workflows/release-success.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | npx sentry-cli releases set-commits "${VERSION}" --auto 4 | npx sentry-cli releases finalize "${VERSION}" 5 | 6 | curl -X "POST" "https://beacon.ubud.club/webhooks/update-components/${NOTIFY_WEBHOOK_SECRET}" 7 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - develop 6 | - main 7 | 8 | jobs: 9 | generate-version: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-node@v4 14 | with: 15 | cache: 'npm' 16 | node-version: '20' 17 | - run: npm ci 18 | - name: set version 19 | run: | 20 | mkdir ./artifact 21 | node ./.github/workflows/get-version.js 22 | echo "$(jq ".version = \"$(cat ./version)\"" package.json )" > ./artifact/package.json 23 | echo "$(jq ".version = \"$(cat ./version)\"" package-lock.json )" > ./artifact/package-lock.json 24 | rm -f ./version 25 | env: 26 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 27 | GH_TOKEN: ${{ secrets.GH_TOKEN }} 28 | - uses: actions/upload-artifact@v4 29 | with: 30 | name: version 31 | path: ./artifact 32 | if-no-files-found: error 33 | 34 | build-containers: 35 | runs-on: ubuntu-latest 36 | needs: [generate-version] 37 | steps: 38 | - uses: actions/checkout@v4 39 | - uses: actions/download-artifact@v4 40 | with: 41 | name: version 42 | path: version 43 | - name: 🔧 Set up QEMU 44 | uses: actions/download-artifact@v4 45 | - name: 🔧 Set up Buildx 46 | id: buildx 47 | uses: docker/setup-buildx-action@master 48 | - name: 🔐 Login to GitHub Container Registry 49 | uses: docker/login-action@v3 50 | with: 51 | registry: ghcr.io 52 | username: ${{ github.repository_owner }} 53 | password: ${{ secrets.GITHUB_TOKEN }} 54 | - name: 🔐 Login to DockerHub 55 | uses: docker/login-action@v3 56 | with: 57 | username: ${{ secrets.DOCKERHUB_USERNAME }} 58 | password: ${{ secrets.DOCKERHUB_PASSWORD }} 59 | - name: ℹ️ Set Build Variables 60 | id: buildVars 61 | run: | 62 | echo "date=$(date +'%Y-%m-%d %H:%M:%S')" >> $GITHUB_OUTPUT 63 | echo "version=$(cat ./version/release.json| jq .version -r)" >> $GITHUB_OUTPUT 64 | 65 | export BRANCH=$(git rev-parse --abbrev-ref HEAD) 66 | echo "branch=${BRANCH}" >> $GITHUB_OUTPUT 67 | 68 | if [[ "${BRANCH}" == "main" ]] 69 | then 70 | echo "clientTag=latest" >> $GITHUB_OUTPUT 71 | echo "next=" >> $GITHUB_OUTPUT 72 | else 73 | echo "clientTag=next" >> $GITHUB_OUTPUT 74 | echo "next=1" >> $GITHUB_OUTPUT 75 | fi 76 | 77 | cat $GITHUB_OUTPUT 78 | mv -f ./version/package.json ./ 79 | mv -f ./version/package-lock.json ./ 80 | rm -rf ./version 81 | - name: 🪄 Build and push 82 | id: docker-build-gh 83 | uses: docker/build-push-action@v5 84 | with: 85 | context: . 86 | github-token: ${{ secrets.GITHUB_TOKEN }} 87 | builder: ${{ steps.buildx.outputs.name }} 88 | platforms: linux/amd64,linux/arm64,linux/i386 89 | pull: true 90 | push: true 91 | tags: | 92 | ghcr.io/${{ github.repository }}:${{ github.sha }} 93 | ghcr.io/${{ github.repository }}:cache-${{ hashFiles('package*.json') }} 94 | labels: | 95 | org.opencontainers.image.authors=${{ github.repository_owner }} 96 | org.opencontainers.image.created=${{ steps.buildVars.outputs.date }} 97 | org.opencontainers.image.ref.name=${{ github.ref }} 98 | org.opencontainers.image.revision=${{ github.sha }} 99 | org.opencontainers.image.source=https://github.com/${{ github.repository }} 100 | cache-from: | 101 | ghcr.io/${{ github.repository }}:cache-${{ hashFiles('package*.json') }} 102 | ghcr.io/${{ github.repository }}:next 103 | build-args: | 104 | NODE_ENV=production 105 | CLIENT_TAG=${{ steps.buildVars.outputs.clientTag }} 106 | NEXT=${{ steps.buildVars.outputs.next }} 107 | BUILD_DATE=${{ steps.buildVars.outputs.date }} 108 | VCS_REF=${{ github.sha }} 109 | VERSION=${{ steps.buildVars.outputs.version }} 110 | - name: 🔄 Push container to DockerHub 111 | id: docker-build-dh 112 | uses: docker/build-push-action@v5 113 | with: 114 | context: . 115 | github-token: ${{ secrets.GITHUB_TOKEN }} 116 | builder: ${{ steps.buildx.outputs.name }} 117 | platforms: linux/amd64,linux/arm64,linux/i386 118 | push: true 119 | tags: ubud/server:${{ github.sha }} 120 | labels: | 121 | org.opencontainers.image.authors=${{ github.repository_owner }} 122 | org.opencontainers.image.created=${{ steps.buildVars.outputs.date }} 123 | org.opencontainers.image.ref.name=${{ github.ref }} 124 | org.opencontainers.image.revision=${{ github.sha }} 125 | org.opencontainers.image.source=https://github.com/${{ github.repository }} 126 | cache-from: | 127 | ghcr.io/${{ github.repository }}:${{ github.sha }} 128 | ghcr.io/${{ github.repository }}:cache-${{ hashFiles('package*.json') }} 129 | cache-to: type=inline 130 | build-args: | 131 | NODE_ENV=production 132 | CLIENT_TAG=${{ steps.buildVars.outputs.clientTag }} 133 | NEXT=${{ steps.buildVars.outputs.next }} 134 | BUILD_DATE=${{ steps.buildVars.outputs.date }} 135 | VCS_REF=${{ github.sha }} 136 | VERSION=${{ steps.buildVars.outputs.version }} 137 | 138 | 139 | release: 140 | runs-on: ubuntu-latest 141 | needs: [build-containers] 142 | steps: 143 | - uses: actions/checkout@v4 144 | - uses: actions/setup-node@v4 145 | with: 146 | cache: 'npm' 147 | node-version: '20' 148 | - name: 🔧 Setup regclient 149 | run: | 150 | mkdir -p "$HOME/.local/bin" 151 | curl -L https://github.com/regclient/regclient/releases/latest/download/regctl-linux-amd64 > "$HOME/.local/bin/regctl" 152 | chmod +x "$HOME/.local/bin/regctl" 153 | echo "$HOME/.local/bin" >> $GITHUB_PATH 154 | - uses: actions/download-artifact@v4 155 | with: 156 | name: version 157 | path: version 158 | - name: install dependencies 159 | run: npm ci 160 | - name: install sentry cli 161 | run: npm install -g @sentry/cli 162 | - name: 🔐 Login to GitHub Container Registry 163 | uses: docker/login-action@v3 164 | with: 165 | registry: ghcr.io 166 | username: ${{ github.repository_owner }} 167 | password: ${{ secrets.GITHUB_TOKEN }} 168 | - name: 🔐 Login to DockerHub 169 | uses: docker/login-action@v3 170 | with: 171 | username: ${{ secrets.DOCKERHUB_USERNAME }} 172 | password: ${{ secrets.DOCKERHUB_PASSWORD }} 173 | - name: semantic-release 174 | run: BRANCH=${GITHUB_REF#refs/heads/} npx semantic-release 175 | env: 176 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 177 | SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} 178 | SENTRY_ORG: ubud 179 | SENTRY_PROJECT: server 180 | SENTRY_URL: ${{ secrets.SENTRY_URL }} 181 | DOCKER_LOCAL_IMAGE_DH: ubud/server:${{ github.sha }} 182 | DOCKER_LOCAL_IMAGE_GH: ghcr.io/${{ github.repository }}:${{ github.sha }} 183 | NOTIFY_WEBHOOK_SECRET: ${{ secrets.NOTIFY_WEBHOOK_SECRET }} 184 | GH_TOKEN: ${{ secrets.GH_TOKEN }} 185 | MICROBADGER_WEBHOOK_SECRET: ${{ secrets.MICROBADGER_WEBHOOK_SECRET }} 186 | - name: update develop 187 | if: ${{ github.ref == 'ref/head/main' }} 188 | uses: everlytic/branch-merge@1.1.5 189 | with: 190 | github_token: ${{ secrets.GITHUB_TOKEN }} 191 | source_ref: 'main' 192 | target_branch: 'develop' 193 | commit_message_template: 'Merge branch {source_ref} into {target_branch} [skip ci]' 194 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | pull_request: 4 | types: [opened, reopened] 5 | push: 6 | branches: 7 | - feature/** 8 | - hotfix/** 9 | - depfu/** 10 | 11 | jobs: 12 | prepare: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | - name: set version 17 | run: | 18 | export OLDVERSION=$(npm view @ubud-app/server@next version) 19 | jq -M ".version=\"$OLDVERSION\"" package.json > package.new.json 20 | rm -f package.json 21 | mv package.new.json package.json 22 | - name: 🔐 Login to DockerHub 23 | uses: docker/login-action@v1 24 | with: 25 | username: ${{ secrets.DOCKERHUB_USERNAME }} 26 | password: ${{ secrets.DOCKERHUB_PASSWORD }} 27 | - name: pull dependencies 28 | run: | 29 | docker pull multiarch/qemu-user-static:register 30 | docker pull multiarch/alpine:x86_64-latest-stable 31 | docker pull ubud/server:next-x86_64-base || true 32 | docker pull ubud/server:${{ github.sha }}-test-container || true 33 | - name: register quemu user static 34 | run: docker run --rm --privileged multiarch/qemu-user-static:register --reset 35 | - name: build test docker container 36 | run: | 37 | docker build \ 38 | --target build-container \ 39 | --build-arg BASEIMAGE="multiarch/alpine:x86_64-latest-stable" \ 40 | --build-arg NODE_ENV="develop" \ 41 | --build-arg CLIENT_TAG="next" \ 42 | --build-arg NEXT="1" \ 43 | --cache-from ubud/server:${{ github.sha }}-test-container \ 44 | --cache-from ubud/server:next-x86_64-base \ 45 | -t "ubud/server:${{ github.sha }}-test-container" . 46 | - name: push 47 | run: docker push "ubud/server:${{ github.sha }}-test-container" 48 | check: 49 | runs-on: ubuntu-latest 50 | needs: [prepare] 51 | steps: 52 | - name: run checks 53 | run: docker run --rm ubud/server:${{ github.sha }}-test-container npm run check 54 | test-mysql: 55 | runs-on: ubuntu-latest 56 | needs: [prepare] 57 | steps: 58 | - name: run tests 59 | run: | 60 | docker run -d --rm --name "database" \ 61 | -e "MYSQL_ROOT_PASSWORD=**********" \ 62 | -e "MYSQL_USER=ubud" \ 63 | -e "MYSQL_PASSWORD=**********" \ 64 | -e "MYSQL_DATABASE=ubud" \ 65 | mariadb:latest 66 | sleep 10 67 | docker run --rm --link database \ 68 | -e "DATABASE=mysql://ubud:**********@database/ubud" \ 69 | ubud/server:${{ github.sha }}-test-container npm run test 70 | docker stop database 71 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /code-visualization 3 | -------------------------------------------------------------------------------- /.idea/.name: -------------------------------------------------------------------------------- 1 | ubud-server -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/encodings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | -------------------------------------------------------------------------------- /.idea/jsLibraryMappings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/jsLinters/eslint.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/watcherTasks.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.idea/webResources.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /code-visualization 3 | /.idea 4 | /.github -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [0.6.1](https://github.com/ubud-app/server/compare/v0.6.0...v0.6.1) (2021-07-31) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * **TransactionLogic:** Don't remove lost transaction for single items ([d32d6ec](https://github.com/ubud-app/server/commit/d32d6ecc425adbb55fdcff1b267f4482e73f1337)) 7 | * **TransactionLogic:** Fix auto-budget ([66d0f9b](https://github.com/ubud-app/server/commit/66d0f9bbca6336adfb799dc238ecd5e7f7b2dd2b)) 8 | * **CSVImporter:** Fix importing wrong date format ([f0a93b1](https://github.com/ubud-app/server/commit/f0a93b12a067f1f269a7de5445005783c33d1df9)) 9 | * **CSVImporter:** Fix importing wrong date format ([933ac78](https://github.com/ubud-app/server/commit/933ac78a963d0e55ca9c30765fb25ee10ff07493)) 10 | * **CSV Import:** Handle column "Wertstellung" with DD.MM.YYYY values ([f72853b](https://github.com/ubud-app/server/commit/f72853b6c5af3d2a34f211fd4af86538514b998f)) 11 | * **Dockerfile:** replace apk from "nodejs-npm" to "npm" ([a5e4809](https://github.com/ubud-app/server/commit/a5e4809354edb8cec1aa37058897461962e5b64d)) 12 | * **Importer:** Trim long memos during import ([c919e93](https://github.com/ubud-app/server/commit/c919e93df367ca6f13803ceefd1999e6e12e02a2)) 13 | 14 | # [0.6.0](https://github.com/ubud-app/server/compare/v0.5.0...v0.6.0) (2021-04-25) 15 | 16 | 17 | ### Bug Fixes 18 | 19 | * **CSV Import:** Fix year for DKB credit card imports ([57f8c91](https://github.com/ubud-app/server/commit/57f8c91193bea3f33f0185cc6c4c017dfe0d5445)) 20 | 21 | 22 | ### Features 23 | 24 | * **CSV Importer:** Add columns for DKB credit cards ([bad692d](https://github.com/ubud-app/server/commit/bad692db6b20385018fbd5d58f0b808b7724ec71)) 25 | * **CSV Importer:** Add support for dkb.de csv export ([e9a86d7](https://github.com/ubud-app/server/commit/e9a86d707f76171ecf626606382ff04bff65be0d)) 26 | * **Transactions:** Guess budget for new, synced transactions ([1bb5ee2](https://github.com/ubud-app/server/commit/1bb5ee26edc8f571c9edcb605a1cfdd45d5f34ef)) 27 | 28 | # [0.5.0](https://github.com/ubud-app/server/compare/v0.4.0...v0.5.0) (2021-04-13) 29 | 30 | 31 | ### Bug Fixes 32 | 33 | * **Log:** Fix scope.addTag is not a function ([0e98323](https://github.com/ubud-app/server/commit/0e983238bc89b3c5e73285d0dfab33e6c3306a73)) 34 | 35 | 36 | ### Features 37 | 38 | * **PluginInstances:** add logging for plugin initialization for debugging purposes ([8dcce61](https://github.com/ubud-app/server/commit/8dcce61b23c3e799262b84458541b1d01e1553bb)) 39 | * **bin/server:** Do not allow to create first user with that tool ([25dda15](https://github.com/ubud-app/server/commit/25dda15a18956357659a035ccdde0ec5f54b7b84)) 40 | 41 | # [0.4.0](https://github.com/ubud-app/server/compare/v0.3.0...v0.4.0) (2020-12-09) 42 | 43 | 44 | ### Bug Fixes 45 | 46 | * **Dockerfile:** Client version for next channel ([fd7c604](https://github.com/ubud-app/server/commit/fd7c604c79e0f3364f7de8da583ce5ca76054cea)) 47 | * **Transactions:** Correct transaction background sync ([25735e7](https://github.com/ubud-app/server/commit/25735e7c76e4f05ad8e6d926692b06e8013a60f7)) 48 | * **CI:** Fix CI cache issue ([592aedc](https://github.com/ubud-app/server/commit/592aedcbfcadf05862f587f5840e871756b9167f)) 49 | * **Dockerfile:** Fix client version ([6e77d04](https://github.com/ubud-app/server/commit/6e77d04cbcd0d543270c124207606659a792da68)) 50 | * **Dockerfile:** Fix client version ([4a6e968](https://github.com/ubud-app/server/commit/4a6e96814d5f22af7f7211e614f5acb55222d1b1)) 51 | * **Summaries:** Fix document balances ([45eab5e](https://github.com/ubud-app/server/commit/45eab5e7c29b633e39fa13eb5a7032fbfbe423f8)) 52 | * **Accounts:** Handle pluginInstanceId = "null" correctly ([bc9c3ef](https://github.com/ubud-app/server/commit/bc9c3ef3f480e359e86ed126dcb923c1c879e58c)) 53 | * **Server:** Serve client again ([de15d7d](https://github.com/ubud-app/server/commit/de15d7dc2e2bb4876652f9f0421cec78557472e0)) 54 | 55 | 56 | ### Features 57 | 58 | * **Docker:** Add labels (http://label-schema.org) ([423c544](https://github.com/ubud-app/server/commit/423c544c44611d3a3742c30371a7768e4af6b081)) 59 | * **Socket:** Use querystrings for collection filtering ([540f396](https://github.com/ubud-app/server/commit/540f39610a3b02227506815922dca7eca54edbc2)) 60 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG BASEIMAGE=alpine:latest 2 | FROM $BASEIMAGE as build-container 3 | 4 | ARG CLIENT_TAG=latest 5 | ENV SENTRY_DSN=$SENTRY_DSN 6 | 7 | RUN apk add --no-cache --update \ 8 | nodejs \ 9 | npm \ 10 | libstdc++ \ 11 | make \ 12 | gcc \ 13 | g++ \ 14 | python3 && \ 15 | npm install -g --unsafe-perm npm 16 | 17 | COPY package*.json "/@ubud-app/server/" 18 | WORKDIR "/@ubud-app/server" 19 | RUN npm ci 20 | 21 | COPY . "/@ubud-app/server/" 22 | RUN npm i "@ubud-app/client@$CLIENT_TAG" --save-optional --no-audit 23 | 24 | 25 | 26 | FROM $BASEIMAGE 27 | 28 | ARG UID=1000 29 | ARG GID=1000 30 | ARG CLIENT_TAG=latest 31 | ARG NODE_ENV=production 32 | ARG NEXT 33 | ARG SENTRY_DSN 34 | 35 | # Build-time metadata as defined at http://label-schema.org 36 | ARG BUILD_DATE 37 | ARG VCS_REF 38 | ARG VERSION 39 | LABEL org.label-schema.build-date=$BUILD_DATE \ 40 | org.label-schema.name="ubud app" \ 41 | org.label-schema.description="A small, selfhosted software for personal budgeting." \ 42 | org.label-schema.url="https://ubud.club" \ 43 | org.label-schema.usage="https://github.com/ubud-app/server#-quick-start" \ 44 | org.label-schema.vcs-ref=$VCS_REF \ 45 | org.label-schema.vcs-url="https://github.com/ubud-app/server" \ 46 | org.label-schema.vendor="Sebastian Pekarek" \ 47 | org.label-schema.version=$VERSION \ 48 | org.label-schema.schema-version="1.0" 49 | 50 | ENV NODE_ENV=$NODE_ENV 51 | ENV SENTRY_DSN=$SENTRY_DSN 52 | ENV NEXT=$NEXT 53 | 54 | RUN apk add --no-cache --update \ 55 | nodejs \ 56 | npm && \ 57 | npm install -g --unsafe-perm npm && \ 58 | addgroup -g $GID ubud && \ 59 | adduser -u $UID -G ubud -s /bin/sh -D ubud 60 | 61 | COPY --from=build-container "/@ubud-app" "/@ubud-app" 62 | 63 | RUN ln -s "/@ubud-app/server/bin/database" "/usr/local/bin/ubud-db" && \ 64 | ln -s "/@ubud-app/server/bin/plugin" "/usr/local/bin/ubud-plugin" && \ 65 | ln -s "/@ubud-app/server/bin/user" "/usr/local/bin/ubud-user" && \ 66 | ln -s "/@ubud-app/server/server.js" "/usr/local/bin/ubud-server" && \ 67 | chown -R ubud:ubud /@ubud-app/server && \ 68 | chown -R ubud:ubud /@ubud-app/server/node_modules && \ 69 | chown -R ubud:ubud /@ubud-app/server/package.json && \ 70 | chown -R ubud:ubud /@ubud-app/server/package-lock.json 71 | 72 | USER ubud 73 | WORKDIR "/@ubud-app/server" 74 | CMD ubud-server 75 | -------------------------------------------------------------------------------- /Privacy.md: -------------------------------------------------------------------------------- 1 | # Datenschutzerklärung 2 | [**de**|[en](./Privacy_en.md)] 3 | 4 | ## Verantwortlicher für die Datenverarbeitung 5 | Diese Datenschutzhinweise gelten für die Datenverarbeitung durch Sebastian Pekarek, Lindenstr. 75 B, 10969 Berlin (nachfolgend Betreiber genannt). 6 | 7 | ## Datenverarbeitung 8 | Grundsätzlich findet keine Datenverarbeitung personenbezogener Daten durch den Betreiber statt. 9 | 10 | ### Beacon 11 | Aus statistischen Gründen werden in regelmäßigen Intervallen nicht-personenbezogene Daten an den Betreiber gemeldet. Diese Daten sind: 12 | 13 | - Instanz-ID (zufällige, eindeutige ID der Instanz) 14 | - Versionen installierter ubud-Komponenten (z.B. Server oder Web-Client) 15 | - node.js Version 16 | - npm Version 17 | - Prozessor-Typ 18 | - Betriebssystem-Typ 19 | - Anzahl User auf der Instanz 20 | - Anzahl Dokumente auf der Instanz 21 | - installierte Plugins 22 | - Name des npm-Moduls 23 | - Version 24 | - Anzahl angelegter Accounts (falls Account-Plugin) 25 | - Anzahl angelegter Ziele (falls Goal-Plugin) 26 | 27 | Bei der Verwendung der Plugin-Suche wird die Eingabe Klartext an den Betreiber übermittelt, um entsprechende Ergebisse ausliefern zu können. 28 | 29 | Der Server des Betreibers wird von Contabo GmbH, Aschauer Straße 32a, 81549 München gehostet. Eine entsprechende Vereinbarung zur Auftragsverarbeitung wurde mit Contabo geschlossen. Jeder Zugriff auf diesen Server erzeugt einen Eintrag im sogenannten Access-Log. Dabei wird die öffentliche IP-Adresse anonymisiert, sodass Zugriffe nicht nachträglich einer bestimmten Person zuzuordnen sind. Die Datenverarbeitung erfolgt auf der Rechtsgrundlage des Artikel 6 Abs. 1 S. 1 lit. f. Berechtigtes Interesse des Betreibers ist die Auswertung, insbesondere um die Anzahl der Nutzer und die Verbreitung der Software nachzuvollziehen. 30 | 31 | ### Sentry 32 | Ausnahme von dem obigen Grundsatz bildet die Nutzung von Sentry. Diese Software wird verwendet, um bisher unbekannte Fehler in den Software-Komponenten aufzuspüren. Die Erhebung und Speicherung der personenbezogen Daten erfolgt aufgrund der Rechtsgrundlage des Artikel 6 Abs. 1 S. 1 lit. b und f auf dem Server des Betreibers innerhalb der EU. Berechtigtes Interesse des Betreibers ist die Lösung von Fehlern in der Software. Die zu diesem Zwecke erhobenen und gespeicherten Daten werden nach spätestens 14 Tagen gelöscht. 33 | 34 | ## Betroffenenrechte 35 | 36 | ### 1. Auskunft 37 | Auf Wunsch erhalten Sie jederzeit unentgeltlich Auskunft über alle personenbezogenen Daten, die wir über Sie gespeichert haben. 38 | 39 | ### 2. Berichtigung, Löschung, Einschränkung der Verarbeitung (Sperrung), Widerspruch 40 | Sollten Sie mit der Speicherung Ihrer personenbezogenen Daten nicht mehr einverstanden oder sollten diese unrichtig geworden sein, werden wir auf eine entsprechende Weisung hin die Löschung oder Sperrung Ihrer Daten veranlassen oder die notwendigen Korrekturen vornehmen (soweit dies nach dem geltenden Recht möglich ist). Gleiches gilt, sofern wir Daten künftig nur noch einschränkend verarbeiten sollen. 41 | 42 | ### 3. Datenübertragbarkeit 43 | Auf Antrag stellen wir Ihnen Ihre Daten in einem gängigen, strukturierten und maschinenlesbaren Format bereit, so dass Sie die Daten auf Wunsch einem anderen Verantwortlichen übermitteln können. 44 | 45 | ### 4. Beschwerderecht 46 | Es besteht ein Beschwerderecht bei der zuständigen Aufsichtsbehörde: 47 | (https://www.bfdi.bund.de/DE/Infothek/Anschriften_Links/anschriften_links-node.html). 48 | 49 | ### 5. Widerrufsrecht bei Einwilligungen mit Wirkung für die Zukunft 50 | Erteilte Einwilligungen können Sie mit Wirkung für die Zukunft jederzeit widerrufen. Durch Ihren Widerruf wird die Rechtmäßigkeit der Verarbeitung bis zum Zeitpunkt des Widerrufs nicht berührt. 51 | 52 | ### 6. Einschränkungen 53 | Daten, bei denen wir nicht in der Lage sind die betroffene Person zu identifizieren, bspw. wenn diese zu Analysezwecken anonymisiert wurden, sind nicht von den vorstehenden Rechten umfasst. Auskunft, Löschung, Sperrung, Korrektur oder Übertragung an ein anderes Unternehmen sind in Bezug auf diese Daten ggf. möglich, wenn Sie uns zusätzliche Informationen, die uns eine Identifizierung erlauben, bereitstellen. 54 | 55 | ### 7. Ausübung Ihrer Betroffenenrechte 56 | Bei Fragen zur Verarbeitung Ihrer personenbezogenen Daten, bei Auskünften, Berichtigung, Sperrung, Widerspruch oder Löschung von Daten oder dem Wunsch der Übertragung der Daten an ein anderes Unternehmen, wenden Sie sich bitte an den Betreiber. 57 | 58 | -------------------------------------------------------------------------------- /Privacy_en.md: -------------------------------------------------------------------------------- 1 | # Privacy Statement 2 | [[de](./Privacy.md)|**en**] 3 | 4 | ## Person responsible for data processing 5 | This data protection notice applies to data processing by Sebastian Pekarek, Lindenstr. 75 B, 10969 Berlin (hereinafter referred to as operator). 6 | 7 | ## Data processing 8 | In principle, no data processing of personal data takes place by the operator. 9 | 10 | ### Beacon 11 | For statistical reasons, non-personal data are reported to the operator at regular intervals. These data are: 12 | 13 | - Instance ID (random, unique ID of the instance) 14 | - Versions of installed ubud components (e.g. server or web client) 15 | - node.js version 16 | - npm version 17 | - Processor type 18 | - Operating system type 19 | - Number of users on the instance 20 | - Number of documents on the instance 21 | - installed plugins 22 | - Name of the npm module 23 | - version 24 | - Number of accounts created (if account plug-in) 25 | - Number of destinations created (if Goal plugin) 26 | 27 | When using the plugin search, the input plain text is transmitted to the operator in order to be able to deliver corresponding results. 28 | 29 | The operator's server is hosted by Contabo GmbH, Aschauer Straße 32a, 81549 Munich, Germany. A corresponding agreement on order processing was concluded with Contabo. Each access to this server generates an entry in the so-called access log. The public IP address is made anonymous, so that access cannot subsequently be assigned to a specific person. The data processing takes place on the legal basis of Article 6 Paragraph 1 S. 1 lit. f. The legitimate interest of the operator is the evaluation, in particular to track the number of users and the distribution of the software. 30 | 31 | ### Sentry 32 | The exception to the above principle is the use of Sentry. This software is used to detect previously unknown errors in the software components. The collection and storage of personal data takes place on the operator's server within the EU on the basis of the legal basis of Article 6 (1) (1) (b) and (f). The justified interest of the operator is the solution of errors in the software. The data collected and stored for this purpose will be deleted after 14 days at the latest. 33 | 34 | ## Rights concerned 35 | 36 | ### 1. information 37 | Upon request, you will receive information free of charge about all personal data that we have stored about you at any time. 38 | 39 | ### 2. correction, deletion, restriction of processing (blocking), objection 40 | Should you no longer agree with the storage of your personal data or should this have become incorrect, we will arrange for the deletion or blocking of your data or make the necessary corrections on the basis of a corresponding instruction (insofar as this is possible under applicable law). The same applies if we are only to process data in a restrictive manner in future. 41 | 42 | ### 3. data transferability 43 | Upon request, we will provide you with your data in a common, structured and machine-readable format, so that you can transfer the data to another responsible person on request. 44 | 45 | ### 4. right of appeal 46 | There is a right of appeal to the competent supervisory authority: 47 | (https://www.bfdi.bund.de/DE/Infothek/Anschriften_Links/anschriften_links-node.html_Links/anschriften_links-node.html). 48 | 49 | ### 5. right of revocation for consents with effect for the future 50 | You may revoke your consent at any time with effect for the future. Your revocation does not affect the lawfulness of the processing until the time of revocation. 51 | 52 | ### 6. restrictions 53 | Data for which we are unable to identify the data subject, e.g. if anonymized for analysis purposes, are not covered by the above rights. Information, deletion, blocking, correction or transfer to another company may be possible with respect to such data if you provide us with additional information that allows us to identify you. 54 | 55 | ### 7. exercising your rights as a data subject 56 | If you have any questions regarding the processing of your personal data, information, correction, blocking, objection or deletion of data or if you wish the data to be transferred to another company, please contact the operator. 57 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |
3 |


4 |
5 | 6 | [![Status](https://img.shields.io/github/workflow/status/ubud-app/server/Release/main?style=flat-square)](https://github.com/ubud-app/server/actions) 7 | [![npm version](https://img.shields.io/npm/v/@ubud-app/server?color=blue&label=version&style=flat-square)](https://www.npmjs.com/package/@ubud-app/server) 8 | [![npm dependency status](https://img.shields.io/librariesio/release/npm/@ubud-app/server?style=flat-square)](https://www.npmjs.com/package/@ubud-app/server) 9 | [![Docker Image Size](https://img.shields.io/docker/image-size/ubud/server/next?style=flat-square)](https://hub.docker.com/r/ubud/server) 10 | 11 | ## 🚨 Warning 12 | 13 | This software is still in a very early stage (early preview). Errors can occur at any time. Therefore ubud should currently not be used productively, but only for testing purposes. 14 | 15 | 16 | ## 🧐 What's this? 17 | 18 | This repository contains the software ubud, a small private software for budgeting. ubud works according to the envelope method and can be extended with plugins, so that turnovers of accounts can be imported automatically. So that your data doesn't buzz around in some clouds, ubud is a self-hosted software. Install ubud on a [Raspberry Pi](https://www.raspberrypi.org/) or on any NAS with docker support. 19 | 20 | 21 | ## 🖼 Screenshot 22 | 23 | ![Screenshot](https://d.sebbo.net/macbookpro13_front-UcPy3pEMhoqNuzqBJwY0nwV4DMPOAFu9h7SGxUSXXATFArbW5UPLQOBnkbw3R7CEsrponXZQ5SrYPs7hViVVKIhzJ2UmckumiVDh.png) 24 | 25 | 26 | ## 🎉 Features 27 | 28 | - self-hosted software, no private data in the cloud 29 | - web interface optimized for mobile devices 30 | - Budgeting via envelope method 31 | - Synchronization with banks possible with plugins 32 | - Multi-user capable 33 | 34 | 35 | ## 🐳 Quick Start 36 | 37 | The easiest way to test ubud is Docker. If you don't have Docker installed on your system yet, you can do this with [this guide](https://docs.docker.com/install/). 38 | 39 | You need a database where all the data is stored. Currently MySQL and MariaDB are supported. Docker images are currently available for ARM and AMD64. 40 | 41 | ``` 42 | # Download docker-compose.yml 43 | wget https://raw.githubusercontent.com/ubud-app/server/develop/docker-compose.yml 44 | 45 | # Edit environment variables 46 | nano docker-compose.yml 47 | 48 | # Start ubud 49 | docker-compose up -d 50 | 51 | # Get initial login credentials 52 | docker logs -f $(docker-compose ps -q ubud) | docker-compose exec -T ubud \ 53 | ./node_modules/bunyan/bin/bunyan -o short --color -l info 54 | ``` 55 | 56 | 57 | ## 🔧 Configuration 58 | | Environment Variable | Default Value | Description | 59 | |:------- |:------------------- |:------------------ | 60 | |DATABASE|mysql://localhost/ubud|Database Connection URI| 61 | |SENTRY_DSN|-|Sentry DSN, overwrite to use a custom Sentry Instance| 62 | |PORT|8080|Port to listen on| 63 | |DEVELOP|0|Run in develop mode| 64 | 65 | 66 | ## 💬 Feedback & Help 67 | 68 | For the early preview there are no issues possible, because I want to get the software to work well with it. However, questions and feedback can still be passed on. Either via Twitter [@ubudapp](https://twitter.com/ubudapp) or in our [Slack-Channel](https://join.slack.com/t/ubud-app/shared_invite/enQtNzAzNTU0MjM2MzUzLTY5MGRiZDE5ZDAyMDc3NDZkNGZlOGQxMTc2ZjA1NzEwZDk5ODc5YTc4MTg5N2VlYzY0ODViODZkNmQ0YTQ0MDk). 69 | 70 | 71 | ## 🛠 Build a Plugin 72 | 73 | Plugins can be installed via the ubud user interface. These are written in node.js. There are three types of plugins, whereby one plugin can implement several types: 74 | 75 | - Account Plugin: Allows you to synchronize one or more accounts with ubud. Example: Plugin for a bank 76 | - Metadata Plugin: Enables metadata to be automatically added to a transaction. Example: Plugin that automatically splits the transaction between the different products for online shop orders. 77 | - Goal Plugin: Allows you to automatically add saving targets to a document. Example: Synchronize wish lists of an online shop 78 | 79 | The development of a plugin is currently still a bit hairy, since there is still no documentation and no tools to help. If you still don't want to wait, please feel free to contact us via Slack or Twitter. 80 | 81 | 82 | ## ⏱ Roadmap 83 | 84 | During the Early Preview you can find a very rough roadmap on [GitHub](https://github.com/orgs/ubud-app/projects/1). 85 | 86 | 87 | ## 👩‍⚖️ Legal Stuff 88 | 89 | - [General terms and conditions](https://github.com/ubud-app/server/blob/develop/Terms.md) 90 | - [Privacy Statement](https://github.com/ubud-app/server/blob/develop/Privacy.md) 91 | -------------------------------------------------------------------------------- /Terms.md: -------------------------------------------------------------------------------- 1 | # Allgemeine Geschäftsbedingungen 2 | [**de**|[en](./Terms_en.md)] 3 | 4 | ## Rechtsverhältnis zwischen Nutzer und Betreiber der Software ubud 5 | Bei der Bereitstellung der Software ubud handelt es sich um eine unentgeltliche bloße Gefälligkeit. Es werden somit keine Rechte und Pflichten des Betreibers der Software ubud begründet. Der Nutzer hingegen erklärt sich bereit, die geltenden Gesetze zu beachten. Verbotene Nutzungsaktivitäten sind zu unterlassen. 6 | 7 | ## Haftungsausschluss 8 | 9 | ### Allgemein 10 | Der Betreiber schließt jegliche Haftung aus. Der Nutzer nimmt zur Kenntnis, dass er nach eigenem Ermessen und auf eigene Gefahr die Software herunterläd, auf seinem Eigentum installiert und verwendet. 11 | 12 | ### Plugins 13 | Bei der Nutzung der Software ubud kannst du Plugins installieren, um eine Anbindung an verschiedene Dritt-Dienste zu ermöglichen. Diese wurden von Dritten oder dem Nutzer entwickelt. Mit der Installation erklärt sich der Nutzer mit folgendem einverstanden: 14 | 15 | - Dienste von Drittanbietern werden nicht vom Betreiber der Software ubud überprüft, empfohlen oder kontrolliert. 16 | - Jegliche Nutzung eines Plugins von Drittanbietern erfolgt auf eigene Gefahr. Eine diesbezügliche Haftung des Betreibers der Software ubud gegenüber Dritten oder dem Nutzer besteht nicht. 17 | - Im Verhältnis Plugin-Entwickler und Nutzer gelten ausschließlich die AGB des Betreibers der Software ubud. 18 | - Einige Dienste von Drittanbietern können den Zugriff auf deine Daten anfordern oder verlangen. Es gilt die Datenschutzerklärung des Plugin-Entwicklers. Der Betreiber der Software ubud hat keine Kontrolle darüber, wie ein Drittanbieter deine Daten verwendet. 19 | - Plugins von Drittanbietern funktionieren möglicherweise nicht ordnungsgemäß. 20 | - In seltenen Fällen behält sich der Betreiber der Software ubud vor, bereits installierte Plugins zu deaktivieren. Dies geschiet nach seinem Ermessen, insbesondere bei Rechtsverstößen, Sicherheits- oder Datenschutzbedenken. 21 | 22 | ## Gewährleistungsausschluss 23 | Der Betreiber schließt jegliche Mängelhaftung aus. Demnach besteht auch kein Anspruch auf Support. Eine lückenlose und fehlerfreie Nutzung der Software ubud kann nicht gewährleistet werden. 24 | 25 | ## Ansprüche und Pflichten von Plugin-Entwicklern 26 | Durch die Bereitstellung eines Plugins werden keinerlei Ansprüche begründet. Weder schuldet der Nutzer noch der Betreiber der Software ubud ein Entgeld. Der Plugin-Entwickler hat keinen Anspruch auf Listung im Plugin-Verzeichnis. 27 | 28 | Dem Plugin-Entwickler ist es nicht gestattet gegenüber dem Nutzer eigene AGB zu stellen. Es gelten auch im Verhältnis Plugin-Entwickler und Nutzer die AGB des Betreibers der Software ubud. 29 | 30 | Plugin-Entwickler verpflichten sich zur Einhaltung der geltenden gesetzlichen Dateschutzbestimmungen sowie aller sonstigen Gesetze. 31 | 32 | ## Urheberrecht 33 | Die Software ist urheberrechtlich geschützt. Kopien sind nur zum Zwecke von Sicherungskopien erlaubt. Die Veröffentlichung wird ausdrücklich untersagt. 34 | 35 | ## Gerichtsstand und Rechtswahl 36 | Als Gerichtsstand wird das Amtsgericht Tempelhof/Kreuzberg vereinbart. 37 | 38 | Es findet deutsches Recht Anwendung. 39 | 40 | ## Änderung dieser AGB 41 | Änderungen dieser AGB werden dem Nutzer spätestens 30 Tage vor Wirksamwerdens in Textform auf der grafischen Benutzeroberfläche dargestellt. Zusätzlich können die AGB auf der Website der Software ubud (https://ubud.club) abgerufen werden. Die Zustimmung des Nutzers gilt als erteilt, wenn eine Nutzung über die 30-tägige Frist erfolgt. Ein Widerspruch ist aus technischen Gründen nicht möglich. Sofern der Nutzer mit der AGB-Änderung nicht einverstanden ist, verbleibt aus technischen Gründen nur die Deinstallation der Software. 42 | 43 | ## Übersetzung 44 | Diese Bedingungen wurden ursprünglich in deutscher Sprache verfasst. Wir haben das Recht, diese Bedingungen in andere Sprachen übersetzen. Im Falle eines Widerspruchs zwischen einer übersetzten Version dieser Bedingungen und der deutschen Version, hat die deutsche Version Vorrang. 45 | -------------------------------------------------------------------------------- /Terms_en.md: -------------------------------------------------------------------------------- 1 | # General terms and conditions 2 | [[de](./Terms.md)|**en**] 3 | 4 | ## Legal relationship between user and operator of the software ubud 5 | The provision of the software ubud is a free mere favour. Thus, no rights and obligations of the operator of the software ubud are established. The user, on the other hand, agrees to comply with the applicable laws. Prohibited usage activities are to be omitted. 6 | 7 | ## Disclaimer 8 | 9 | ### General 10 | The operator excludes any liability. The user acknowledges that he downloads, installs and uses the software at his own discretion and risk. 11 | 12 | ### Plugins 13 | When using the ubud software, you can install plugins to enable connection to various third-party services. These were developed by third parties or the user. With the installation the user agrees with the following: 14 | 15 | - Third-party plugins and services are not checked, recommended or controlled by the operator of the ubud software. 16 | - Any use of a third-party plug-in is at your own risk. The operator of the ubud software is not liable to third parties or the user in this respect. 17 | - In the relationship between plugin developer and user, only the general terms and conditions of the operator of the software ubud apply. 18 | - Some third-party services may request or demand access to your data. The privacy policy of the plugin developer applies. The operator of the software ubud has no control over how a third party uses your data. 19 | - Third-party plug-ins may not work properly. 20 | - In rare cases, the operator of the ubud software reserves the right to disable plug-ins that are already installed. This is done at its discretion, especially in the event of legal violations, security or privacy concerns. 21 | 22 | ## Disclaimer of Warranty 23 | The operator excludes any liability for defects. Accordingly, there is also no claim to support. A complete and error-free use of the software ubud cannot be guaranteed. 24 | 25 | ## Requirements and duties of plugin developers 26 | The provision of a plugin does not give rise to any claims whatsoever. Neither the user nor the operator of the software owes ubud a fee. The plugin developer has no claim to listing in the plugin directory. 27 | 28 | The plugin developer is not allowed to provide the user with his own terms and conditions. The general terms and conditions of the operator of the software ubud also apply in the relationship between the plugin developer and the user. 29 | 30 | Plugin developers are obliged to comply with the applicable legal data protection regulations and all other laws. 31 | 32 | ## Copyright 33 | The software is protected by copyright. Copies are only allowed for backup purposes. Publication is expressly prohibited. 34 | 35 | ## Place of jurisdiction and choice of law 36 | The place of jurisdiction shall be Tempelhof/Kreuzberg Local Court. 37 | 38 | German law shall apply. 39 | 40 | ## Amendment of these General Terms and Conditions 41 | Amendments to these GTC shall be presented to the user in text form on the graphical user interface at least 30 days before they take effect. In addition, the GTC can be downloaded from the ubud software website (https://ubud.club). The user's consent shall be deemed to have been granted if use is made over the 30-day period. An objection is not possible for technical reasons. If the user does not agree with the change to the GTC, only the uninstallation of the software remains for technical reasons. 42 | 43 | ## Translation 44 | These Terms and Conditions were originally written in German. We have the right to translate these terms into other languages. In the event of a conflict between a translated version of these Terms and the German version, the German version shall prevail. 45 | -------------------------------------------------------------------------------- /bin/database: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 'use strict'; 3 | 4 | const commander = require('commander'); 5 | const LogHelper = require('../helpers/log'); 6 | const ConfigHelper = require('../helpers/config'); 7 | const DatabaseHelper = require('../helpers/database'); 8 | 9 | const log = new LogHelper('bin/database'); 10 | const migrator = DatabaseHelper.getMigrator(); 11 | 12 | 13 | function run (promise, type) { 14 | promise.then(function (migrations) { 15 | if (type === 'read' && migrations.length) { 16 | log.info('Result: \n - %s', migrations.length, migrations.map(m => m.file).join('\n - ')); 17 | } 18 | else if (type === 'read' && !migrations.length) { 19 | log.info('Done, no migrations found.'); 20 | } 21 | else if (type === 'reset') { 22 | log.info('Done, database reset complete.'); 23 | } 24 | else if (type === 'write' && migrations.length) { 25 | log.info('Done, executed %s migrations.\n - %s', migrations.length, migrations.map(m => m.file).join('\n - ')); 26 | } 27 | else { 28 | log.info('Done, no migrations executed.'); 29 | } 30 | 31 | process.exit(0); 32 | }).catch(function (error) { 33 | log.error(error); 34 | process.exit(1); 35 | }); 36 | } 37 | 38 | commander.version(ConfigHelper.getVersion(), '-V, --version'); 39 | 40 | commander 41 | .command('up [id]') 42 | .description('Run pending migrations till ') 43 | .action(function (id) { 44 | if (id !== true) { 45 | run(migrator.up({ 46 | to: id 47 | }), 'write'); 48 | } else { 49 | run(migrator.up(), 'write'); 50 | } 51 | }); 52 | 53 | commander 54 | .command('down [id]') 55 | .description('Run pending migrations down to ') 56 | .action(function (id) { 57 | if (id !== true) { 58 | run(migrator.down({ 59 | to: id 60 | }), 'write'); 61 | } 62 | }); 63 | 64 | commander 65 | .command('pending') 66 | .description('List pending migrations') 67 | .action(function () { 68 | run(migrator.pending(), 'read'); 69 | }); 70 | 71 | commander 72 | .command('list') 73 | .description('List executed migrations') 74 | .action(function () { 75 | run(migrator.executed(), 'read'); 76 | }); 77 | 78 | commander 79 | .command('reset') 80 | .description('Reset database') 81 | .action(function () { 82 | run(DatabaseHelper.reset(), 'reset'); 83 | }); 84 | 85 | commander.parse(process.argv); -------------------------------------------------------------------------------- /bin/plugin: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 'use strict'; 3 | 4 | const commander = require('commander'); 5 | const LogHelper = require('../helpers/log'); 6 | const ConfigHelper = require('../helpers/config'); 7 | const PluginHelper = require('../helpers/plugin'); 8 | const DatabaseHelper = require('../helpers/database'); 9 | 10 | const log = new LogHelper('bin/plugin'); 11 | 12 | async function run(action) { 13 | try { 14 | await action(); 15 | process.exit(0); 16 | } 17 | catch(error) { 18 | log.error(error); 19 | process.exit(1); 20 | } 21 | } 22 | 23 | commander.version(ConfigHelper.getVersion(), '-V, --version'); 24 | 25 | commander 26 | .command('list') 27 | .alias('ls') 28 | .description('List all installed plugin instances and their status…') 29 | .action(function () { 30 | run(async function() { 31 | const Table = require('cli-table'); 32 | const table = new Table({ 33 | head: ['ID', 'Document', 'Plugin'] 34 | }); 35 | 36 | const plugins = await DatabaseHelper.get('plugin-instance').findAll({ 37 | include: [{ 38 | model: DatabaseHelper.get('document') 39 | }] 40 | }); 41 | plugins.forEach(plugin => { 42 | table.push([ 43 | plugin.id, 44 | plugin.document.name, 45 | plugin.type 46 | ]); 47 | }); 48 | 49 | log.info(table.toString()); 50 | }); 51 | }); 52 | 53 | commander 54 | .command('install [document] [plugin]') 55 | .alias('i') 56 | .description('Install a plugin, where document is a valid document id and plugin is a `npm install` parameter (name, tarball, etc.)') 57 | .action(function (documentId, plugin) { 58 | run(async function() { 59 | const document = await DatabaseHelper.get('document').findByPk(documentId); 60 | if(!document) { 61 | throw new Error('Document not found, is the id correct?'); 62 | } 63 | 64 | await PluginHelper.installPlugin(plugin, document, {dontLoad: true}); 65 | log.info('Plugin installed successfully. Please restart ubud-server to apply changes.'); 66 | }); 67 | }); 68 | 69 | commander.parse(process.argv); -------------------------------------------------------------------------------- /bin/user: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 'use strict'; 3 | 4 | const commander = require('commander'); 5 | const LogHelper = require('../helpers/log'); 6 | const ConfigHelper = require('../helpers/config'); 7 | const DatabaseHelper = require('../helpers/database'); 8 | const ErrorResponse = require('../helpers/errorResponse'); 9 | 10 | const log = new LogHelper('bin/user'); 11 | 12 | async function run (action) { 13 | try { 14 | await action(); 15 | process.exit(0); 16 | } 17 | catch (error) { 18 | if (error instanceof ErrorResponse) { 19 | log.error(error.toString()); 20 | } 21 | else { 22 | log.error(error); 23 | } 24 | process.exit(1); 25 | } 26 | } 27 | 28 | commander.version(ConfigHelper.getVersion(), '-V, --version'); 29 | 30 | commander 31 | .command('list') 32 | .alias('ls') 33 | .description('List all users and their permissions') 34 | .action(function () { 35 | run(async function () { 36 | const Table = require('cli-table'); 37 | const table = new Table({ 38 | head: ['User ID', 'Email', 'Is Admin', 'Document ID', 'Document Name'] 39 | }); 40 | 41 | const users = await DatabaseHelper.get('user').findAll({ 42 | include: [{ 43 | model: DatabaseHelper.get('document') 44 | }] 45 | }); 46 | users.forEach(user => { 47 | const documents = user.documents.map(d => [d.id.substr(0, 18), d.name]); 48 | const firstDocument = documents.shift() || ['-', '-']; 49 | 50 | table.push([ 51 | user.id.substr(0, 18), 52 | user.email, 53 | user.isAdmin ? 'Yes' : 'No', 54 | firstDocument[0], 55 | firstDocument[1] 56 | ]); 57 | 58 | documents.forEach(d => { 59 | table.push([ 60 | '', 61 | '', 62 | '', 63 | d[0], 64 | d[1] 65 | ]); 66 | }); 67 | }); 68 | 69 | log.info(table.toString()); 70 | }); 71 | }); 72 | 73 | 74 | commander 75 | .command('add [email]') 76 | .description('Adds a new user to the system with the given email address.') 77 | .action(function (email) { 78 | run(async function () { 79 | const UserLogic = require('../logic/user'); 80 | const count = await DatabaseHelper.get('user').count(); 81 | if (count === 0) { 82 | log.error(new Error( 83 | 'Open your browser and login with the credentials which you can find in the logs. ' + 84 | 'Use this tool only to create additional users.' 85 | )); 86 | return; 87 | } 88 | 89 | const {model, secrets} = await UserLogic.create({email}, {session: {user: {isAdmin: true}}}); 90 | 91 | log.info('Created User:\nID: %s\nPassword: %s', model.id, secrets.password); 92 | }); 93 | }); 94 | 95 | commander.parse(process.argv); 96 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | services: 3 | database: 4 | image: "mariadb:latest" 5 | restart: "always" 6 | environment: 7 | MYSQL_ROOT_PASSWORD: "**********" 8 | MYSQL_USER: "ubud" 9 | MYSQL_PASSWORD: "**********" 10 | MYSQL_DATABASE: "ubud" 11 | # volumes: 12 | # - /path/to/persist:/var/lib/mysql 13 | 14 | ubud: 15 | image: "ubud/server:next" 16 | restart: "always" 17 | environment: 18 | DATABASE: "mysql://ubud:**********@database/ubud" 19 | depends_on: 20 | - "database" 21 | expose: 22 | - "8080" 23 | ports: 24 | - "127.0.0.1:8080:8080" 25 | -------------------------------------------------------------------------------- /helpers/config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | /* eslint-disable node/no-process-env */ 3 | 4 | const path = require('path'); 5 | const fs = require('fs'); 6 | 7 | let version; 8 | let database; 9 | let sentryDSN; 10 | let ui; 11 | 12 | 13 | // get version number 14 | try { 15 | const packageJsonPath = path.resolve(__dirname, '..', 'package.json'); 16 | 17 | // eslint-disable-next-line security/detect-non-literal-fs-filename 18 | const packageJsonContent = fs.readFileSync(packageJsonPath, 'utf8'); 19 | version = JSON.parse(packageJsonContent).version; 20 | } 21 | catch (err) { 22 | version = null; 23 | } 24 | 25 | // Database Connection URI 26 | database = process.env['DATABASE']; 27 | if (!database) { 28 | database = 'mysql://localhost/ubud'; 29 | } 30 | 31 | // Sentry DSN 32 | sentryDSN = process.env['SENTRY_DSN']; 33 | if(sentryDSN === undefined) { 34 | sentryDSN = 'https://13a04aa0257b4b739ceec9c73d745d2d@glitch.sebbo.net/6'; 35 | } 36 | 37 | // Client / UI 38 | try { 39 | 40 | ui = require('@ubud-app/client'); // eslint-disable-line node/global-require, node/no-missing-require 41 | ui.static = path.resolve(ui.static); 42 | 43 | const packageJsonPath = require.resolve('@ubud-app/client/package.json'); 44 | const stats = fs.statSync(packageJsonPath); // eslint-disable-line security/detect-non-literal-fs-filename 45 | ui.timestamp = stats.mtime; 46 | 47 | // eslint-disable-next-line security/detect-non-literal-fs-filename 48 | const packageJsonContent = fs.readFileSync(packageJsonPath, 'utf8'); 49 | ui.version = JSON.parse(packageJsonContent).version; 50 | } 51 | catch(err) { 52 | // do nothing 53 | } 54 | 55 | 56 | /** 57 | * ConfigHelper 58 | * 59 | * @module helpers/config 60 | * @class ConfigHelper 61 | */ 62 | class ConfigHelper { 63 | /** 64 | * Returns the app's version number from package.json 65 | * @returns {String} 66 | */ 67 | static getVersion() { 68 | return version; 69 | } 70 | 71 | /** 72 | * Returns the port the app server should bind to 73 | * @returns {Number} 74 | */ 75 | static getPort() { 76 | return parseInt(process.env.PORT) || 8080; 77 | } 78 | 79 | /** 80 | * Returns the database connection URI set via the 81 | * `DATABASE` environment variable. 82 | * @returns {String} 83 | */ 84 | static getDatabaseURI() { 85 | return database; 86 | } 87 | 88 | /** 89 | * Returns the Sentry DSN (Data Source Name) used to 90 | * remotely submit error messages. 91 | * @returns {String|null} 92 | */ 93 | static getSentryDSN() { 94 | return sentryDSN || null; 95 | } 96 | 97 | /** 98 | * True, if app runs in development mode. Set `DEVELOP` 99 | * environment variable, to do so. 100 | * @returns {boolean} 101 | */ 102 | static isDev() { 103 | return !!process.env.DEVELOP; 104 | } 105 | 106 | /** 107 | * Returns client paths if client is installed, 108 | * otherwise null. 109 | * @returns {object|null} 110 | */ 111 | static getClient() { 112 | return ui; 113 | } 114 | 115 | /** 116 | * True, if app runs next channel instead of latest, 117 | * applies not for plugins. 118 | * @returns {boolean} 119 | */ 120 | static isNext() { 121 | return !!process.env.NEXT; 122 | } 123 | } 124 | 125 | module.exports = ConfigHelper; 126 | -------------------------------------------------------------------------------- /helpers/errorResponse.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | class ErrorResponseHelper extends Error { 4 | /** 5 | * ErrorResponse 6 | * 7 | * @param {number} [status] HTTP Status Code 8 | * @param {string|Error} message 9 | * @param {object} [options] 10 | * @param {Object} [options.attributes] Example: {name: 'Too long!'} 11 | * @param {string} [options.reference} 12 | * @param {Object} [options.extra] Example: {termsUrl: 'https://…'} 13 | */ 14 | constructor(status, message, options) { 15 | if (!message) { 16 | message = status; 17 | status = 500; 18 | } 19 | 20 | super(message); 21 | this.name = 'ErrorResponse'; 22 | this.status = status; 23 | this.options = options || {}; 24 | this.options.attributes = options && options.attributes ? options.attributes : {}; 25 | this.options.extra = options && options.extra ? options.extra : {}; 26 | } 27 | 28 | toJSON () { 29 | return Object.assign({ 30 | error: this.status, 31 | message: this.message 32 | }, this.options); 33 | } 34 | } 35 | 36 | module.exports = ErrorResponseHelper; -------------------------------------------------------------------------------- /helpers/httpRequestHandler.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const ErrorResponse = require('./errorResponse'); 4 | const DatabaseHelper = require('./database'); 5 | const LogHelper = require('./log'); 6 | const log = new LogHelper('HTTPRequestHandler'); 7 | 8 | 9 | /** 10 | * HTTPRequestHandlerHelper 11 | * 12 | * @module helpers/httpRequestHandler 13 | * @class HTTPRequestHandlerHelper 14 | */ 15 | class HTTPRequestHandler { 16 | /** 17 | * @param {Object} options 18 | * @param {Logic} [options.Logic] 19 | * @param {String} [options.route] 20 | * @param {Object} [options.req] 21 | * @param {Object} [options.res] 22 | */ 23 | constructor (options) { 24 | this.Logic = options.Logic; 25 | this.route = options.route; 26 | this.req = options.req; 27 | this.res = options.res; 28 | } 29 | 30 | /** 31 | * Handle the request. 32 | * Requires all options in constructor to be set. 33 | * 34 | * @returns {HTTPRequestHandler} 35 | */ 36 | run () { 37 | const res = this.res; 38 | 39 | this.checkSession() 40 | .then(session => this.runLogic(session)) 41 | .then(response => this.success(response), error => this.error(error)) 42 | .catch(function (err) { 43 | log.error(err); 44 | 45 | try { 46 | res.sendStatus(500); 47 | } 48 | catch (sendErr) { 49 | log.debug(sendErr); 50 | } 51 | }); 52 | 53 | return this; 54 | } 55 | 56 | /** 57 | * Checks the Session 58 | * @returns {Promise} 59 | */ 60 | async checkSession () { 61 | const auth = require('basic-auth'); 62 | const bcrypt = require('bcryptjs'); 63 | const moment = require('moment'); 64 | 65 | const Logic = this.Logic; 66 | const route = this.route; 67 | 68 | const req = this.req; 69 | let credentials; 70 | try { 71 | credentials = auth(req); 72 | } 73 | catch (err) { 74 | throw new ErrorResponse(401, 'Not able to parse your `Authorization` header…'); 75 | } 76 | 77 | if (!credentials) { 78 | throw new ErrorResponse(401, '`Authorization` header missing…'); 79 | } 80 | if (!credentials.name) { 81 | throw new ErrorResponse(401, 'Error in `Authorization` header: username / session id empty…'); 82 | } 83 | if (!credentials.pass) { 84 | throw new ErrorResponse(401, 'Error in `Authorization` header: password / session secret empty…'); 85 | } 86 | 87 | if (Logic.getModelName() === 'session' && route === 'create' && credentials.name.length !== 36) { 88 | return new Promise(function (cb) { 89 | cb(credentials); 90 | }); 91 | } 92 | 93 | const session = await DatabaseHelper.get('session').findOne({ 94 | where: { 95 | id: credentials.name 96 | }, 97 | include: [{ 98 | model: DatabaseHelper.get('user') 99 | }] 100 | }); 101 | if (!session) { 102 | throw new ErrorResponse(401, 'Not able to authorize: Is session id and secret correct?'); 103 | } 104 | 105 | const isSessionCorrect = await bcrypt.compare(credentials.pass, session.secret); 106 | if (!isSessionCorrect) { 107 | throw new ErrorResponse(401, 'Not able to authorize: Is session id and secret correct?'); 108 | } 109 | 110 | if (session.mobilePairing && (Logic.getModelName() !== 'session' || req.params[0] !== session.id)) { 111 | throw new ErrorResponse(401, 'Not able to authorize: This is a session for the mobile auth flow only.'); 112 | } 113 | 114 | const RepositoryHelper = require('../helpers/repository'); 115 | const terms = await RepositoryHelper.getTerms(); 116 | if ( 117 | session.user.acceptedTermVersion === null || 118 | session.user.acceptedTermVersion < terms.version - 1 || 119 | session.user.acceptedTermVersion === terms.version - 1 && moment().isSameOrAfter(terms.validFrom) 120 | ) { 121 | throw new ErrorResponse(401, 'Not able to login: User has not accept the current terms!', { 122 | attributes: { 123 | acceptedTerms: 'Is required to be set to the current term version.' 124 | } 125 | }); 126 | } 127 | 128 | return session; 129 | } 130 | 131 | /** 132 | * Runs the logic (Logic.get etc.) for 133 | * the given request 134 | * 135 | * @param {Model} session 136 | * @returns {Promise} 137 | */ 138 | runLogic (session) { 139 | const Logic = this.Logic; 140 | const method = 'serve' + this.route.substr(0, 1).toUpperCase() + this.route.substr(1); 141 | const options = { 142 | id: this.req.params[0] || null, 143 | body: this.req.body || {}, 144 | params: this.req.query, 145 | session: session, 146 | httpRequest: this.req 147 | }; 148 | 149 | return Logic[method](options).catch(e => { 150 | throw e; 151 | }); 152 | } 153 | 154 | /** 155 | * Yeah! We need a success response… 156 | * @param {Object|Array} result 157 | */ 158 | success (result) { 159 | const res = this.res; 160 | 161 | if (!result) { 162 | res.sendStatus(204); 163 | } 164 | else { 165 | res.send(result); 166 | } 167 | } 168 | 169 | /** 170 | * Oups. We need a error response… 171 | * @param {Error} err 172 | */ 173 | error (err) { 174 | const res = this.res; 175 | 176 | if (err instanceof Error && !(err instanceof ErrorResponse)) { 177 | err = new ErrorResponse(500, err, { 178 | reference: log.error(err).id 179 | }); 180 | } 181 | if (err instanceof ErrorResponse) { 182 | res.status(err.status).send(err.toJSON()); 183 | return; 184 | } 185 | 186 | throw err; 187 | } 188 | } 189 | 190 | 191 | module.exports = HTTPRequestHandler; 192 | -------------------------------------------------------------------------------- /helpers/importer/csv.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const moment = require('moment'); 5 | const TransactionLogic = require('../../logic/transaction'); 6 | 7 | const csv2transactionMap = { 8 | time: [ 9 | ['Belegdatum', ['DD.MM.YYYY', 'DD.MM.YY']], 10 | ['Buchungstag', ['DD-MM-YY', 'DD.MM.YYYY']], 11 | ['Wertstellung', ['DD-MM-YY', 'DD.MM.YYYY']], 12 | ['Datum', ['DD-MM-YYYY', 'DD.MM.YYYY', 'DD-MM-YY', 'YYYY-MM-DD']], 13 | ['Valutadatum', 'DD-MM-YY'] 14 | ], 15 | pluginsOwnPayeeId: [ 16 | ['Beguenstigter/Zahlungspflichtiger'], 17 | ['Name'], 18 | ['Transaktionsbeschreibung'], 19 | ['Empfänger'], 20 | ['Auftraggeber / Begünstigter'], 21 | ['Beschreibung'] 22 | ], 23 | memo: [ 24 | ['Verwendungszweck'], 25 | ['Transaktionsbeschreibung'], 26 | ['Beschreibung'] 27 | ], 28 | amount: [ 29 | ['Betrag'], 30 | ['Buchungsbetrag'], 31 | ['Betrag (EUR)'] 32 | ] 33 | }; 34 | 35 | 36 | /** 37 | * CSVImporter 38 | * 39 | * @module helpers/importer/csv 40 | * @class CSVImporter 41 | */ 42 | class CSVImporter { 43 | static async check (file) { 44 | return file.mime === 'text/csv' || path.extname(file.name).toLowerCase() === '.csv'; 45 | } 46 | 47 | static async parse (file) { 48 | const TransactionModel = TransactionLogic.getModel(); 49 | const data = file.data.toString('latin1').split('\n\n').pop(); 50 | 51 | const { default: neatCsv } = await import('neat-csv'); 52 | let csv = await neatCsv(data, {separator: ';'}); 53 | if(Object.keys(csv[0]).length < 2) { 54 | csv = await neatCsv(data, {separator: ','}); 55 | } 56 | 57 | return csv.map(row => { 58 | const model = TransactionModel.build(); 59 | 60 | Object.entries(csv2transactionMap).forEach(([attr, def]) => { 61 | def.forEach(([possibleColumn, momentFormat]) => { 62 | if (model[attr]) { 63 | return; 64 | } 65 | 66 | if (row[possibleColumn] && attr === 'time') { 67 | const momentFormats = Array.isArray(momentFormat) ? 68 | momentFormat.filter(f => f.length === row[possibleColumn].length) : 69 | [momentFormat]; 70 | 71 | const time = moment(row[possibleColumn], momentFormats); 72 | if (time && time.isValid()) { 73 | model[attr] = time.toJSON(); 74 | } 75 | } 76 | else if (row[possibleColumn] && attr === 'amount') { 77 | const amount = parseInt(row[possibleColumn].replace(/,|\./g, ''), 10); 78 | if (!isNaN(amount) && amount !== 0) { 79 | model[attr] = amount; 80 | } 81 | if (amount === 0) { 82 | return; 83 | } 84 | } 85 | else if (typeof row[possibleColumn] === 'string') { 86 | model[attr] = row[possibleColumn].trim(); 87 | } 88 | }); 89 | 90 | // Some banks add transactions with an amount of 91 | // 0 to send messages to their customers… 92 | if (model[attr] === undefined && attr !== 'amount') { 93 | throw new Error( 94 | 'Unable to import CSV: no value found for `' + attr + '`, parsed this data: ' + 95 | JSON.stringify(row, null, ' ') 96 | ); 97 | } 98 | }); 99 | 100 | if(!model.amount) { 101 | return null; 102 | } 103 | 104 | return model; 105 | }).filter(Boolean); 106 | } 107 | } 108 | 109 | 110 | module.exports = CSVImporter; 111 | -------------------------------------------------------------------------------- /helpers/importer/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | 4 | /** 5 | * ImporterHelper 6 | * 7 | * @module helpers/importer 8 | * @class ImporterHelper 9 | */ 10 | class ImporterHelper { 11 | static initialize () { 12 | if(this.initialized) { 13 | return; 14 | } 15 | 16 | this.importers = [ 17 | require('./csv'), 18 | require('./mt-940'), 19 | require('./ofx') 20 | ]; 21 | 22 | this.initialized = true; 23 | } 24 | 25 | /** 26 | * Parse the given file 27 | * @param {Model} account AccountModel 28 | * @param {object} file 29 | * @param {String} file.name 30 | * @param {Buffer} file.data 31 | * @param {String} file.mime 32 | * @returns {Promise>} 33 | */ 34 | static async parse (account, file) { 35 | this.initialize(); 36 | 37 | let myImporter; 38 | for(const importerId in this.importers) { 39 | const importer = this.importers[importerId]; 40 | const usable = await importer.check(file); 41 | 42 | if(usable) { 43 | myImporter = importer; 44 | break; 45 | } 46 | } 47 | if(!myImporter) { 48 | throw new Error('Unable to import file `' + file.name + '`: no parser found for file'); 49 | } 50 | 51 | const transactions = await myImporter.parse(file); 52 | return transactions.map(t => { 53 | t.accountId = account.id; 54 | 55 | t.memo = t.memo || null; 56 | if(t.memo && t.memo.length > 512) { 57 | t.memo = t.memo.substr(0, 511) + '…'; 58 | } 59 | 60 | return t; 61 | }); 62 | } 63 | } 64 | 65 | 66 | module.exports = ImporterHelper; 67 | -------------------------------------------------------------------------------- /helpers/importer/mt-940.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const mt940 = require('mt940-js'); 5 | const moment = require('moment'); 6 | const TransactionLogic = require('../../logic/transaction'); 7 | 8 | 9 | /** 10 | * MT940Importer 11 | * 12 | * @module helpers/importer/mt-940 13 | * @class MT940Importer 14 | */ 15 | class MT940Importer { 16 | static async check (file) { 17 | return path.extname(file.name).toLowerCase() === '.sta'; 18 | } 19 | 20 | static async parse (file) { 21 | const TransactionModel = TransactionLogic.getModel(); 22 | const transactions = []; 23 | const data = await mt940.read(file.data); 24 | 25 | data.forEach(item => { 26 | item.transactions.forEach(transaction => { 27 | transactions.push( 28 | TransactionModel.build({ 29 | time: moment(transaction.valueDate, 'YYYY-MM-DD').toJSON(), 30 | memo: transaction.description, 31 | amount: parseInt(transaction.amount.toString().replace(/,|\./, ''), 10) * (transaction.isExpense ? -1 : 1), 32 | pluginsOwnPayeeId: null 33 | }) 34 | ); 35 | }); 36 | }); 37 | 38 | return transactions; 39 | } 40 | } 41 | 42 | 43 | module.exports = MT940Importer; 44 | -------------------------------------------------------------------------------- /helpers/importer/ofx.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const moment = require('moment'); 5 | const TransactionLogic = require('../../logic/transaction'); 6 | 7 | 8 | /** 9 | * OFXImporter 10 | * 11 | * @module helpers/importer/ofx 12 | * @class OFXImporter 13 | */ 14 | class OFXImporter { 15 | static async check (file) { 16 | try { 17 | // eslint-disable-next-line node/no-missing-require 18 | require('ofx'); 19 | } 20 | catch(err) { 21 | return false; 22 | } 23 | 24 | return path.extname(file.name).toLowerCase() === '.ofx'; 25 | } 26 | 27 | static async parse (file) { 28 | // ofx is available, as check checks for it… 29 | // eslint-disable-next-line node/no-missing-require 30 | const ofx = require('ofx'); 31 | const TransactionModel = TransactionLogic.getModel(); 32 | 33 | const transactions = []; 34 | const data = await ofx.parse(file.data.toString()); 35 | 36 | const transactionList = this.findTransactionList(data); 37 | if(!transactionList || !transactionList.STMTTRN) { 38 | throw new Error('Unable to import OFX file: Transaction List not found!'); 39 | } 40 | 41 | transactionList.STMTTRN.forEach(transaction => { 42 | transactions.push( 43 | TransactionModel.build({ 44 | time: moment(transaction.DTPOSTED, 'YYYYMMDD').toJSON(), 45 | memo: transaction.MEMO, 46 | amount: parseInt(transaction.TRNAMT.replace(/,|\./, ''), 10), 47 | pluginsOwnPayeeId: transaction.NAME 48 | }) 49 | ); 50 | }); 51 | 52 | return transactions; 53 | } 54 | 55 | static findTransactionList (data) { 56 | if (typeof data === 'object' && data.BANKTRANLIST) { 57 | return data.BANKTRANLIST; 58 | } 59 | else if (typeof data === 'object') { 60 | return Object.values(data) 61 | .map(child => this.findTransactionList(child)) 62 | .find(list => list); 63 | } 64 | else { 65 | return null; 66 | } 67 | } 68 | } 69 | 70 | 71 | module.exports = OFXImporter; 72 | -------------------------------------------------------------------------------- /helpers/keychain.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const crypto = require('crypto'); 4 | const EventEmitter = require('events'); 5 | 6 | const DatabaseHelper = require('../helpers/database'); 7 | const LogHelper = require('./log'); 8 | const log = new LogHelper('KeychainHelper'); 9 | 10 | let keychainKey = null; 11 | const events = new EventEmitter(); 12 | const algorithm = 'aes-256-ctr'; 13 | 14 | 15 | /** 16 | * KeychainHelper 17 | * 18 | * @module helpers/keychain 19 | * @class KeychainHelper 20 | */ 21 | class KeychainHelper { 22 | 23 | /** 24 | * Returns true, if the keychain is currently unlocked. 25 | * 26 | * @returns {boolean} 27 | */ 28 | static isUnlocked () { 29 | return !!keychainKey; 30 | } 31 | 32 | /** 33 | * Returns true, if the keychain is currently locked. 34 | * 35 | * @returns {boolean} 36 | */ 37 | static isLocked () { 38 | return !keychainKey; 39 | } 40 | 41 | /** 42 | * Returns true, if keychain is set up. 43 | * @returns {Promise} 44 | */ 45 | static async isSetUp () { 46 | const numberOfUsersWithKeychainKey = await DatabaseHelper.get('user') 47 | .count({ 48 | where: { 49 | keychainKey: { 50 | [DatabaseHelper.op('not')]: null 51 | } 52 | } 53 | }); 54 | 55 | return !!numberOfUsersWithKeychainKey; 56 | } 57 | 58 | /** 59 | * Returns a promise which resolves when the keychain 60 | * gets unlocked. Instantly resolves, if the database 61 | * is already in unlocked state. 62 | * 63 | * @returns {Promise} 64 | */ 65 | static async waitTillUnlocked () { 66 | if (this.isUnlocked()) { 67 | return Promise.resolve(); 68 | } 69 | 70 | return new Promise(resolve => { 71 | this.events().once('unlocked', () => resolve()); 72 | }); 73 | } 74 | 75 | /** 76 | * Returns the EventEmitter instance which reflects 77 | * all keychain events for this server instance. 78 | * 79 | * @returns {EventEmitter} 80 | * @instance 81 | */ 82 | static events () { 83 | return events; 84 | } 85 | 86 | /** 87 | * Encrypts the given, json-serializable object and returns 88 | * the encrypted string. Only possible if keychain is unlocked. 89 | * otherwise will throw an error. 90 | * 91 | * @param {string|number|null|object} data 92 | * @returns {String} 93 | */ 94 | static async encrypt (data) { 95 | if (this.isLocked()) { 96 | throw new Error('Impossible to encrypt given data: keychain is locked!'); 97 | } 98 | 99 | let string; 100 | try { 101 | string = JSON.stringify(data); 102 | } 103 | catch (error) { 104 | throw new Error(`Impossible to encrypt given data: object is not serializable (${error.toString()})`); 105 | } 106 | 107 | return KeychainHelper._encrypt(string, keychainKey); 108 | } 109 | 110 | /** 111 | * Encrypts the given string and returns the encrypted one. 112 | * 113 | * @param {String} input 114 | * @param {String|Hash} key 115 | * @returns {Promise} 116 | * @private 117 | */ 118 | static async _encrypt (input, key) { 119 | const secret = crypto.createHash('sha256').update(key).digest(); 120 | const iv = crypto.randomBytes(16); 121 | 122 | const cipher = crypto.createCipheriv(algorithm, secret, iv); 123 | 124 | let encrypted = cipher.update(input, 'utf8', 'hex'); 125 | encrypted += cipher.final('hex'); 126 | 127 | return iv.toString('hex') + '!' + encrypted; 128 | } 129 | 130 | /** 131 | * Decrypts the given string and returns the object. 132 | * Only possible if keychain is unlocked, otherwise 133 | * will throw an error. 134 | * 135 | * @param {String} input 136 | * @returns {String|Number|null|Object} 137 | */ 138 | static async decrypt (input) { 139 | if (this.isLocked()) { 140 | throw new Error('Impossible to decrypt given data: keychain is locked!'); 141 | } 142 | 143 | const dec = await KeychainHelper._decrypt(input, keychainKey); 144 | return JSON.parse(dec); 145 | } 146 | 147 | /** 148 | * Decrypts the given string and returns the original one. 149 | * 150 | * @param {String} input 151 | * @param {String|Buffer} key 152 | * @returns {Promise} 153 | * @private 154 | */ 155 | static async _decrypt (input, key) { 156 | const secret = crypto.createHash('sha256').update(key).digest(); 157 | const inputParts = input.split('!', 2); 158 | const iv = Buffer.from(inputParts[0], 'hex'); 159 | 160 | const decipher = crypto.createDecipheriv(algorithm, secret, iv); 161 | 162 | let dec = decipher.update(inputParts[1], 'hex', 'utf8'); 163 | dec += decipher.final('utf8'); 164 | 165 | return dec; 166 | } 167 | 168 | /** 169 | * Try to unlock and initialize the keychain for the given user. 170 | * Password should be checked already. Woun't throw any errors in 171 | * case this is not possible unless`options.force` is set to true. 172 | * 173 | * @param {Sequelize.Model} userModel – UserModel of the user trying to unlock keychain 174 | * @param {String} password – Cleartext password of the user 175 | * @param {Object} [options] 176 | * @param {Boolean} [options.force] – Force unlock. Will throw an error, if unlock is not possible. 177 | * @param {Boolean} [options.dontSave] – If set to true, unlock() wount save model changes in database, you need to 178 | * do this yourself afterwards if this is set. 179 | */ 180 | static async unlock (userModel, password, options = {}) { 181 | if (!userModel.isAdmin && options.force) { 182 | throw new Error('Unable to unlock keychain: You are not an admin!'); 183 | } 184 | if (!userModel.isAdmin) { 185 | return; 186 | } 187 | 188 | // initialize keychain 189 | if (this.isLocked() && !userModel.keychainKey) { 190 | const isSetUp = await this.isSetUp(); 191 | if (!isSetUp) { 192 | const key = crypto.randomBytes(256); 193 | await KeychainHelper.migrateDatabase(key); 194 | 195 | keychainKey = key; 196 | events.emit('unlocked'); 197 | 198 | log.info('Keychain set up with new, random secret. Now in unlocked state.'); 199 | } 200 | } 201 | 202 | // setup keychainKey for user 203 | if (!userModel.keychainKey && this.isUnlocked()) { 204 | userModel.keychainKey = await KeychainHelper._encrypt( 205 | keychainKey.toString('hex'), 206 | password 207 | ); 208 | 209 | if (!options.dontSave) { 210 | await userModel.save(); 211 | } 212 | 213 | log.info(`Updated keychain key of user ${userModel.id}. User is now able to unlock keychain.`); 214 | } 215 | 216 | // throw error if unlock is not possible 217 | if (!userModel.keychainKey && this.isLocked() && options.force) { 218 | throw new Error('Unable to unlock keychain: Unlock not possible with this user, unlock with another admin user.'); 219 | } 220 | 221 | // do unlock 222 | if (userModel.keychainKey && this.isLocked()) { 223 | const keychainKeyString = await KeychainHelper._decrypt(userModel.keychainKey, password); 224 | keychainKey = Buffer.from(keychainKeyString, 'hex'); 225 | 226 | events.emit('unlocked'); 227 | log.info(`Keychain unlocked by user ${userModel.id}.`); 228 | } 229 | } 230 | 231 | static async migrateDatabase (key) { 232 | const types = ['plugin-config', 'plugin-store']; 233 | for (let i in types) { 234 | const type = types[i]; 235 | const pluginConfigs = await DatabaseHelper.get(type).findAll({ 236 | attributes: ['id', 'value'] 237 | }); 238 | 239 | for (let j in pluginConfigs) { 240 | const model = pluginConfigs[j]; 241 | model.value = await KeychainHelper._encrypt(model.value, key); 242 | 243 | await model.save(); 244 | } 245 | } 246 | } 247 | } 248 | 249 | 250 | module.exports = KeychainHelper; -------------------------------------------------------------------------------- /helpers/log.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const bunyan = require('bunyan'); 4 | const Sentry = require('@sentry/node'); 5 | const _ = require('underscore'); 6 | const os = require('os'); 7 | const util = require('util'); 8 | const http = require('http'); 9 | const {v4: uuid} = require('uuid'); 10 | const ConfigHelper = require('./config'); 11 | 12 | // bunyan logger 13 | const logger = bunyan.createLogger({ 14 | name: 'ubud-server', 15 | level: 'trace', 16 | serializers: {req: bunyan.stdSerializers.req} 17 | }); 18 | 19 | const globalTags = {}; 20 | 21 | // add global tags 22 | globalTags.nodejs = process.version; 23 | globalTags.version = ConfigHelper.getVersion(); 24 | 25 | 26 | // initialize sentry instance 27 | if (ConfigHelper.getSentryDSN()) { 28 | Sentry.init({ 29 | dsn: ConfigHelper.getSentryDSN(), 30 | release: ConfigHelper.getVersion() 31 | }); 32 | } 33 | 34 | 35 | /** 36 | * LogHelper 37 | * 38 | * @module helpers/log 39 | * @class LogHelper 40 | */ 41 | class LogHelper { 42 | constructor(module, options) { 43 | this.module = module; 44 | this.options = options || {}; 45 | } 46 | 47 | 48 | _log(s) { 49 | const t = {}; 50 | let i; 51 | 52 | s = _.extend({}, { 53 | id: 0, 54 | error: null, 55 | time: null, 56 | level: 'log', 57 | module: this.module || null, 58 | param_message: null, 59 | report: false, 60 | request: null, 61 | user: null, 62 | extra: {}, 63 | options: {}, 64 | callback: null 65 | }, s); 66 | s.options = s.options || {}; 67 | 68 | // add Time 69 | s.time = new Date().toGMTString(); 70 | 71 | // add custom Tags 72 | _.extend(s.extra, globalTags); 73 | 74 | // add request 75 | if (s.options && s.options.request && s.options.request instanceof http.IncomingMessage) { 76 | s.request = s.options.request; 77 | } 78 | 79 | // add user 80 | if (s.options && s.options.user) { 81 | s.user = s.options.user; 82 | } 83 | 84 | // add path infos 85 | if (s.options && s.options.request) { 86 | s.pathname = s.options.request._parsedUrl.pathname; 87 | } 88 | 89 | 90 | // analyse arguments 91 | if (!s.error && (!s.args || s.args.length === 0)) { 92 | return null; 93 | } 94 | for (i in s.args) { 95 | if (Object.prototype.hasOwnProperty.call(s.args, i)) { 96 | t.argument = s.args[i]; 97 | i = parseInt(i, 10); 98 | 99 | if (i === 0 && _.isString(t.argument)) { 100 | s.error = t.argument; 101 | t.isString = 1; 102 | t.variables = [t.argument]; 103 | } 104 | else if (i === 0) { 105 | s.error = t.argument; 106 | } 107 | else if (t.argument instanceof http.IncomingMessage) { 108 | s.options.request = t.argument; 109 | } 110 | else if (parseInt(i, 10) === s.args.length - 1 && _.isObject(t.argument)) { 111 | _.extend(s.extra, t.argument); 112 | if (t.isString) { 113 | t.variables.push(t.argument); 114 | } 115 | } 116 | else if (t.isString) { 117 | t.variables.push(t.argument); 118 | } 119 | } 120 | } 121 | 122 | 123 | // generate id 124 | s.id = uuid().substr(0, 32).toUpperCase(); 125 | 126 | // replace variables 127 | if (t.isString) { 128 | s.param_message = s.error; 129 | s.error = util.format.apply(null, t.variables); 130 | } 131 | 132 | // sentry 133 | if (s.report && !ConfigHelper.isDev() && ConfigHelper.getSentryDSN()) { 134 | Sentry.withScope(scope => { 135 | scope.setExtra('machine', os.hostname() + ':' + ConfigHelper.getPort()); 136 | scope.setTag('module', s.module); 137 | scope.setTag('id', s.id); 138 | scope.setTag('level', s.level); 139 | scope.setUser(s.user); 140 | 141 | Sentry[s.level === 'error' ? 'captureException' : 'captureMessage'](s.error); 142 | scope.clear(); 143 | }); 144 | } 145 | 146 | // json log 147 | if( 148 | process.mainModule && process.mainModule.filename && 149 | ( 150 | process.mainModule.filename.substr(-13) === '/bin/database' || 151 | process.mainModule.filename.substr(-11) === '/bin/plugin' || 152 | process.mainModule.filename.substr(-9) === '/bin/user' 153 | ) 154 | ) { 155 | const map = {fatal: 'error', error: 'error', warning: 'warn', info: 'info', debug: 'log'}; 156 | 157 | if(s.module !== 'Database') { 158 | console[map[s.level]](s.error); // eslint-disable-line no-console 159 | } 160 | }else { 161 | logger[s.level === 'warning' ? 'warn' : s.level](_.extend({}, s.extra, { 162 | id: s.id, 163 | module: s.module, 164 | username: s.user, 165 | machine: os.hostname() + ':' + ConfigHelper.getPort(), 166 | pathname: s.pathname, 167 | req: s.request 168 | }), s.error); 169 | } 170 | 171 | // Exception 172 | if (s.level === 'fatal' && s.error instanceof Error) { 173 | throw s.error; 174 | } 175 | 176 | return s; 177 | } 178 | 179 | 180 | fatal() { 181 | let myLog = this._log({ 182 | args: arguments, 183 | level: 'fatal', 184 | report: true 185 | }); 186 | 187 | return myLog; 188 | } 189 | 190 | error() { 191 | return this._log({ 192 | args: arguments, 193 | level: 'error', 194 | report: true 195 | }); 196 | } 197 | 198 | warn() { 199 | return this._log({ 200 | args: arguments, 201 | level: 'warning', 202 | report: true 203 | }); 204 | } 205 | 206 | info() { 207 | return this._log({ 208 | args: arguments, 209 | level: 'info', 210 | report: false 211 | }); 212 | } 213 | 214 | debug() { 215 | return this._log({ 216 | args: arguments, 217 | level: 'debug', 218 | report: false 219 | }); 220 | } 221 | 222 | log() { 223 | return this._log({ 224 | args: arguments, 225 | level: 'debug', 226 | report: false 227 | }); 228 | } 229 | 230 | context(method, cb) { 231 | let returned, 232 | error; 233 | 234 | try { 235 | returned = method(); 236 | } 237 | catch (err) { 238 | error = this._log({ 239 | error: err, 240 | level: 'error', 241 | report: true 242 | }); 243 | } 244 | 245 | if (_.isFunction(cb)) { 246 | cb(error || null, returned); 247 | return returned; 248 | } 249 | 250 | return returned; 251 | } 252 | 253 | wrap(method, cb) { 254 | const l = this; 255 | return function () { 256 | return l.context(method, cb); 257 | }; 258 | } 259 | } 260 | 261 | // process exit log 262 | if (!ConfigHelper.isDev()) { 263 | process.on('uncaughtException', function (err) { 264 | const log = new LogHelper('LogHelper'); 265 | 266 | log.fatal({ 267 | error: err, 268 | level: 'error', 269 | module: 'root', 270 | report: true 271 | }); 272 | }); 273 | } 274 | 275 | module.exports = LogHelper; 276 | -------------------------------------------------------------------------------- /helpers/plugin/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const EventEmitter = require('events'); 4 | const LogHelper = require('../log'); 5 | const DatabaseHelper = require('../database'); 6 | const PluginInstance = require('./instance'); 7 | const log = new LogHelper('PluginHelper'); 8 | 9 | 10 | const pluginEvents = new EventEmitter(); 11 | let initialized = false; 12 | let plugins = []; 13 | 14 | 15 | /** 16 | * PluginHelper 17 | * 18 | * @module helpers/plugin 19 | * @class PluginHelper 20 | */ 21 | class PluginHelper { 22 | static async initialize () { 23 | if (initialized) { 24 | return; 25 | } 26 | 27 | let models; 28 | initialized = true; 29 | 30 | try { 31 | models = await DatabaseHelper.get('plugin-instance').findAll(); 32 | } 33 | catch (err) { 34 | log.fatal('Unable to fetch used plugins: %s', err); 35 | throw err; 36 | } 37 | 38 | plugins = models.map(plugin => new PluginInstance(plugin, pluginEvents)); 39 | } 40 | 41 | static async listPlugins () { 42 | return plugins; 43 | } 44 | 45 | static async getPlugin (id) { 46 | return plugins.find(plugin => plugin.id() === id) || null; 47 | } 48 | 49 | /** 50 | * installPlugin() 51 | * 52 | * Installs the given plugin for the selected document. For type, all parameters 53 | * specified for `npm install` are valid (see https://docs.npmjs.com/cli/install). 54 | * 55 | * 56 | * ### Sequence 57 | * 58 | * - run npm install 59 | * - Fails: error 60 | * 61 | * - check plugin basics 62 | * - Fails: uninstall plugin + error 63 | * 64 | * - add plugin to database 65 | * - Fails: error 66 | * 67 | * - add plugin to ram db 68 | * 69 | * - check plugin configuration 70 | * - Fails: uninstall plugin + error 71 | * - Valid: go to ready state 72 | * - Invalid: go to waiting for configuration state 73 | * 74 | * @param {string} type Plugin type, for example "@ubud-app/plugin-n26" or "~/my-plugin" 75 | * @param {Sequelize.Model} document 76 | * @param {object} [options] 77 | * @param {boolean} [options.dontLoad] Don't load plugin instance. Method will return null then. 78 | * @returns {Promise.} 79 | */ 80 | static async installPlugin (type, document, options) { 81 | options = options || {}; 82 | 83 | /* 84 | * npm install 85 | */ 86 | type = await this._runPackageInstall(type); 87 | log.debug('%s: installed successfully', type); 88 | 89 | 90 | /* 91 | * run plugin checks 92 | */ 93 | try { 94 | await PluginInstance.check(type); 95 | log.debug('%s: checks passed', type); 96 | } 97 | catch (err) { 98 | const count = await DatabaseHelper.get('plugin-instance').count({ 99 | where: {type} 100 | }); 101 | 102 | if (!count) { 103 | 104 | // remove plugin again 105 | try { 106 | await this._runPackageRemove(type); 107 | log.debug('%s: removed successfully', type); 108 | } 109 | catch (err) { 110 | log.warn('%s: unable to remove plugin: %s', type, err); 111 | } 112 | } 113 | 114 | throw err; 115 | } 116 | 117 | 118 | /* 119 | * add instance to database 120 | */ 121 | const model = await DatabaseHelper.get('plugin-instance').create({type, documentId: document.id}); 122 | if (options.dontLoad) { 123 | return null; 124 | } 125 | 126 | const instance = new PluginInstance(model, pluginEvents); 127 | plugins.push(instance); 128 | 129 | return instance; 130 | } 131 | 132 | 133 | /** 134 | * removePlugin() 135 | * 136 | * @param {PluginInstance} instance 137 | * @returns {Promise.} 138 | */ 139 | static async removePlugin (instance) { 140 | // stop plugin 141 | await instance.destroy(); 142 | 143 | // destroy database model 144 | await instance.model().destroy(); 145 | 146 | // remove plugin from index 147 | const i = plugins.indexOf(instance); 148 | if (i !== -1) { 149 | plugins.splice(i, 1); 150 | } 151 | 152 | // get package usages by other plugin instances 153 | const usages = await DatabaseHelper.get('plugin-instance').count({ 154 | where: { 155 | type: instance.type() 156 | } 157 | }); 158 | 159 | // remove package if not used anymore 160 | if (!usages) { 161 | await this._runPackageRemove(instance.type()); 162 | } 163 | } 164 | 165 | 166 | /** 167 | * Returns the event object used to transmit all 168 | * plugin events to our sockets… 169 | * 170 | * @returns {EventEmitter} 171 | */ 172 | static events () { 173 | return pluginEvents; 174 | } 175 | 176 | 177 | static async _runPackageInstall (type) { 178 | const path = require('path'); 179 | let modulePath = PluginHelper._packageDirectory(type); 180 | let res; 181 | 182 | if (!modulePath) { 183 | try { 184 | res = await this._runPackageRunQueue(['npm', 'install', '--ignore-scripts', '--production', type]); 185 | } 186 | catch (err) { 187 | log.error(err); 188 | throw new Error('Unable to install required package via npm`: ' + err.string); 189 | } 190 | 191 | modulePath = PluginHelper._packageDirectory(type); 192 | } 193 | 194 | modulePath = path.dirname(require.resolve(type + '/package.json')); 195 | if (!modulePath) { 196 | throw new Error('Unable to find plugin after installation'); 197 | } 198 | 199 | log.debug('Plugin installation done, path is ' + modulePath); 200 | Object.keys(require.cache) 201 | .filter(key => key.substr(0, modulePath.length) === modulePath) 202 | .forEach(key => { 203 | log.debug('Invalidated cached module: ' + key); 204 | delete require.cache[key]; 205 | }); 206 | 207 | if (res) { 208 | const id = res.split('\n').find(l => l.trim().substr(0, 1) === '+'); 209 | if (!id) { 210 | throw new Error(`Plugin installed, but unable to get plugin name. Output was \`${res}\``); 211 | } 212 | 213 | return id.substr(2, id.lastIndexOf('@') - 2).trim(); 214 | } 215 | 216 | return type; 217 | } 218 | 219 | static _packageDirectory (type) { 220 | const path = require('path'); 221 | 222 | try { 223 | const modulePath = path.dirname(require.resolve(type + '/package.json')); 224 | return modulePath; 225 | } 226 | catch (err) { 227 | return null; 228 | } 229 | } 230 | 231 | static async _runPackageRemove (type) { 232 | await this._runPackageRunQueue(['npm', 'remove', type]); 233 | } 234 | 235 | static async _runPackageRunQueue (command) { 236 | const id = command.join(' '); 237 | log.debug('_runPackageRunQueue: %s: add', id); 238 | 239 | this._runPackageRunQueue.q = this._runPackageRunQueue.q || []; 240 | this._runPackageRunQueue.e = this._runPackageRunQueue.e || new EventEmitter(); 241 | 242 | // already in queue? 243 | const i = this._runPackageRunQueue.q.find(i => i[0] === id); 244 | if (i) { 245 | log.debug('_runPackageRunQueue: %s: already in queue', id); 246 | return i[2]; 247 | } 248 | 249 | // add to queue 250 | const item = [ 251 | id, 252 | command, 253 | new Promise(resolve => { 254 | const l = _id => { 255 | if (_id === id) { 256 | this._runPackageRunQueue.e.off('start', l); 257 | resolve(); 258 | } 259 | }; 260 | this._runPackageRunQueue.e.on('start', l); 261 | }).then(() => { 262 | log.debug('_runPackageRunQueue: %s: running', id); 263 | 264 | const exec = require('promised-exec'); 265 | const escape = require('shell-escape'); 266 | 267 | return exec(escape(command)); 268 | }).finally(() => { 269 | log.debug('_runPackageRunQueue: %s: done', id); 270 | const i = this._runPackageRunQueue.q.indexOf(item); 271 | if (i >= 0) { 272 | log.debug('_runPackageRunQueue: %s: remove queue item', id); 273 | this._runPackageRunQueue.q.splice(i, 1); 274 | } 275 | if (this._runPackageRunQueue.q.length > 0) { 276 | setTimeout(() => { 277 | log.debug('_runPackageRunQueue: %s: start next item: %s', id, this._runPackageRunQueue.q[0][0]); 278 | this._runPackageRunQueue.e.emit('start', this._runPackageRunQueue.q[0][0]); 279 | }, 5000); 280 | } 281 | }) 282 | ]; 283 | this._runPackageRunQueue.q.push(item); 284 | log.debug('_runPackageRunQueue: %s: added, now %i items in queue', id, this._runPackageRunQueue.q.length); 285 | 286 | if (this._runPackageRunQueue.q.length === 1) { 287 | log.debug('_runPackageRunQueue: %s: kickstart queue', id); 288 | this._runPackageRunQueue.e.emit('start', this._runPackageRunQueue.q[0][0]); 289 | } 290 | 291 | return item[2]; 292 | } 293 | } 294 | 295 | 296 | module.exports = PluginHelper; 297 | -------------------------------------------------------------------------------- /helpers/plugin/runner.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* eslint-disable node/no-process-exit, no-process-exit, no-console */ 4 | 5 | const PluginTools = require('./tools.js'); 6 | module.exports = PluginTools; 7 | 8 | /** 9 | * PluginRunner 10 | * 11 | * @class PluginRunner 12 | */ 13 | class PluginRunner { 14 | static async initialize () { 15 | process.title = 'ubud-plugin'; 16 | 17 | const job = await this.getJobDescription(); 18 | process.title = 'ubud-plugin (' + job.type + ')'; 19 | PluginTools._runner = this; 20 | PluginTools._config = job.config; 21 | 22 | try { 23 | job.plugin = require(job.type); // eslint-disable-line security/detect-non-literal-require 24 | } 25 | catch(err) { 26 | err.message = 'Unable to execute plugin: ' + err.message; 27 | throw err; 28 | } 29 | 30 | if (job.method === 'check') { 31 | return this.check(job); 32 | } 33 | else if (job.method === 'getSupported') { 34 | return this.getSupported(job); 35 | } 36 | else if (job.method === 'getConfig') { 37 | return this.getConfig(); 38 | } 39 | else if (job.method === 'validateConfig') { 40 | return this.validateConfig(job); 41 | } 42 | else if (job.method === 'getAccounts') { 43 | return this.getAccounts(job); 44 | } 45 | else if (job.method === 'getTransactions') { 46 | return this.getTransactions(job); 47 | } 48 | else if (job.method === 'getMetadata') { 49 | return this.getMetadata(job); 50 | } 51 | else if (job.method === 'getGoals') { 52 | return this.getGoals(job); 53 | } 54 | else { 55 | throw new Error('Unimplemented method: `' + job.method + '`'); 56 | } 57 | } 58 | 59 | /** 60 | * Returns a promise which will resolve with the job description 61 | * when received. 62 | * 63 | * @returns {Promise.} 64 | */ 65 | static async getJobDescription () { 66 | return new Promise(cb => { 67 | const callback = job => { 68 | process.send({type: 'confirm'}); 69 | process.removeListener('message', callback); 70 | cb(job); 71 | }; 72 | 73 | process.on('message', callback); 74 | }); 75 | } 76 | 77 | /** 78 | * Sends the given data as plugin result. 79 | * 80 | * @param {object} data 81 | * @returns {Promise.} 82 | */ 83 | static async sendResponse (data) { 84 | if (!Array.isArray(data)) { 85 | process.send({type: 'response', data}); 86 | return; 87 | } 88 | 89 | data.forEach(item => { 90 | process.send({type: 'item', item}); 91 | }); 92 | 93 | if (Array.isArray(data)) { 94 | process.send({type: 'response', data: []}); 95 | return; 96 | } 97 | process.send({type: 'response'}); 98 | } 99 | 100 | /** 101 | * Checks some plugin basics to detect if this plugin is 102 | * usable or not. Will return {success: true} if everything 103 | * is fine, otherwise will throw an error to notify the 104 | * server instance. 105 | * 106 | * @param {object} job 107 | * @returns {Promise.} 108 | */ 109 | static async check (job) { 110 | if (PluginTools._getConfig().length > 0 && typeof job.plugin.validateConfig !== 'function') { 111 | throw new Error('Plugin has getConfig, but validateConfig() is not a function!'); 112 | } 113 | 114 | let methods = 0; 115 | ['getAccounts', 'getTransactions', 'getMetadata', 'getGoals'].forEach(method => { 116 | if (job.plugin[method] && typeof job.plugin[method] === 'function') { 117 | methods += 1; 118 | } 119 | else if (job.plugin[method] && typeof job.plugin[method] !== 'function') { 120 | throw new Error('Plugin has invalid function ' + method + '()'); 121 | } 122 | }); 123 | if (!methods) { 124 | throw new Error('Plugin is not compatible!'); 125 | } 126 | 127 | if (job.plugin.getAccounts && !job.plugin.getTransactions) { 128 | throw new Error('Plugin implemented getAccounts(), but getTransactions() is missing'); 129 | } 130 | if (!job.plugin.getAccounts && job.plugin.getTransactions) { 131 | throw new Error('Plugin implemented getTransactions(), but getAccounts() is missing'); 132 | } 133 | 134 | return {success: true}; 135 | } 136 | 137 | /** 138 | * Returns a list of all supported methods this plugin implements 139 | * 140 | * @returns {Promise.} 141 | */ 142 | static async getSupported (job) { 143 | const supported = []; 144 | 145 | ['validateConfig', 'getAccounts', 'getTransactions', 'getMetadata', 'getGoals'].forEach(method => { 146 | if (job.plugin[method] && typeof job.plugin[method] === 'function') { 147 | supported.push(method); 148 | } 149 | }); 150 | 151 | return supported; 152 | } 153 | 154 | /** 155 | * Returns the configuration object for this instance. 156 | * 157 | * @returns {Promise.} 158 | */ 159 | static async getConfig () { 160 | return PluginTools._getConfig(); 161 | } 162 | 163 | /** 164 | * Returns the store object for this key. 165 | * 166 | * @returns {Promise.} 167 | */ 168 | static async getStoreValue (key) { 169 | return new Promise((resolve, reject) => { 170 | const callback = job => { 171 | if (job.method === 'get' && job.key === key) { 172 | process.removeListener('message', callback); 173 | 174 | if (job.value !== undefined) { 175 | resolve(job.value); 176 | } else { 177 | reject(job.error); 178 | } 179 | } 180 | }; 181 | 182 | process.on('message', callback); 183 | process.send({type: 'get', key}); 184 | }); 185 | } 186 | 187 | /** 188 | * Sets the store value for this key. 189 | * 190 | * @returns {Promise.} 191 | */ 192 | static async setStoreValue (key, value) { 193 | return new Promise((resolve, reject) => { 194 | const callback = job => { 195 | if (job.method === 'set' && job.key === key) { 196 | process.removeListener('message', callback); 197 | 198 | if (!job.error) { 199 | resolve(); 200 | } else { 201 | reject(job.error); 202 | } 203 | } 204 | }; 205 | 206 | process.on('message', callback); 207 | process.send({type: 'set', key, value}); 208 | }); 209 | } 210 | 211 | /** 212 | * Validates the given configuration data… 213 | * 214 | * @returns {Promise.} 215 | */ 216 | static async validateConfig (job) { 217 | if (!job.plugin.validateConfig) { 218 | return {valid: true}; 219 | } 220 | 221 | try { 222 | await job.plugin.validateConfig(); 223 | return {valid: true}; 224 | } 225 | catch (error) { 226 | if (error instanceof PluginTools.ConfigurationError) { 227 | return {valid: false, errors: [error.toJSON()]}; 228 | } 229 | else if (error instanceof PluginTools.ConfigurationErrors) { 230 | return {valid: false, errors: error.toJSON()}; 231 | } 232 | else { 233 | throw error; 234 | } 235 | } 236 | } 237 | 238 | /** 239 | * Get Accounts 240 | * 241 | * @param {object} job 242 | * @returns {Promise.} 243 | */ 244 | static async getAccounts (job) { 245 | const accounts = await job.plugin.getAccounts(); 246 | return accounts.map(account => { 247 | if (!(account instanceof PluginTools.Account)) { 248 | throw new Error('Account has to be instance of PluginTools.Account!'); 249 | } 250 | 251 | return account.toJSON(); 252 | }); 253 | } 254 | 255 | /** 256 | * Get Transactions 257 | * 258 | * @param {object} job 259 | * @returns {Promise.} 260 | */ 261 | static async getTransactions (job) { 262 | const moment = require('moment'); 263 | const transactions = await job.plugin.getTransactions( 264 | job.params.accountId, 265 | moment(job.params.since) 266 | ); 267 | 268 | return transactions.map(transaction => { 269 | if (!(transaction instanceof PluginTools.Transaction)) { 270 | throw new Error('Transaction has to be instance of PluginTools.Transaction!'); 271 | } 272 | 273 | return transaction.toJSON(); 274 | }); 275 | } 276 | 277 | /** 278 | * Get Metadata 279 | * 280 | * @param {object} job 281 | * @returns {Promise.} 282 | */ 283 | static async getMetadata (job) { 284 | let metadata = await job.plugin.getMetadata(job.params); 285 | if (!Array.isArray(metadata)) { 286 | metadata = [metadata]; 287 | } 288 | 289 | return metadata.map(m => { 290 | if (!(m instanceof PluginTools.Split || m instanceof PluginTools.Memo)) { 291 | throw new Error('Objects in metadata has to be instance of PluginTools.Split or PluginTools.Memo'); 292 | } 293 | 294 | return m.toJSON(); 295 | }); 296 | } 297 | 298 | /** 299 | * Get Goals 300 | * 301 | * @param {object} job 302 | * @returns {Promise.} 303 | */ 304 | static async getGoals (job) { 305 | const goals = await job.plugin.getGoals(); 306 | return goals.map(goal => { 307 | if (!(goal instanceof PluginTools.Goal)) { 308 | throw new Error('Goal has to be instance of PluginTools.Goal!'); 309 | } 310 | 311 | return goal.toJSON(); 312 | }); 313 | } 314 | } 315 | 316 | PluginRunner.initialize() 317 | .then(result => { 318 | return PluginRunner.sendResponse(result); 319 | }) 320 | .then(() => { 321 | process.exit(0); 322 | }) 323 | .catch(error => { 324 | console.log('Unexpected Error:', error); 325 | process.exit(1); 326 | }); 327 | -------------------------------------------------------------------------------- /helpers/socketRequestHandler.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const ErrorResponse = require('./errorResponse'); 4 | const LogHelper = require('./log'); 5 | const log = new LogHelper('SocketRequestHandler'); 6 | 7 | 8 | /** 9 | * SocketRequestHandlerHelper 10 | * 11 | * @module helpers/socketRequestHandler 12 | * @class SocketRequestHandlerHelper 13 | */ 14 | class SocketRequestHandler { 15 | /** 16 | * @param {Object} options 17 | * @param {Logic} [options.Logic] 18 | * @param {String} [options.route] 19 | * @param {SocketSession} [options.session] 20 | * @param {Object|Array} [options.data] 21 | * @param {Function} [options.cb] 22 | */ 23 | constructor(options) { 24 | this.Logic = options.Logic; 25 | this.route = options.route; 26 | this.session = options.session; 27 | this.data = options.data; 28 | this.cb = options.cb; 29 | } 30 | 31 | /** 32 | * Handle the request. 33 | * Requires all options in constructor to be set. 34 | * 35 | * @returns {SocketRequestHandler} 36 | */ 37 | run() { 38 | const cb = this.cb; 39 | 40 | try { 41 | this.checkSession() 42 | .then(session => this.runLogic(session)) 43 | .then(response => this.success(response), error => this.error(error)) 44 | .catch(function (err) { 45 | log.error(err); 46 | 47 | try { 48 | cb({ 49 | error: 500, 50 | message: 'Unknown internal error (1)', 51 | attributes: {} 52 | }); 53 | return; 54 | } 55 | catch (sendErr) { 56 | log.debug(sendErr); 57 | } 58 | }); 59 | } 60 | catch (err) { 61 | log.error(err); 62 | 63 | try { 64 | cb({ 65 | error: 500, 66 | message: 'Unknown internal error (2)', 67 | attributes: {} 68 | }); 69 | return; 70 | } 71 | catch (sendErr) { 72 | log.debug(sendErr); 73 | } 74 | } 75 | 76 | return this; 77 | } 78 | 79 | /** 80 | * Checks the Session 81 | * @returns {Promise} 82 | */ 83 | checkSession() { 84 | const Logic = this.Logic; 85 | const route = this.route; 86 | const data = this.data; 87 | const session = this.session; 88 | 89 | if (!session.isAuthenticated() && Logic.getModelName() === 'session' && route === 'create') { 90 | return Promise.resolve({ 91 | name: data.email, 92 | pass: data.password 93 | }); 94 | } 95 | 96 | if (!session.isAuthenticated()) { 97 | return Promise.reject(new ErrorResponse( 98 | 401, 99 | 'You are not authenticated. Use the `auth` event to login or create a new session…' 100 | )); 101 | } 102 | 103 | return Promise.resolve(session.getSessionModel()); 104 | } 105 | 106 | /** 107 | * Runs the logic (Logic.get etc.) for 108 | * the given request 109 | * 110 | * @param {Model} session 111 | * @returns {Promise} 112 | */ 113 | runLogic(session) { 114 | const Logic = this.Logic; 115 | const method = 'serve' + this.route.substr(0, 1).toUpperCase() + this.route.substr(1); 116 | 117 | const parameters = new URLSearchParams(this.data.id); 118 | const params = {}; 119 | parameters.forEach((value, key) => { 120 | params[key] = value; 121 | }); 122 | 123 | const options = { 124 | id: this.data.id || null, 125 | body: this.data || {}, 126 | session: session, 127 | params, 128 | setSession: session => { 129 | this.session.setSessionModel(session); 130 | } 131 | }; 132 | 133 | return Logic[method](options).catch(e => { 134 | throw e; 135 | }); 136 | } 137 | 138 | /** 139 | * Yeah! We need a success response… 140 | * @param {Object|Array} result 141 | */ 142 | success(result) { 143 | this.cb(result || {}); 144 | } 145 | 146 | /** 147 | * Oups. We need a error response… 148 | * @param {Error} err 149 | */ 150 | error(err) { 151 | const cb = this.cb; 152 | 153 | if (err instanceof Error && !(err instanceof ErrorResponse)) { 154 | err = new ErrorResponse(500, err, { 155 | reference: log.error(err) 156 | }); 157 | } 158 | if (err instanceof ErrorResponse) { 159 | cb(err.toJSON()); 160 | return; 161 | } 162 | 163 | throw err; 164 | } 165 | } 166 | 167 | 168 | module.exports = SocketRequestHandler; 169 | -------------------------------------------------------------------------------- /helpers/socketSession.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const bcrypt = require('bcryptjs'); 4 | const ErrorResponse = require('./errorResponse'); 5 | const DatabaseHelper = require('./database'); 6 | 7 | /** 8 | * SocketSession 9 | * 10 | * @module helpers/socketSession 11 | * @class SocketSession 12 | */ 13 | class SocketSession { 14 | constructor() { 15 | this.session = null; 16 | } 17 | 18 | /** 19 | * Try to authenticate the session with the given data… 20 | * 21 | * @param {Object} data 22 | * @param {String} data.id Session identifier 23 | * @param {String} data.secret Session secret 24 | * @returns {Promise} 25 | */ 26 | async authenticate(data) { 27 | if (!data.id) { 28 | throw new ErrorResponse(401, 'Error in `auth` data: attribute `id` missing…'); 29 | } 30 | if (!data.secret) { 31 | throw new ErrorResponse(401, 'Error in `auth` data: attribute `secret` missing…'); 32 | } 33 | 34 | const session = await DatabaseHelper.get('session').findOne({ 35 | where: { 36 | id: data.id || data.name 37 | }, 38 | include: [{ 39 | model: DatabaseHelper.get('user') 40 | }] 41 | }); 42 | if (!session) { 43 | throw new ErrorResponse(401, 'Not able to authorize: Is session id and secret correct?'); 44 | } 45 | if (session.mobilePairing) { 46 | throw new ErrorResponse(401, 'Not able to authorize: mobile auth flow not yet implemented for sockets'); 47 | } 48 | 49 | const isSessionCorrect = await bcrypt.compare(data.secret, session.secret); 50 | if (!isSessionCorrect) { 51 | throw new ErrorResponse(401, 'Not able to authorize: Is session id and secret correct?'); 52 | } 53 | 54 | const RepositoryHelper = require('../helpers/repository'); 55 | const terms = await RepositoryHelper.getTerms(); 56 | const moment = require('moment'); 57 | if( 58 | session.user.acceptedTermVersion === null || 59 | session.user.acceptedTermVersion < terms.version - 1 || 60 | session.user.acceptedTermVersion === terms.version - 1 && moment().isSameOrAfter(terms.validFrom) 61 | ) { 62 | throw new ErrorResponse(401, 'Not able to login: User has not accept the current terms!', { 63 | attributes: { 64 | acceptedTerms: 'Is required to be set to the current term version.' 65 | }, 66 | extra: { 67 | tos: terms.tos.defaultUrl, 68 | privacy: terms.privacy.defaultUrl 69 | } 70 | }); 71 | } 72 | 73 | this.session = session; 74 | return session; 75 | } 76 | 77 | /** 78 | * Return true, if this session is a authenticated one… 79 | * @returns {boolean} 80 | */ 81 | isAuthenticated() { 82 | return !!this.session; 83 | } 84 | 85 | /** 86 | * Returns the session model 87 | * @returns {null} 88 | */ 89 | getSessionModel() { 90 | return this.session || null; 91 | } 92 | 93 | /** 94 | * Sets the session model 95 | * @returns {SocketSession} 96 | */ 97 | setSessionModel(session) { 98 | this.session = session; 99 | return this; 100 | } 101 | } 102 | 103 | 104 | module.exports = SocketSession; 105 | -------------------------------------------------------------------------------- /logic/_.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const ErrorResponse = require('../helpers/errorResponse'); 4 | 5 | class BaseLogic { 6 | static getModelName() { 7 | throw new ErrorResponse(500, 'getModelName() is not overwritten!'); 8 | } 9 | 10 | static getPluralModelName() { 11 | throw new ErrorResponse(500, 'getModelName() is not overwritten!'); 12 | } 13 | 14 | static getModel() { 15 | const DatabaseHelper = require('../helpers/database'); 16 | return DatabaseHelper.get(this.getModelName()); 17 | } 18 | 19 | static getAvailableRoutes() { 20 | return ['create', 'get', 'list', 'update', 'delete']; 21 | } 22 | 23 | static getPathForRoute(route) { 24 | let regex = '/api/' + this.getPluralModelName(); 25 | if (['get', 'update', 'delete'].indexOf(route) > -1) { 26 | regex += '/([a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12})$'; 27 | } 28 | else if (route === 'list') { 29 | regex += '/?([a-zA-Z]*)$'; 30 | } 31 | 32 | return new RegExp(regex, ''); // eslint-disable-line security/detect-non-literal-regexp 33 | } 34 | 35 | static async serveCreate(options) { 36 | if (!this.create) { 37 | throw new ErrorResponse(501, 'Not implemented yet!'); 38 | } 39 | 40 | const {model, secrets} = await this.create(options.body, options); 41 | return this.format(model, secrets || {}, options); 42 | } 43 | 44 | static async serveGet(options) { 45 | if (!this.get) { 46 | throw new ErrorResponse(501, 'Not implemented yet!'); 47 | } 48 | 49 | const model = await this.get(options.id, options); 50 | if (!model) { 51 | throw new ErrorResponse(404, 'Not Found'); 52 | } 53 | 54 | return this.format(model, {}, options); 55 | } 56 | 57 | static async serveList(options) { 58 | if (!this.list) { 59 | throw new ErrorResponse(501, 'Not implemented yet!'); 60 | } 61 | 62 | const models = await this.list(options.params, options); 63 | return Promise.all(models.map(model => 64 | this.format(model, {}, options) 65 | )); 66 | } 67 | 68 | static async serveUpdate(options) { 69 | if (!this.update || !this.get) { 70 | throw new ErrorResponse(501, 'Not implemented yet!'); 71 | } 72 | if (!options.id) { 73 | throw new ErrorResponse(400, 'You need an ID to make an update!'); 74 | } 75 | 76 | const before = await this.get(options.id, options); 77 | if (!before) { 78 | throw new ErrorResponse(404, 'Not Found'); 79 | } 80 | 81 | const {model, secrets} = await this.update(before, options.body, options); 82 | return this.format(model, secrets || {}, options); 83 | } 84 | 85 | static async serveDelete(options) { 86 | if (!options.id) { 87 | throw new ErrorResponse(400, 'You need an ID to make an update!'); 88 | } 89 | if (!this.delete) { 90 | throw new ErrorResponse(501, 'Not implemented yet!'); 91 | } 92 | 93 | const model = await this.get(options.id, options); 94 | if (!model) { 95 | throw new ErrorResponse(404, 'Not Found'); 96 | } 97 | 98 | await this.delete(model, options); 99 | } 100 | } 101 | 102 | 103 | module.exports = BaseLogic; -------------------------------------------------------------------------------- /logic/account.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = require('underscore'); 4 | const BaseLogic = require('./_'); 5 | const ErrorResponse = require('../helpers/errorResponse'); 6 | 7 | class AccountLogic extends BaseLogic { 8 | static getModelName () { 9 | return 'account'; 10 | } 11 | 12 | static getPluralModelName () { 13 | return 'accounts'; 14 | } 15 | 16 | static async format (account) { 17 | const DatabaseHelper = require('../helpers/database'); 18 | 19 | const [transactionCount, transactionsWithoutUnits, notTransfer, transferOn, transferOff] = await Promise.all([ 20 | DatabaseHelper.get('transaction').findOne({ 21 | attributes: [ 22 | [DatabaseHelper.count('id'), 'value'] 23 | ], 24 | where: { 25 | accountId: account.id 26 | } 27 | }), 28 | DatabaseHelper.query( 29 | 'SELECT SUM(`amount`) AS `value` ' + 30 | 'FROM `transactions` AS `transaction` ' + 31 | 'WHERE ' + 32 | ' (SELECT COUNT(*) FROM `units` WHERE `units`.`transactionId` = `transaction`.`id`) = 0 AND ' + 33 | ' `accountId` = "' + account.id + '";' 34 | ), 35 | DatabaseHelper.query( 36 | 'SELECT SUM(`amount`) AS `value` ' + 37 | 'FROM `units` AS `unit` ' + 38 | 'WHERE ' + 39 | ' `type` != "TRANSFER" AND ' + 40 | ' `transactionId` IN (SELECT `id` FROM `transactions` WHERE `accountId` = "' + account.id + '");' 41 | ), 42 | DatabaseHelper.query( 43 | 'SELECT SUM(`amount`) AS `value` ' + 44 | 'FROM `units` AS `unit` ' + 45 | 'WHERE ' + 46 | ' `type` = "TRANSFER" AND ' + 47 | ' `transactionId` IN (SELECT `id` FROM `transactions` WHERE `accountId` = "' + account.id + '");' 48 | ), 49 | DatabaseHelper.get('unit').findOne({ 50 | attributes: [ 51 | [DatabaseHelper.sum('amount'), 'value'] 52 | ], 53 | where: { 54 | type: 'TRANSFER', 55 | transferAccountId: account.id 56 | } 57 | }) 58 | ]); 59 | 60 | return { 61 | id: account.id, 62 | name: account.name, 63 | type: account.type, 64 | number: account.number, 65 | balance: (parseInt(notTransfer[0].value, 10) || 0) + 66 | (parseInt(transactionsWithoutUnits[0].value, 10) || 0) + 67 | (parseInt(transferOn[0].value, 10) || 0) - 68 | (parseInt(transferOff.dataValues.value, 10) || 0), 69 | transactions: parseInt(transactionCount.dataValues.value, 10) || 0, 70 | documentId: account.documentId, 71 | pluginInstanceId: account.pluginInstanceId 72 | }; 73 | } 74 | 75 | static getValidTypeValues () { 76 | return ['checking', 'savings', 'creditCard', 'cash', 'paypal', 'mortgage', 'asset', 'loan']; 77 | } 78 | 79 | static async create (body, options) { 80 | const DatabaseHelper = require('../helpers/database'); 81 | const model = this.getModel().build(); 82 | 83 | model.name = body.name; 84 | if (!model.name) { 85 | throw new ErrorResponse(400, 'Account requires attribute `name`…', { 86 | attributes: { 87 | name: 'Is required!' 88 | } 89 | }); 90 | } 91 | if (model.name.length > 255) { 92 | throw new ErrorResponse(400, 'Attribute `Account.name` has a maximum length of 255 chars, sorry…', { 93 | attributes: { 94 | name: 'Is too long, only 255 characters allowed…' 95 | } 96 | }); 97 | } 98 | 99 | model.type = body.type; 100 | if (this.getValidTypeValues().indexOf(model.type) === -1) { 101 | throw new ErrorResponse( 102 | 400, 103 | 'Attribute `Account.type` is invalid, must be one of: ' + this.getValidTypeValues().join(', '), { 104 | attributes: { 105 | name: 'Invalid value' 106 | } 107 | }); 108 | } 109 | 110 | model.number = body.number || null; 111 | model.hidden = !!body.hidden; 112 | 113 | const documentModel = await DatabaseHelper.get('document').findOne({ 114 | where: {id: body.documentId}, 115 | attributes: ['id'], 116 | include: [{ 117 | model: DatabaseHelper.get('user'), 118 | attributes: [], 119 | where: { 120 | id: options.session.userId 121 | } 122 | }] 123 | }); 124 | 125 | if (!documentModel) { 126 | throw new ErrorResponse(401, 'Not able to create account: linked document not found.'); 127 | } 128 | 129 | model.documentId = documentModel.id; 130 | await model.save(); 131 | 132 | return {model}; 133 | } 134 | 135 | static async get (id, options) { 136 | const DatabaseHelper = require('../helpers/database'); 137 | return this.getModel().findOne({ 138 | where: { 139 | id: id 140 | }, 141 | include: [{ 142 | model: DatabaseHelper.get('document'), 143 | attributes: ['id'], 144 | include: DatabaseHelper.includeUserIfNotAdmin(options.session) 145 | }] 146 | }); 147 | } 148 | 149 | static async list (params, options) { 150 | const DatabaseHelper = require('../helpers/database'); 151 | 152 | const sql = { 153 | include: [{ 154 | model: DatabaseHelper.get('document'), 155 | attributes: [], 156 | required: true, 157 | include: options.session.user.isAdmin ? [] : [{ 158 | model: DatabaseHelper.get('user'), 159 | attributes: [], 160 | required: true, 161 | where: { 162 | id: options.session.userId 163 | } 164 | }] 165 | }], 166 | order: [ 167 | ['name', 'ASC'] 168 | ] 169 | }; 170 | 171 | _.each(params, (id, k) => { 172 | if (k === 'document') { 173 | sql.include[0].where = {id}; 174 | } 175 | else if (k === 'pluginInstance' && (!id || id === 'null')) { 176 | sql.where = sql.where || {}; 177 | sql.where.pluginInstanceId = null; 178 | } 179 | else if (k === 'pluginInstance') { 180 | sql.where = sql.where || {}; 181 | sql.where.pluginInstanceId = id; 182 | } 183 | else { 184 | throw new ErrorResponse(400, 'Unknown filter `' + k + '`!'); 185 | } 186 | }); 187 | 188 | return this.getModel().findAll(sql); 189 | } 190 | 191 | static async update (model, body) { 192 | if (body.name !== undefined) { 193 | model.name = body.name; 194 | } 195 | if (!model.name) { 196 | throw new ErrorResponse(400, 'Account requires attribute `name`…', { 197 | attributes: { 198 | name: 'Is required!' 199 | } 200 | }); 201 | } 202 | if (model.name.length > 255) { 203 | throw new ErrorResponse(400, 'Attribute `Account.name` has a maximum length of 255 chars, sorry…', { 204 | attributes: { 205 | name: 'Is too long, only 255 characters allowed…' 206 | } 207 | }); 208 | } 209 | 210 | if (body.type !== null) { 211 | model.type = body.type; 212 | } 213 | if (this.getValidTypeValues().indexOf(model.type) === -1) { 214 | throw new ErrorResponse( 215 | 400, 216 | 'Attribute `Account.type` is invalid, must be one of: ' + this.getValidTypeValues().join(', '), { 217 | attributes: { 218 | name: 'Invalid value' 219 | } 220 | }); 221 | } 222 | 223 | if (body.number !== undefined && !model.pluginInstanceId) { 224 | model.number = body.number || null; 225 | } 226 | 227 | if (body.hidden) { 228 | model.hidden = !!body.hidden; 229 | } 230 | 231 | if (body.pluginInstanceId === null && model.pluginInstanceId) { 232 | model.pluginInstanceId = null; 233 | } 234 | 235 | await model.save(); 236 | return {model}; 237 | } 238 | 239 | static async delete (model) { 240 | const DatabaseHelper = require('../helpers/database'); 241 | if (model.pluginInstanceId) { 242 | throw new ErrorResponse(400, 'It\'s not allowed to destroy managed accounts.'); 243 | } 244 | 245 | const firstTransaction = await DatabaseHelper.get('transaction').findOne({ 246 | attributes: ['time'], 247 | where: { 248 | accountId: model.id 249 | }, 250 | order: [['time', 'ASC']], 251 | limit: 1 252 | }); 253 | 254 | await model.destroy(); 255 | 256 | // Account was empty -> no need to recalculate document 257 | if (!firstTransaction) { 258 | return; 259 | } 260 | 261 | const moment = require('moment'); 262 | const month = moment(firstTransaction.time).startOf('month'); 263 | 264 | const PortionsLogic = require('../logic/portion'); 265 | const SummaryLogic = require('../logic/summary'); 266 | 267 | await PortionsLogic.recalculatePortionsFrom({month, documentId: model.documentId}); 268 | await SummaryLogic.recalculateSummariesFrom(model.documentId, month); 269 | } 270 | 271 | /** 272 | * Method to manually send the `updated` event 273 | * for an account. As an account includes the 274 | * budget and the number of transactions, this 275 | * is required when touching transactions. 276 | */ 277 | static sendUpdatedEvent (model) { 278 | const PluginHelper = require('../helpers/plugin'); 279 | PluginHelper.events().emit('update', { 280 | action: 'updated', 281 | name: 'account', 282 | model 283 | }); 284 | } 285 | } 286 | 287 | module.exports = AccountLogic; 288 | -------------------------------------------------------------------------------- /logic/budget-guess.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const BaseLogic = require('./_'); 4 | const ErrorResponse = require('../helpers/errorResponse'); 5 | 6 | class BudgetGuessLogic extends BaseLogic { 7 | static getModelName () { 8 | return 'budget-guess'; 9 | } 10 | 11 | static getPluralModelName () { 12 | return 'budget-guesses'; 13 | } 14 | 15 | static format (guess) { 16 | return guess; 17 | } 18 | 19 | static async list (params) { 20 | if (!params.transactionId) { 21 | throw new ErrorResponse(400, 'Guess budget requires attribute `transactionId`…', { 22 | attributes: { 23 | transactionId: 'Is required!' 24 | } 25 | }); 26 | } 27 | 28 | const TransactionLogic = require('./transaction'); 29 | const transaction = await TransactionLogic.get(params.transactionId); 30 | return TransactionLogic.guessBudget(transaction); 31 | } 32 | } 33 | 34 | module.exports = BudgetGuessLogic; 35 | -------------------------------------------------------------------------------- /logic/budget.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = require('underscore'); 4 | const BaseLogic = require('./_'); 5 | const ErrorResponse = require('../helpers/errorResponse'); 6 | 7 | class BudgetLogic extends BaseLogic { 8 | static getModelName() { 9 | return 'budget'; 10 | } 11 | 12 | static getPluralModelName() { 13 | return 'budgets'; 14 | } 15 | 16 | static format(budget) { 17 | return { 18 | id: budget.id, 19 | name: budget.name, 20 | goal: budget.goal, 21 | hidden: budget.hidden, 22 | overspending: budget.overspending, 23 | deletable: budget.units ? budget.units.length === 0 : true, 24 | pluginInstanceId: budget.pluginInstanceId, 25 | categoryId: budget.categoryId, 26 | documentId: budget.category.document.id 27 | }; 28 | } 29 | 30 | static async create(body, options = {}) { 31 | const DatabaseHelper = require('../helpers/database'); 32 | const model = this.getModel().build(); 33 | 34 | model.name = body.name; 35 | if (!model.name) { 36 | throw new ErrorResponse(400, 'Budget requires attribute `name`…', { 37 | attributes: { 38 | name: 'Is required!' 39 | } 40 | }); 41 | } 42 | if (model.name.length > 255) { 43 | throw new ErrorResponse(400, 'Attribute `Budget.name` has a maximum length of 255 chars, sorry…', { 44 | attributes: { 45 | name: 'Is too long, only 255 characters allowed…' 46 | } 47 | }); 48 | } 49 | 50 | model.goal = parseInt(body.goal, 10) || null; 51 | 52 | const categoryModel = await DatabaseHelper.get('category').findOne({ 53 | where: {id: body.categoryId}, 54 | attributes: ['id'], 55 | include: [{ 56 | model: DatabaseHelper.get('document'), 57 | attributes: ['id'], 58 | include: DatabaseHelper.includeUserIfNotAdmin(options.session) 59 | }] 60 | }); 61 | if (!categoryModel) { 62 | throw new ErrorResponse(400, 'Not able to create budget: linked category not found.'); 63 | } 64 | 65 | model.categoryId = categoryModel.id; 66 | model.category = categoryModel; 67 | await model.save(); 68 | 69 | return {model}; 70 | } 71 | 72 | static async get(id, options = {}) { 73 | const DatabaseHelper = require('../helpers/database'); 74 | return this.getModel().findOne({ 75 | where: { 76 | id: id 77 | }, 78 | include: [ 79 | { 80 | model: DatabaseHelper.get('category'), 81 | attributes: ['id'], 82 | include: [ 83 | { 84 | model: DatabaseHelper.get('document'), 85 | attributes: ['id'], 86 | include: DatabaseHelper.includeUserIfNotAdmin(options.session) 87 | } 88 | ] 89 | }, 90 | { 91 | model: DatabaseHelper.get('unit'), 92 | attributes: ['id'] 93 | } 94 | ] 95 | }); 96 | } 97 | 98 | static async list(params, options) { 99 | const DatabaseHelper = require('../helpers/database'); 100 | 101 | const sql = { 102 | include: [ 103 | { 104 | model: DatabaseHelper.get('category'), 105 | attributes: ['id'], 106 | required: true, 107 | include: [ 108 | { 109 | model: DatabaseHelper.get('document'), 110 | attributes: ['id'], 111 | required: true, 112 | include: options.session.user.isAdmin ? [] : [{ 113 | model: DatabaseHelper.get('user'), 114 | attributes: [], 115 | required: true, 116 | where: { 117 | id: options.session.userId 118 | } 119 | }] 120 | } 121 | ] 122 | }, 123 | { 124 | model: DatabaseHelper.get('unit'), 125 | attributes: ['id'] 126 | } 127 | ], 128 | order: [ 129 | ['name', 'ASC'] 130 | ] 131 | }; 132 | 133 | _.each(params, (id, k) => { 134 | if (k === 'category') { 135 | sql.include[0].where = {id}; 136 | } 137 | else if (k === 'document') { 138 | sql.include[0].include[0].where = {id}; 139 | } 140 | else if (k === 'hidden') { 141 | sql.where = { 142 | hidden: id === '1' || id === 'true' 143 | }; 144 | } 145 | else { 146 | throw new ErrorResponse(400, 'Unknown filter `' + k + '`!'); 147 | } 148 | }); 149 | 150 | return this.getModel().findAll(sql); 151 | } 152 | 153 | static async update(model, body, options) { 154 | const DatabaseHelper = require('../helpers/database'); 155 | 156 | if (body.name !== undefined) { 157 | model.name = body.name; 158 | } 159 | if (!model.name) { 160 | throw new ErrorResponse(400, 'Budget requires attribute `name`…', { 161 | attributes: { 162 | name: 'Is required!' 163 | } 164 | }); 165 | } 166 | if (model.name.length > 255) { 167 | throw new ErrorResponse(400, 'Attribute `Budget.name` has a maximum length of 255 chars, sorry…', { 168 | attributes: { 169 | name: 'Is too long, only 255 characters allowed…' 170 | } 171 | }); 172 | } 173 | 174 | if (body.goal !== undefined && !model.pluginInstanceId) { 175 | model.goal = parseInt(body.goal, 10) || null; 176 | } 177 | else if (body.goal !== undefined && model.goal !== body.goal) { 178 | throw new ErrorResponse(400, 'Attribute `Budget.goal` is managed by a plugin, you are not allowed to update it…', { 179 | attributes: { 180 | name: 'Managed by a plugin, not allowed to be changed…' 181 | } 182 | }); 183 | } 184 | if (body.hidden !== undefined) { 185 | model.hidden = !!body.hidden; 186 | } 187 | 188 | if (!body.categoryId) { 189 | return model.save(); 190 | } 191 | 192 | const categoryModel = await DatabaseHelper.get('category').findOne({ 193 | where: {id: body.categoryId}, 194 | attributes: ['id'], 195 | include: [{ 196 | model: DatabaseHelper.get('document'), 197 | attributes: [], 198 | include: [{ 199 | model: DatabaseHelper.get('user'), 200 | attributes: [], 201 | where: { 202 | id: options.session.userId 203 | } 204 | }] 205 | }] 206 | }); 207 | if (!categoryModel) { 208 | throw new ErrorResponse(400, 'Not able to update budget: linked category not found.'); 209 | } 210 | 211 | model.categoryId = categoryModel.id; 212 | await model.save(); 213 | 214 | return {model}; 215 | } 216 | 217 | static async delete(model) { 218 | if(model.units.length) { 219 | throw new ErrorResponse( 220 | 501, 221 | `It's not allowed to delete this budget, as it's used to budget ${model.units.length} transactions.` 222 | ); 223 | } 224 | 225 | await model.destroy(); 226 | } 227 | } 228 | 229 | module.exports = BudgetLogic; -------------------------------------------------------------------------------- /logic/category.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = require('underscore'); 4 | const BaseLogic = require('./_'); 5 | const ErrorResponse = require('../helpers/errorResponse'); 6 | 7 | class CategoryLogic extends BaseLogic { 8 | static getModelName() { 9 | return 'category'; 10 | } 11 | 12 | static getPluralModelName() { 13 | return 'categories'; 14 | } 15 | 16 | static format(category) { 17 | return { 18 | id: category.id, 19 | name: category.name, 20 | documentId: category.documentId 21 | }; 22 | } 23 | 24 | static async create(body, options) { 25 | const DatabaseHelper = require('../helpers/database'); 26 | const model = this.getModel().build(); 27 | 28 | model.name = body.name; 29 | if (!model.name) { 30 | throw new ErrorResponse(400, 'Category requires attribute `name`…', { 31 | attributes: { 32 | name: 'Is required!' 33 | } 34 | }); 35 | } 36 | if (model.name.length > 255) { 37 | throw new ErrorResponse(400, 'Attribute `Category.name` has a maximum length of 255 chars, sorry…', { 38 | attributes: { 39 | name: 'Is too long, only 255 characters allowed…' 40 | } 41 | }); 42 | } 43 | 44 | return DatabaseHelper.get('document') 45 | .findOne({ 46 | where: {id: body.documentId}, 47 | attributes: ['id'], 48 | include: [{ 49 | model: DatabaseHelper.get('user'), 50 | attributes: [], 51 | where: { 52 | id: options.session.userId 53 | } 54 | }] 55 | }) 56 | .then(function (documentModel) { 57 | if (!documentModel) { 58 | throw new ErrorResponse(400, 'Not able to create category: linked document not found.'); 59 | } 60 | 61 | model.documentId = documentModel.id; 62 | return model.save(); 63 | }) 64 | .then(function (model) { 65 | return {model}; 66 | }) 67 | .catch(e => { 68 | throw e; 69 | }); 70 | } 71 | 72 | static async get(id, options) { 73 | const DatabaseHelper = require('../helpers/database'); 74 | return this.getModel().findOne({ 75 | where: { 76 | id: id 77 | }, 78 | include: [{ 79 | model: DatabaseHelper.get('document'), 80 | attributes: [], 81 | include: DatabaseHelper.includeUserIfNotAdmin(options.session) 82 | }] 83 | }); 84 | } 85 | 86 | static async list(params, options) { 87 | const DatabaseHelper = require('../helpers/database'); 88 | 89 | const sql = { 90 | include: [{ 91 | model: DatabaseHelper.get('document'), 92 | attributes: [], 93 | required: true, 94 | include: options.session.user.isAdmin ? [] : [{ 95 | model: DatabaseHelper.get('user'), 96 | attributes: [], 97 | required: true, 98 | where: { 99 | id: options.session.userId 100 | } 101 | }] 102 | }], 103 | order: [ 104 | ['name', 'ASC'] 105 | ] 106 | }; 107 | 108 | _.each(params, (id, k) => { 109 | if (k === 'document') { 110 | sql.include[0].where = {id}; 111 | } 112 | else { 113 | throw new ErrorResponse(400, 'Unknown filter `' + k + '`!'); 114 | } 115 | }); 116 | 117 | return this.getModel().findAll(sql); 118 | } 119 | 120 | static async update(model, body) { 121 | if (body.name !== undefined) { 122 | model.name = body.name; 123 | } 124 | if (!model.name) { 125 | throw new ErrorResponse(400, 'Category requires attribute `name`…', { 126 | attributes: { 127 | name: 'Is required!' 128 | } 129 | }); 130 | } 131 | if (model.name.length > 255) { 132 | throw new ErrorResponse(400, 'Attribute `Category.name` has a maximum length of 255 chars, sorry…', { 133 | attributes: { 134 | name: 'Is too long, only 255 characters allowed…' 135 | } 136 | }); 137 | } 138 | 139 | await model.save(); 140 | return {model}; 141 | } 142 | 143 | static async delete(model) { 144 | const DatabaseHelper = require('../helpers/database'); 145 | const count = await DatabaseHelper.get('budget').count({ 146 | where: { 147 | categoryId: model.id 148 | } 149 | }); 150 | if (count > 0) { 151 | throw new ErrorResponse( 152 | 501, 153 | 'It\'s not allowed to delete categories which still have budgets.' 154 | ); 155 | } 156 | 157 | await model.destroy(); 158 | } 159 | } 160 | 161 | module.exports = CategoryLogic; -------------------------------------------------------------------------------- /logic/component.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const BaseLogic = require('./_'); 4 | const RepositoryHelper = require('../helpers/repository'); 5 | 6 | class ComponentLogic extends BaseLogic { 7 | static getModelName() { 8 | return 'component'; 9 | } 10 | 11 | static getPluralModelName() { 12 | return 'components'; 13 | } 14 | 15 | static format(component) { 16 | return component; 17 | } 18 | 19 | static async get(id) { 20 | return RepositoryHelper.getComponent(id); 21 | } 22 | 23 | static async list() { 24 | return RepositoryHelper.getComponents(); 25 | } 26 | } 27 | 28 | module.exports = ComponentLogic; 29 | -------------------------------------------------------------------------------- /logic/document.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const BaseLogic = require('./_'); 4 | const DatabaseHelper = require('../helpers/database'); 5 | const ErrorResponse = require('../helpers/errorResponse'); 6 | 7 | class DocumentLogic extends BaseLogic { 8 | static getModelName () { 9 | return 'document'; 10 | } 11 | 12 | static getPluralModelName () { 13 | return 'documents'; 14 | } 15 | 16 | static format (document, secrets, options) { 17 | const r = { 18 | id: document.id, 19 | name: document.name, 20 | settings: {} 21 | }; 22 | 23 | (document.settings || []).forEach(function (setting) { 24 | r.settings[setting.key] = JSON.parse(setting.value); 25 | }); 26 | 27 | if (options.session.user.isAdmin && document.users) { 28 | r.users = document.users.map(user => ({ 29 | id: user.id, 30 | email: user.email 31 | })); 32 | } 33 | else if (options.session.user) { 34 | r.users = [{ 35 | id: options.session.user.id, 36 | email: options.session.user.email 37 | }]; 38 | } 39 | 40 | return r; 41 | } 42 | 43 | static async create (attributes, options) { 44 | const model = this.getModel().build(); 45 | 46 | model.name = attributes.name; 47 | if (!model.name) { 48 | throw new ErrorResponse(400, 'Documents require an attribute `name`…', { 49 | attributes: { 50 | name: 'Is required!' 51 | } 52 | }); 53 | } 54 | 55 | await model.save(); 56 | let jobs = [model.addUser(options.session.user)]; 57 | 58 | // Settings 59 | Object.entries(attributes.settings || {}).forEach(([k, v]) => { 60 | jobs.push( 61 | DatabaseHelper 62 | .get('setting') 63 | .create( 64 | { 65 | key: k, 66 | value: JSON.stringify(v), 67 | documentId: model.id 68 | } 69 | ) 70 | .then(setting => { 71 | model.settings = model.settings || []; 72 | model.settings.push(setting); 73 | }) 74 | .catch(e => { 75 | throw e; 76 | }) 77 | ); 78 | }); 79 | 80 | await Promise.all(jobs); 81 | return {model}; 82 | } 83 | 84 | static async get (id, options) { 85 | const sql = { 86 | where: {id}, 87 | include: [ 88 | { 89 | model: DatabaseHelper.get('user'), 90 | attributes: ['id', 'email'] 91 | }, 92 | { 93 | model: DatabaseHelper.get('setting') 94 | } 95 | ] 96 | }; 97 | 98 | if (!options.session.user.isAdmin) { 99 | sql.include[0].where = { 100 | id: options.session.userId 101 | }; 102 | } 103 | 104 | return this.getModel().findOne(sql); 105 | } 106 | 107 | static async list (params, options) { 108 | const sql = { 109 | include: [ 110 | { 111 | model: DatabaseHelper.get('user'), 112 | attributes: ['id', 'email'] 113 | }, 114 | { 115 | model: DatabaseHelper.get('setting') 116 | } 117 | ], 118 | order: [ 119 | ['name', 'ASC'] 120 | ] 121 | }; 122 | 123 | if (!options.session.user.isAdmin) { 124 | sql.include[0].where = { 125 | id: options.session.userId 126 | }; 127 | } 128 | 129 | return this.getModel().findAll(sql); 130 | } 131 | 132 | static async update (model, body, options) { 133 | if (body.name !== undefined && !body.name) { 134 | throw new ErrorResponse(400, 'Document name can\'t be empty…', { 135 | attributes: { 136 | name: 'Is required' 137 | } 138 | }); 139 | } 140 | if (body.name) { 141 | model.name = body.name; 142 | } 143 | 144 | await model.save(); 145 | 146 | // settings 147 | if (body.settings !== undefined) { 148 | 149 | // delete old and unused settings 150 | await Promise.all(model.settings.map(setting => { 151 | if (Object.keys(body.settings).indexOf(setting.key) === -1) { 152 | return setting.destroy(); 153 | } 154 | 155 | return Promise.resolve(); 156 | })); 157 | 158 | // compare new vs old 159 | model.settings = await Promise.all(Object.entries(body.settings).map(([key, value]) => { 160 | const oldSetting = model.settings.find(s => s.key === key); 161 | const newValue = JSON.stringify(value); 162 | 163 | if (oldSetting && oldSetting.value === newValue) { 164 | return Promise.resolve(oldSetting); 165 | } 166 | else if (oldSetting) { 167 | oldSetting.value = newValue; 168 | return oldSetting.save(); 169 | } 170 | else { 171 | return DatabaseHelper 172 | .get('setting') 173 | .create({ 174 | key: key, 175 | value: newValue, 176 | documentId: model.id 177 | }); 178 | } 179 | })); 180 | } 181 | 182 | // leave document for non admins 183 | if (!options.session.user.isAdmin && Array.isArray(body.users) && body.users.length === 0) { 184 | const userModel = model.users.find(user => user.id === options.session.user.id); 185 | model.users.splice(model.users.indexOf(userModel), 1); 186 | await model.removeUser(userModel); 187 | } 188 | 189 | // all done for non-admins 190 | if (!options.session.user.isAdmin || !body.users) { 191 | return {model}; 192 | } 193 | 194 | 195 | // add new users 196 | await Promise.all((body.users || []).map(async function (user) { 197 | const thisModel = model.users.find(u => u.id === user.id); 198 | if (thisModel) { 199 | return; 200 | } 201 | 202 | const userModel = await DatabaseHelper.get('user').findByPk(user.id); 203 | if (!userModel) { 204 | throw new ErrorResponse(400, 'Unable to add user to document: User not found', { 205 | attributes: { 206 | users: 'Unknown user specified…' 207 | } 208 | }); 209 | } 210 | 211 | model.users.push(userModel); 212 | return model.addUser(userModel); 213 | })); 214 | 215 | // remove old users 216 | await Promise.all((model.users || []).map(async function (userModel) { 217 | const plainUser = body.users.find(u => u.id === userModel.id); 218 | if (plainUser) { 219 | return; 220 | } 221 | 222 | model.users.splice(model.users.indexOf(userModel), 1); 223 | return model.removeUser(userModel); 224 | })); 225 | 226 | return {model}; 227 | } 228 | 229 | static delete (model) { 230 | return model.destroy(); 231 | } 232 | } 233 | 234 | module.exports = DocumentLogic; -------------------------------------------------------------------------------- /logic/import.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const BaseLogic = require('./_'); 4 | const ErrorResponse = require('../helpers/errorResponse'); 5 | 6 | 7 | class ImportLogic extends BaseLogic { 8 | static getModelName () { 9 | return 'import'; 10 | } 11 | 12 | static getPluralModelName () { 13 | return 'imports'; 14 | } 15 | 16 | static async format (i) { 17 | return i; 18 | } 19 | 20 | static async create (body, options) { 21 | const ImporterHelper = require('../helpers/importer'); 22 | const DatabaseHelper = require('../helpers/database'); 23 | const TransactionLogic = require('../logic/transaction'); 24 | const AccountLogic = require('../logic/account'); 25 | 26 | const req = options.httpRequest; 27 | if (!req) { 28 | throw new ErrorResponse(400, 'You can only do uploads via HTTP API, sorry…'); 29 | } 30 | 31 | const accountId = req.query.account || req.body.account || req.query.accountId || req.body.accountId; 32 | if (!accountId) { 33 | throw new ErrorResponse(400, 'You need to select an account id before!', { 34 | account: 'Is empty' 35 | }); 36 | } 37 | 38 | const account = await AccountLogic.getModel().findOne({ 39 | where: { 40 | id: accountId 41 | }, 42 | include: [{ 43 | model: DatabaseHelper.get('document'), 44 | attributes: [], 45 | include: DatabaseHelper.includeUserIfNotAdmin(options.session) 46 | }] 47 | }); 48 | if (!account) { 49 | throw new ErrorResponse(400, 'You can only do uploads via HTTP API, sorry…', { 50 | account: 'Is not valid' 51 | }); 52 | } 53 | if (account.pluginInstanceId) { 54 | throw new ErrorResponse(400, 'It\'s not allowed to do an import on managed accounts', { 55 | account: 'Is managed, choose another one' 56 | }); 57 | } 58 | 59 | await Promise.all( 60 | Object.values(req.files).map(file => (async () => { 61 | const transactions = await ImporterHelper.parse(account, { 62 | name: file.name, 63 | data: file.data, 64 | mime: file.mimetype 65 | }); 66 | 67 | await TransactionLogic.syncTransactions(account, transactions); 68 | })()) 69 | ); 70 | 71 | return { 72 | model: { 73 | success: true 74 | } 75 | }; 76 | } 77 | } 78 | 79 | module.exports = ImportLogic; -------------------------------------------------------------------------------- /logic/index.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | require('./account'), 3 | require('./budget'), 4 | require('./budget-guess'), 5 | require('./category'), 6 | require('./component'), 7 | require('./document'), 8 | require('./import'), 9 | require('./payee'), 10 | require('./plugin'), 11 | require('./plugin-config'), 12 | require('./plugin-instance'), 13 | require('./plugin-store'), 14 | require('./portion'), 15 | require('./session'), 16 | require('./setting'), 17 | require('./summary'), 18 | require('./transaction'), 19 | require('./unit'), 20 | require('./user') 21 | ]; -------------------------------------------------------------------------------- /logic/payee.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = require('underscore'); 4 | const BaseLogic = require('./_'); 5 | const ErrorResponse = require('../helpers/errorResponse'); 6 | 7 | class PayeeLogic extends BaseLogic { 8 | static getModelName() { 9 | return 'payee'; 10 | } 11 | 12 | static getPluralModelName() { 13 | return 'payees'; 14 | } 15 | 16 | static format(payee) { 17 | return { 18 | id: payee.id, 19 | name: payee.name, 20 | documentId: payee.documentId 21 | }; 22 | } 23 | 24 | static async create(body, options) { 25 | const DatabaseHelper = require('../helpers/database'); 26 | const model = this.getModel().build(); 27 | 28 | model.name = body.name; 29 | if (!model.name) { 30 | throw new ErrorResponse(400, 'Account requires attribute `name`…', { 31 | attributes: { 32 | name: 'Is required!' 33 | } 34 | }); 35 | } 36 | if (model.name.length > 255) { 37 | throw new ErrorResponse(400, 'Attribute `Account.name` has a maximum length of 255 chars, sorry…', { 38 | attributes: { 39 | name: 'Is too long, only 255 characters allowed…' 40 | } 41 | }); 42 | } 43 | 44 | const documentModel = await DatabaseHelper.get('document').findOne({ 45 | where: {id: body.documentId}, 46 | attributes: ['id'], 47 | include: DatabaseHelper.includeUserIfNotAdmin(options.session) 48 | }); 49 | if (!documentModel) { 50 | throw new ErrorResponse(401, 'Not able to create account: linked document not found.'); 51 | } 52 | 53 | model.documentId = documentModel.id; 54 | await model.save(); 55 | 56 | return {model}; 57 | } 58 | 59 | static async get(id, options) { 60 | const DatabaseHelper = require('../helpers/database'); 61 | return this.getModel().findOne({ 62 | where: { 63 | id: id 64 | }, 65 | include: [{ 66 | model: DatabaseHelper.get('document'), 67 | attributes: ['id'], 68 | required: true, 69 | include: DatabaseHelper.includeUserIfNotAdmin(options.session) 70 | }] 71 | }); 72 | } 73 | 74 | static async list(params, options) { 75 | const DatabaseHelper = require('../helpers/database'); 76 | const moment = require('moment'); 77 | 78 | const sql = { 79 | include: [{ 80 | model: DatabaseHelper.get('document'), 81 | attributes: ['id'], 82 | required: true, 83 | include: DatabaseHelper.includeUserIfNotAdmin(options.session) 84 | }], 85 | order: [ 86 | ['name', 'ASC'] 87 | ] 88 | }; 89 | 90 | _.each(params, (id, k) => { 91 | if (k === 'document') { 92 | sql.include[0].where = {id}; 93 | } 94 | else if (k === 'q') { 95 | sql.where = { 96 | name: {[DatabaseHelper.op('like')]: '%' + id + '%'} 97 | }; 98 | } 99 | else if (k === 'limit') { 100 | sql.limit = parseInt(id, 10) || null; 101 | } 102 | else if (k === 'updatedSince') { 103 | sql.where = sql.where || {}; 104 | 105 | const m = moment(id); 106 | if(!m.isValid()) { 107 | throw new ErrorResponse(400, 'Attribute `updated-since` has to be a valid datetime, sorry…', { 108 | attributes: { 109 | updatedSince: 'Is not valid' 110 | } 111 | }); 112 | } 113 | sql.where.updatedAt = { 114 | [DatabaseHelper.op('gte')]: m.toJSON() 115 | }; 116 | } 117 | else { 118 | throw new ErrorResponse(400, 'Unknown filter `' + k + '`!'); 119 | } 120 | }); 121 | 122 | return this.getModel().findAll(sql); 123 | } 124 | } 125 | 126 | module.exports = PayeeLogic; -------------------------------------------------------------------------------- /logic/plugin-config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const BaseLogic = require('./_'); 4 | 5 | class PluginConfigLogic extends BaseLogic { 6 | static getModelName() { 7 | return 'plugin-config'; 8 | } 9 | 10 | static getPluralModelName() { 11 | return 'plugin-configs'; 12 | } 13 | 14 | static disableSequelizeSocketHooks() { 15 | return true; 16 | } 17 | } 18 | 19 | module.exports = PluginConfigLogic; -------------------------------------------------------------------------------- /logic/plugin-instance.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const BaseLogic = require('./_'); 4 | const ErrorResponse = require('../helpers/errorResponse'); 5 | const PluginHelper = require('../helpers/plugin'); 6 | const DatabaseHelper = require('../helpers/database'); 7 | 8 | class PluginInstanceLogic extends BaseLogic { 9 | static getModelName () { 10 | return 'plugin-instance'; 11 | } 12 | 13 | static getPluralModelName () { 14 | return 'plugin-instances'; 15 | } 16 | 17 | static async format (plugin) { 18 | return plugin.toJSON(true); 19 | } 20 | 21 | static disableSequelizeSocketHooks () { 22 | return true; 23 | } 24 | 25 | static async create (attributes, options) { 26 | if (!attributes.type) { 27 | throw new ErrorResponse(400, 'PluginInstance requires attribute `type`…', { 28 | attributes: { 29 | type: 'Is required!' 30 | } 31 | }); 32 | } 33 | 34 | const PluginRepository = require('../helpers/repository'); 35 | if (!await PluginRepository.getPluginById(attributes.type)) { 36 | throw new ErrorResponse(400, 'You can only install plugins which are listed in the plugin repository!', { 37 | attributes: { 38 | type: 'Is not listed in plugin repository!' 39 | } 40 | }); 41 | } 42 | 43 | if (!attributes.documentId) { 44 | throw new ErrorResponse(400, 'PluginInstance requires attribute `documentId`…', { 45 | attributes: { 46 | documentId: 'Is required!' 47 | } 48 | }); 49 | } 50 | 51 | const DatabaseHelper = require('../helpers/database'); 52 | const sql = {attributes: ['id']}; 53 | if (!options.session.user.isAdmin) { 54 | sql.include = [{ 55 | model: DatabaseHelper.get('user'), 56 | attributes: [], 57 | where: { 58 | id: options.session.userId 59 | } 60 | }]; 61 | } 62 | 63 | const document = await DatabaseHelper.get('document').findByPk(attributes.documentId, sql); 64 | if (!document) { 65 | throw new ErrorResponse(404, 'Could not create PluginInstance: document not found…', { 66 | attributes: { 67 | documentId: 'Is not valid!' 68 | } 69 | }); 70 | } 71 | 72 | const PluginHelper = require('../helpers/plugin'); 73 | try { 74 | const plugin = await PluginHelper.installPlugin(attributes.type, document); 75 | return {model: plugin}; 76 | } 77 | catch (err) { 78 | throw new ErrorResponse(500, 'Unable to install plugin: ' + err); 79 | } 80 | } 81 | 82 | static async get (id, options) { 83 | const plugins = await PluginHelper.listPlugins(); 84 | const plugin = plugins.find(p => p.id() === id); 85 | if (!plugin) { 86 | return null; 87 | } 88 | 89 | if (!options.session.user.isAdmin) { 90 | const DatabaseHelper = require('../helpers/database'); 91 | const document = await DatabaseHelper.get('document').findByPk(plugin.documentId(), { 92 | include: DatabaseHelper.includeUserIfNotAdmin(options.session) 93 | }); 94 | 95 | if (!document) { 96 | return null; 97 | } 98 | } 99 | 100 | return plugin; 101 | } 102 | 103 | static async list (params, options) { 104 | if (params.document) { 105 | const document = await DatabaseHelper.get('document').findByPk(params.document); 106 | 107 | if (!document) { 108 | return []; 109 | } else { 110 | const plugins = await PluginHelper.listPlugins(); 111 | return plugins.filter(p => p.documentId() === document.id); 112 | } 113 | } 114 | 115 | if (!options.session.user.isAdmin) { 116 | throw new ErrorResponse(403, 'Only admins are allowed to list all plugins…'); 117 | } 118 | 119 | return PluginHelper.listPlugins(); 120 | } 121 | 122 | static async update (model, body) { 123 | if (body && body.config && body.config.length > 0) { 124 | const values = {}; 125 | 126 | body.config.forEach(field => { 127 | values[field.id] = field.value; 128 | }); 129 | 130 | const errors = await model.checkAndSaveConfig(values); 131 | if (errors.length > 0) { 132 | const attributes = {}; 133 | errors.forEach(error => { 134 | if (error.code === 'empty') { 135 | attributes[error.field] = 'Field `' + error.field + '` is required, but empty.'; 136 | } 137 | else if (error.code === 'wrong') { 138 | attributes[error.field] = 'Field `' + error.field + '` is not valid.'; 139 | } 140 | else { 141 | attributes[error.field] = 'Field `' + error.field + '` validation failed without reason.'; 142 | } 143 | }); 144 | 145 | throw new ErrorResponse(400, 'Plugin settings are not valid', {attributes}); 146 | } 147 | } 148 | 149 | return {model}; 150 | } 151 | 152 | static async delete (instance) { 153 | const PluginHelper = require('../helpers/plugin'); 154 | try { 155 | await PluginHelper.removePlugin(instance); 156 | } 157 | catch (err) { 158 | throw new ErrorResponse(500, 'Unable to remove plugin: ' + err); 159 | } 160 | } 161 | } 162 | 163 | module.exports = PluginInstanceLogic; -------------------------------------------------------------------------------- /logic/plugin-store.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const BaseLogic = require('./_'); 4 | 5 | class PluginConfigLogic extends BaseLogic { 6 | static getModelName() { 7 | return 'plugin-store'; 8 | } 9 | 10 | static getPluralModelName() { 11 | return 'plugin-stores'; 12 | } 13 | 14 | static disableSequelizeSocketHooks() { 15 | return true; 16 | } 17 | } 18 | 19 | module.exports = PluginConfigLogic; -------------------------------------------------------------------------------- /logic/plugin.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const BaseLogic = require('./_'); 4 | const RepositoryHelper = require('../helpers/repository'); 5 | 6 | class PluginLogic extends BaseLogic { 7 | static getModelName() { 8 | return 'plugin'; 9 | } 10 | 11 | static getPluralModelName() { 12 | return 'plugins'; 13 | } 14 | 15 | static format(plugin) { 16 | return plugin; 17 | } 18 | 19 | static async get(id) { 20 | return RepositoryHelper.getPluginById(id); 21 | } 22 | 23 | static async list(params) { 24 | return RepositoryHelper.searchAccountPlugin(params.q); 25 | } 26 | } 27 | 28 | module.exports = PluginLogic; -------------------------------------------------------------------------------- /logic/session.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const BaseLogic = require('./_'); 4 | 5 | class SessionLogic extends BaseLogic { 6 | static getModelName () { 7 | return 'session'; 8 | } 9 | 10 | static getPluralModelName () { 11 | return 'sessions'; 12 | } 13 | 14 | static format (session, secrets) { 15 | const j = { 16 | id: session.id, 17 | userId: session.userId, 18 | name: session.name, 19 | url: session.url, 20 | accepted: !session.mobilePairing 21 | }; 22 | 23 | if (secrets && secrets.token) { 24 | j.secret = secrets.token; 25 | } 26 | 27 | return j; 28 | } 29 | 30 | static async create (attributes, options) { 31 | const ErrorResponse = require('../helpers/errorResponse'); 32 | const DatabaseHelper = require('../helpers/database'); 33 | const KeychainHelper = require('../helpers/keychain'); 34 | 35 | const bcrypt = require('bcryptjs'); 36 | const crypto = require('crypto'); 37 | 38 | const model = this.getModel().build(); 39 | const secrets = {}; 40 | 41 | model.name = (attributes.name || '').toString(); 42 | if (!model.name) { 43 | throw new ErrorResponse(400, 'Session requires attribute `name`…', { 44 | attributes: { 45 | name: 'Is required!' 46 | } 47 | }); 48 | } 49 | if (model.name.length > 255) { 50 | throw new ErrorResponse(400, 'Attribute `Session.name` has a maximum length of 255 chars, sorry…', { 51 | attributes: { 52 | name: 'Is too long, only 255 characters allowed…' 53 | } 54 | }); 55 | } 56 | 57 | model.url = attributes.url || null; 58 | if (model.url) { 59 | try { 60 | new URL(model.url); 61 | } 62 | catch (err) { 63 | throw new ErrorResponse(400, 'Attribute `Session.url` seems to be invalid…', { 64 | attributes: { 65 | url: 'Seems to be invalid' 66 | } 67 | }); 68 | } 69 | } 70 | 71 | 72 | // logged in user: create mobile pairing sessions for me 73 | if (options.session.user) { 74 | model.userId = options.session.user.id; 75 | model.user = options.session.user; 76 | model.mobilePairing = true; 77 | 78 | const crypto = require('crypto'); 79 | const random = await new Promise((resolve, reject) => { 80 | crypto.randomBytes(32, (err, buffer) => { 81 | if (err) { 82 | reject(err); 83 | } 84 | else { 85 | resolve(buffer.toString('hex')); 86 | } 87 | }); 88 | }); 89 | 90 | secrets.token = random; 91 | 92 | const hash = await bcrypt.hash(random, 10); 93 | model.secret = hash; 94 | 95 | await model.save(); 96 | return {model, secrets}; 97 | } 98 | 99 | 100 | // logged out user: login 101 | const userModel = await DatabaseHelper.get('user').findOne({ 102 | where: { 103 | email: options.session.name 104 | } 105 | }); 106 | if (!userModel) { 107 | throw new ErrorResponse(401, 'Not able to authorize: Is username / password correct?'); 108 | } 109 | 110 | model.userId = userModel.id; 111 | model.user = userModel; 112 | 113 | const passwordCorrect = await bcrypt.compare(options.session.pass, userModel.password); 114 | if (!passwordCorrect) { 115 | throw new ErrorResponse(401, 'Not able to authorize: Is username / password correct?'); 116 | } 117 | 118 | const RepositoryHelper = require('../helpers/repository'); 119 | const terms = await RepositoryHelper.getTerms(); 120 | if (attributes.acceptedTerms && userModel.acceptedTermVersion !== attributes.acceptedTerms) { 121 | userModel.acceptedTermVersion = attributes.acceptedTerms; 122 | await userModel.save(); 123 | } 124 | if (!userModel.acceptedTermVersion || userModel.acceptedTermVersion !== terms.version) { 125 | throw new ErrorResponse(401, 'Not able to login: User has not accept the current terms!', { 126 | attributes: { 127 | acceptedTerms: 'Is required to be set to the current term version.' 128 | }, 129 | extra: terms 130 | }); 131 | } 132 | 133 | await KeychainHelper.unlock(userModel, options.session.pass); 134 | 135 | const random = crypto.randomBytes(32).toString('hex'); 136 | const passwordHash = await bcrypt.hash(random, 10); 137 | model.secret = passwordHash; 138 | secrets.token = random; 139 | 140 | options.setSession(model); 141 | await model.save(); 142 | 143 | return {model, secrets}; 144 | } 145 | 146 | static async get (id, options) { 147 | return this.getModel().findOne({ 148 | where: { 149 | id: id, 150 | userId: options.session.userId 151 | } 152 | }); 153 | } 154 | 155 | static async list (params, options) { 156 | return this.getModel().findAll({ 157 | where: { 158 | userId: options.session.userId 159 | } 160 | }); 161 | } 162 | 163 | static async update (model, body, options) { 164 | const ErrorResponse = require('../helpers/errorResponse'); 165 | 166 | const secrets = {}; 167 | 168 | if (body.name !== undefined) { 169 | model.name = body.name; 170 | 171 | // neues secret generieren 172 | if (model.mobilePairing && model.id === options.session.id) { 173 | const crypto = require('crypto'); 174 | secrets.token = await new Promise((resolve, reject) => { 175 | crypto.randomBytes(32, (err, buffer) => { 176 | if (err) { 177 | reject(err); 178 | } 179 | else { 180 | resolve(buffer.toString('hex')); 181 | } 182 | }); 183 | }); 184 | 185 | const bcrypt = require('bcryptjs'); 186 | model.secret = await bcrypt.hash(secrets.token, 10); 187 | } 188 | } 189 | if (!model.name) { 190 | throw new ErrorResponse(400, 'Session requires attribute `name`…', { 191 | attributes: { 192 | name: 'Is required!' 193 | } 194 | }); 195 | } 196 | if (model.name.length > 255) { 197 | throw new ErrorResponse(400, 'Attribute `Session.name` has a maximum length of 255 chars, sorry…', { 198 | attributes: { 199 | name: 'Is too long, only 255 characters allowed…' 200 | } 201 | }); 202 | } 203 | 204 | 205 | if (body.url !== undefined) { 206 | model.url = body.url; 207 | } 208 | if (model.url) { 209 | try { 210 | new URL(model.url); 211 | } 212 | catch (err) { 213 | throw new ErrorResponse(400, 'Attribute `Session.url` seems to be invalid…', { 214 | attributes: { 215 | url: 'Seems to be invalid' 216 | } 217 | }); 218 | } 219 | } 220 | 221 | if (body.accepted !== undefined && !options.session.mobilePairing) { 222 | model.mobilePairing = !body.accepted; 223 | } 224 | 225 | await model.save(); 226 | return {model, secrets}; 227 | } 228 | 229 | static delete (model) { 230 | return model.destroy(); 231 | } 232 | } 233 | 234 | module.exports = SessionLogic; 235 | -------------------------------------------------------------------------------- /logic/setting.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const BaseLogic = require('./_'); 4 | const LogHelper = require('../helpers/log'); 5 | const DatabaseHelper = require('../helpers/database'); 6 | const ErrorResponse = require('../helpers/errorResponse'); 7 | const log = new LogHelper('SettingLogic'); 8 | 9 | class SettingLogic extends BaseLogic { 10 | static getModelName() { 11 | return 'setting'; 12 | } 13 | 14 | static getPluralModelName() { 15 | return 'settings'; 16 | } 17 | 18 | static format(setting) { 19 | let value = null; 20 | 21 | if (setting.value !== undefined && setting.value !== null) { 22 | try { 23 | value = JSON.parse(setting.value); 24 | } 25 | catch (err) { 26 | log.warn(new Error('Unable to parse setting value `' + setting.value + '`:')); 27 | } 28 | } 29 | 30 | return { 31 | id: setting.id, 32 | documentId: setting.documentId, 33 | key: setting.key, 34 | value: value 35 | }; 36 | } 37 | 38 | static async create(attributes, options) { 39 | const model = this.getModel().build(); 40 | 41 | model.key = attributes.key; 42 | if (!model.key) { 43 | throw new ErrorResponse(400, 'Setting requires attribute `key`…', { 44 | attributes: { 45 | key: 'Is required!' 46 | } 47 | }); 48 | } 49 | 50 | model.value = JSON.stringify(attributes.value); 51 | 52 | model.documentId = attributes.documentId; 53 | if (!model.documentId) { 54 | throw new ErrorResponse(400, 'Setting requires attribute `documentId`…', { 55 | attributes: { 56 | documentId: 'Is required!' 57 | } 58 | }); 59 | } 60 | 61 | const document = await DatabaseHelper.get('document').findOne({ 62 | attributes: ['id'], 63 | where: { 64 | id: model.documentId 65 | }, 66 | include: DatabaseHelper.includeUserIfNotAdmin(options.session, {through: true}) 67 | }); 68 | if (!document) { 69 | throw new ErrorResponse(400, 'Document given in `documentId` not found…', { 70 | attributes: { 71 | documentId: 'Document not found' 72 | } 73 | }); 74 | } 75 | 76 | model.documentId = document.id; 77 | 78 | try { 79 | await model.save(); 80 | } 81 | catch(err) { 82 | if (err.toString().indexOf('SequelizeUniqueConstraintError') > -1) { 83 | throw new ErrorResponse(400, 'Setting with this key already exists in document…', { 84 | attributes: { 85 | key: 'Already exists' 86 | } 87 | }); 88 | } 89 | 90 | throw err; 91 | } 92 | 93 | return {model}; 94 | } 95 | 96 | static async get(id, options) { 97 | return this.getModel().findOne({ 98 | where: {id}, 99 | include: [{ 100 | model: DatabaseHelper.get('document'), 101 | attributes: [], 102 | include: DatabaseHelper.includeUserIfNotAdmin(options.session) 103 | }] 104 | }); 105 | } 106 | 107 | static async list(params, options) { 108 | return this.getModel().findAll({ 109 | include: [{ 110 | model: DatabaseHelper.get('document'), 111 | required: true, 112 | include: DatabaseHelper.includeUserIfNotAdmin(options.session) 113 | }] 114 | }); 115 | } 116 | 117 | static async update(model, body) { 118 | if (body.key !== undefined && body.key !== model.key) { 119 | throw new ErrorResponse(400, 'It\'s not allowed to change the Setting key…', { 120 | attributes: { 121 | key: 'Changes not allowed' 122 | } 123 | }); 124 | } 125 | if (body.documentId !== undefined && body.documentId !== model.documentId) { 126 | throw new ErrorResponse(400, 'It\'s not allowed to change the Setting document id…', { 127 | attributes: { 128 | key: 'Changes not allowed' 129 | } 130 | }); 131 | } 132 | 133 | model.value = JSON.stringify(body.value); 134 | await model.save(); 135 | 136 | return {model}; 137 | } 138 | } 139 | 140 | module.exports = SettingLogic; -------------------------------------------------------------------------------- /logic/unit.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const BaseLogic = require('./_'); 4 | 5 | class UnitLogic extends BaseLogic { 6 | static getModelName() { 7 | return 'unit'; 8 | } 9 | 10 | static getPluralModelName() { 11 | return 'units'; 12 | } 13 | 14 | static disableSequelizeSocketHooks() { 15 | return true; 16 | } 17 | } 18 | 19 | module.exports = UnitLogic; -------------------------------------------------------------------------------- /logic/user.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const crypto = require('crypto'); 4 | const bcrypt = require('bcryptjs'); 5 | const {pwnedPassword} = require('hibp'); 6 | const mailValidator = require('email-validator'); 7 | const moment = require('moment'); 8 | 9 | const BaseLogic = require('./_'); 10 | const ErrorResponse = require('../helpers/errorResponse'); 11 | const RepositoryHelper = require('../helpers/repository'); 12 | const KeychainHelper = require('../helpers/keychain'); 13 | 14 | class UserLogic extends BaseLogic { 15 | static getModelName () { 16 | return 'user'; 17 | } 18 | 19 | static getPluralModelName () { 20 | return 'users'; 21 | } 22 | 23 | static async format (user, secrets) { 24 | const terms = await RepositoryHelper.getTerms(); 25 | const canUnlockKeychain = !!user.keychainKey || 26 | !user.keychainKey && user.isAdmin && !await KeychainHelper.isSetUp() 27 | ; 28 | 29 | const r = { 30 | id: user.id, 31 | email: user.email, 32 | admin: { 33 | isAdmin: user.isAdmin, 34 | canUnlockKeychain, 35 | shouldUnlockKeychain: canUnlockKeychain && KeychainHelper.isLocked() 36 | }, 37 | otpEnabled: user.otpEnabled, 38 | needsPasswordChange: user.needsPasswordChange, 39 | terms: { 40 | accepted: moment().isAfter(terms.validFrom) ? terms.version : user.acceptedTermVersion, 41 | current: terms 42 | } 43 | }; 44 | 45 | if (secrets.password) { 46 | r.password = secrets.password; 47 | } 48 | 49 | return r; 50 | } 51 | 52 | static async create (attributes, options) { 53 | if (!options.session.user.isAdmin) { 54 | throw new ErrorResponse(403, 'You need admin privileges to create new users…'); 55 | } 56 | 57 | const secrets = {}; 58 | const model = this.getModel().build(); 59 | 60 | model.isAdmin = !!attributes.isAdmin; 61 | model.needsPasswordChange = true; 62 | 63 | model.email = attributes.email; 64 | if (!model.email) { 65 | throw new ErrorResponse(400, 'User requires attribute `email`…', { 66 | attributes: { 67 | key: 'Is required!' 68 | } 69 | }); 70 | } 71 | if (!mailValidator.validate(model.email)) { 72 | throw new ErrorResponse(400, 'Email doesn\'t seem to be valid…', { 73 | attributes: { 74 | key: 'Is not valid!' 75 | } 76 | }); 77 | } 78 | 79 | const random = await new Promise((resolve, reject) => { 80 | crypto.randomBytes(16, (err, buffer) => { 81 | if (err) { 82 | reject(err); 83 | } 84 | else { 85 | resolve(buffer.toString('hex')); 86 | } 87 | }); 88 | }); 89 | 90 | secrets.password = random; 91 | const hash = await bcrypt.hash(random, 10); 92 | model.password = hash; 93 | 94 | await KeychainHelper.unlock(model, secrets.password, {dontSave: true}); 95 | 96 | try { 97 | await model.save(); 98 | } 99 | catch (err) { 100 | if (err.toString().indexOf('SequelizeUniqueConstraintError') > -1) { 101 | throw new ErrorResponse(400, 'User with this email address already exists…', { 102 | attributes: { 103 | email: 'Already exists' 104 | } 105 | }); 106 | } 107 | 108 | throw err; 109 | } 110 | 111 | return {model, secrets}; 112 | } 113 | 114 | static async get (id, options) { 115 | if (options.session.user.isAdmin || id === options.session.userId) { 116 | return this.getModel().findByPk(id); 117 | } 118 | 119 | return null; 120 | } 121 | 122 | static async list (params, options) { 123 | const req = {}; 124 | 125 | if (!options.session.user.isAdmin) { 126 | req.where = { 127 | id: options.session.userId 128 | }; 129 | } 130 | 131 | return this.getModel().findAll(req); 132 | } 133 | 134 | static async update (model, body, options) { 135 | 136 | // email 137 | if (body.email === undefined) { 138 | throw false; 139 | } 140 | if (!options.session.user.isAdmin && model.id !== options.session.userId) { 141 | throw new ErrorResponse(403, 'You are not allowed to update other people\'s email address!'); 142 | } 143 | 144 | model.email = body.email; 145 | if (!mailValidator.validate(model.email)) { 146 | throw new ErrorResponse(400, 'Email doesn\'t seem to be valid…', { 147 | attributes: { 148 | email: 'Is not valid!' 149 | } 150 | }); 151 | } 152 | 153 | 154 | // password 155 | if (model.id !== options.session.userId && body.password !== undefined) { 156 | throw new ErrorResponse(403, 'You are not allowed to update other people\'s password!'); 157 | } 158 | if (body.password !== undefined) { 159 | const [hash, count] = await Promise.all([ 160 | bcrypt.hash(body.password, 10), 161 | pwnedPassword(body.password) 162 | ]); 163 | 164 | if (count > 0) { 165 | throw new ErrorResponse(400, 'Password is not secure…', { 166 | attributes: { 167 | password: `Seems not to be secure. Password is listed on haveibeenpwned.com ${count} times.` 168 | } 169 | }); 170 | } 171 | if (model.isAdmin && KeychainHelper.isLocked()) { 172 | throw new ErrorResponse(400, 'Keychain is locked…', { 173 | attributes: { 174 | password: 'Please unlock keychain before changing password.' 175 | } 176 | }); 177 | } 178 | 179 | model.password = hash; 180 | model.needsPasswordChange = false; 181 | model.keychainKey = null; 182 | 183 | await KeychainHelper.unlock(model, body.password, {dontSave: true}); 184 | } 185 | 186 | 187 | // isAdmin 188 | if (options.session.user.isAdmin && body.isAdmin !== undefined && body.admin && body.admin.isAdmin !== model.isAdmin) { 189 | model.isAdmin = !!body.admin.isAdmin; 190 | 191 | if (!model.isAdmin) { 192 | model.keychainKey = null; 193 | } 194 | } 195 | else if (body.isAdmin !== undefined && !!body.isAdmin !== model.isAdmin) { 196 | throw new ErrorResponse(403, 'You are not allowed to update other people\'s admin privilege!'); 197 | } 198 | 199 | 200 | // unlock keychain 201 | if (body.admin.unlockPassword) { 202 | const passwordCorrect = await bcrypt.compare(body.admin.unlockPassword, model.password); 203 | if (!passwordCorrect) { 204 | throw new ErrorResponse(400, 'Not able to unlock keychain: Is your password correct?'); 205 | } 206 | 207 | await KeychainHelper.unlock(model, body.admin.unlockPassword, {dontSave: true}); 208 | } 209 | 210 | 211 | // terms 212 | if (body.terms.accepted && body.terms.accepted !== model.acceptedTermVersion) { 213 | model.acceptedTermVersion = body.terms.accepted; 214 | } 215 | 216 | await model.save(); 217 | return {model}; 218 | } 219 | 220 | static async delete (model, options) { 221 | if (model.id === options.session.userId) { 222 | throw new ErrorResponse(400, 'Sorry, but you can\'t delete yourself…'); 223 | } 224 | 225 | return model.destroy(); 226 | } 227 | } 228 | 229 | module.exports = UserLogic; 230 | -------------------------------------------------------------------------------- /migrations/2019-07-28_15-02_terms.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | async up (q, models, sequelize, DataTypes) { 5 | await q.addColumn('users', 'acceptedTermVersion', { 6 | type: DataTypes.INTEGER.UNSIGNED, 7 | allowNull: true, 8 | defaultValue: null 9 | }); 10 | 11 | }, 12 | async down (q) { 13 | await q.removeColumn('users', 'acceptedTermVersion'); 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /migrations/2019-07-30_14-57_ubud.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | async up (q, models, sequelize, Sequelize) { 5 | const instances = await models['plugin-instance'].findAll({ 6 | where: { 7 | type: { 8 | [Sequelize.Op.startsWith]: '@dwimm/' 9 | } 10 | } 11 | }); 12 | 13 | for(let i = 0; i < instances.length; i++) { 14 | const instance = instances[i]; 15 | instance.type = '@ubud-app/' + instance.type.substr(7); 16 | await instance.save(); 17 | } 18 | }, 19 | async down (q, models, sequelize, Sequelize) { 20 | const instances = await models['plugin-instance'].findAll({ 21 | where: { 22 | type: { 23 | [Sequelize.Op.startsWith]: '@ubud-app/' 24 | } 25 | } 26 | }); 27 | 28 | for(let i = 0; i < instances.length; i++) { 29 | const instance = instances[i]; 30 | instance.type = '@dwimm/' + instance.type.substr(10); 31 | await instance.save(); 32 | } 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /migrations/2019-08-03_19-00_payee-set-null.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | async up (q) { 5 | await q.removeConstraint('transactions', 'transactions_ibfk_2'); 6 | await q.addConstraint('transactions', { 7 | type: 'FOREIGN KEY', 8 | name: 'transactions_ibfk_2', 9 | fields: ['payeeId'], 10 | references: { 11 | table: 'payees', 12 | field: 'id' 13 | }, 14 | onDelete: 'set null', 15 | onUpdate: 'set null' 16 | }); 17 | }, 18 | async down (q) { 19 | await q.removeConstraint('transactions', 'transactions_ibfk_2'); 20 | await q.addConstraint('transactions', { 21 | type: 'FOREIGN KEY', 22 | name: 'transactions_ibfk_2', 23 | fields: ['payeeId'], 24 | references: { 25 | table: 'payees', 26 | field: 'id' 27 | }, 28 | onDelete: 'cascade', 29 | onUpdate: 'cascade' 30 | }); 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /migrations/2019-09-09_11-22_keychain-unlock-key.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | async up (q, models, sequelize, DataTypes) { 5 | await q.addColumn('users', 'keychainKey', { 6 | type: DataTypes.STRING(2048), 7 | allowNull: true, 8 | defaultValue: null 9 | }); 10 | 11 | await q.changeColumn('plugin-configs', 'value', { 12 | type: DataTypes.STRING(2048), 13 | allowNull: true 14 | }); 15 | }, 16 | async down (q, models, sequelize, DataTypes) { 17 | await q.removeColumn('users', 'keychainKey'); 18 | 19 | await q.changeColumn('plugin-configs', 'value', { 20 | type: DataTypes.STRING, 21 | allowNull: true 22 | }); 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /migrations/2019-09-20_09-59_learnings.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | async up (q, models, sequelize, DataTypes) { 5 | await models.learning.truncate(); 6 | 7 | await q.changeColumn('learnings', 'location', { 8 | type: DataTypes.ENUM('payee', 'memo', 'plugin:payee', 'plugin:memo'), 9 | allowNull: false 10 | }); 11 | 12 | try { 13 | await q.removeConstraint('learnings', 'learnings_ibfk_2'); 14 | } 15 | catch(err) { 16 | // ignored 17 | } 18 | 19 | try { 20 | await q.removeColumn('learnings', 'categoryId'); 21 | } 22 | catch(err) { 23 | // ignored 24 | } 25 | 26 | await q.addColumn('learnings', 'budgetId',{ 27 | type: DataTypes.UUID, 28 | allowNull: false 29 | }); 30 | await q.addConstraint('learnings', { 31 | type: 'FOREIGN KEY', 32 | name: 'learnings_ibfk_2', 33 | fields: ['budgetId'], 34 | references: { 35 | table: 'budgets', 36 | field: 'id' 37 | }, 38 | onDelete: 'cascade', 39 | onUpdate: 'cascade' 40 | }); 41 | 42 | await q.changeColumn('learnings', 'word', { 43 | type: DataTypes.STRING(32), 44 | allowNull: false 45 | }); 46 | 47 | await q.addColumn('learnings', 'transactionId',{ 48 | type: DataTypes.UUID, 49 | allowNull: false 50 | }); 51 | await q.addConstraint('learnings', { 52 | type: 'FOREIGN KEY', 53 | name: 'learnings_ibfk_3', 54 | fields: ['transactionId'], 55 | references: { 56 | table: 'transactions', 57 | field: 'id' 58 | }, 59 | onDelete: 'cascade', 60 | onUpdate: 'cascade' 61 | }); 62 | 63 | await q.addIndex('learnings', ['location', 'word', 'documentId'], { 64 | name: 'learnings_guess' 65 | }); 66 | 67 | await q.addIndex('learnings', ['location', 'word', 'transactionId'], { 68 | unique: true, 69 | name: 'learnings_unique' 70 | }); 71 | 72 | await q.addIndex('learnings', ['transactionId'], { 73 | name: 'learnings_sync' 74 | }); 75 | } 76 | }; 77 | -------------------------------------------------------------------------------- /migrations/2019-09-21_16-27_learnings.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | async up (q, models) { 5 | const TransactionLogic = require('../logic/transaction'); 6 | let numberOfItems = null; 7 | let offset = 0; 8 | do { 9 | const transactions = await models.transaction.findAll({ 10 | attributes: [ 11 | 'id', 12 | 'memo', 13 | 'pluginsOwnPayeeId', 14 | 'pluginsOwnMemo' 15 | ], 16 | include: [ 17 | { 18 | model: models.account, 19 | attributes: ['documentId'] 20 | }, 21 | { 22 | model: models.unit 23 | }, 24 | { 25 | model: models.payee, 26 | attribute: ['id', 'name'] 27 | } 28 | ], 29 | limit: 50, 30 | offset 31 | }); 32 | 33 | for(let i = 0; i < transactions.length; i++) { 34 | await TransactionLogic.updateLearnings(transactions[i]); 35 | } 36 | 37 | numberOfItems = transactions.length; 38 | offset += numberOfItems; 39 | } while (numberOfItems >= 50); 40 | } 41 | }; 42 | -------------------------------------------------------------------------------- /migrations/2020-07-30_20-59_indices.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | async up (q) { 5 | await q.addIndex('accounts', ['name', 'documentId', 'pluginInstanceId'], { 6 | name: 'account_query_index' 7 | }); 8 | await q.addIndex('budgets', ['id', 'name', 'categoryId', 'pluginInstanceId', 'hidden'], { 9 | name: 'budget_query_index' 10 | }); 11 | await q.addIndex('categories', ['id', 'name', 'documentId'], { 12 | name: 'category_query_index' 13 | }); 14 | await q.addIndex('documents', ['id', 'name'], { 15 | name: 'document_query_index' 16 | }); 17 | await q.addIndex('payees', ['name', 'documentId'], { 18 | name: 'payee_query_index' 19 | }); 20 | await q.addIndex('plugin-configs', ['pluginInstanceId', 'key'], { 21 | name: 'plugin-config_query_index' 22 | }); 23 | await q.addIndex('plugin-stores', ['pluginInstanceId', 'key'], { 24 | name: 'plugin-store_query_index' 25 | }); 26 | await q.addIndex('portions', ['month', 'budgetId'], { 27 | name: 'portion_query_index' 28 | }); 29 | await q.addIndex('sessions', ['userId'], { 30 | name: 'session_query_index' 31 | }); 32 | await q.addIndex('shares', ['id', 'userId', 'documentId'], { 33 | name: 'share_query_index' 34 | }); 35 | await q.addIndex('summaries', ['month', 'documentId'], { 36 | name: 'summary_query_index' 37 | }); 38 | await q.addIndex('transactions', ['time', 'accountId', 'status'], { 39 | name: 'transaction_query_index' 40 | }); 41 | await q.addIndex('units', ['id', 'transactionId'], { 42 | name: 'unit_query_index' 43 | }); 44 | } 45 | }; 46 | -------------------------------------------------------------------------------- /migrations/2020-08-09_12-04_recalculate-summaries.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | async up (q) { 5 | await q.bulkDelete('summaries', {}, { 6 | truncate: true 7 | }); 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /models/account.js: -------------------------------------------------------------------------------- 1 | const AccountLogic = require('../logic/account'); 2 | 3 | module.exports = class AccountModelDefinition { 4 | static getDefinition(DataTypes) { 5 | return { 6 | id: { 7 | type: DataTypes.UUID, 8 | primaryKey: true, 9 | defaultValue: DataTypes.UUIDV4 10 | }, 11 | name: { 12 | type: DataTypes.STRING, 13 | allowNull: false 14 | }, 15 | type: { 16 | type: DataTypes.ENUM(AccountLogic.getValidTypeValues()), 17 | allowNull: false 18 | }, 19 | hidden: { 20 | type: DataTypes.BOOLEAN, 21 | allowNull: false, 22 | defaultValue: false 23 | }, 24 | pluginsOwnId: { 25 | type: DataTypes.STRING, 26 | allowNull: true 27 | } 28 | }; 29 | } 30 | }; -------------------------------------------------------------------------------- /models/budget.js: -------------------------------------------------------------------------------- 1 | module.exports = class BudgetModelDefinition { 2 | static getDefinition(DataTypes) { 3 | return { 4 | id: { 5 | type: DataTypes.UUID, 6 | primaryKey: true, 7 | defaultValue: DataTypes.UUIDV4 8 | }, 9 | name: { 10 | type: DataTypes.STRING, 11 | allowNull: false 12 | }, 13 | goal: { 14 | type: DataTypes.INTEGER, 15 | allowNull: true 16 | }, 17 | hidden: { 18 | type: DataTypes.BOOLEAN, 19 | allowNull: false, 20 | defaultValue: false 21 | }, 22 | pluginsOwnId: { 23 | type: DataTypes.STRING, 24 | allowNull: true 25 | } 26 | }; 27 | } 28 | }; -------------------------------------------------------------------------------- /models/category.js: -------------------------------------------------------------------------------- 1 | module.exports = class CategoryModelDefinition { 2 | static getDefinition(DataTypes) { 3 | return { 4 | id: { 5 | type: DataTypes.UUID, 6 | primaryKey: true, 7 | defaultValue: DataTypes.UUIDV4 8 | }, 9 | name: { 10 | type: DataTypes.STRING, 11 | allowNull: false 12 | } 13 | }; 14 | } 15 | }; -------------------------------------------------------------------------------- /models/document.js: -------------------------------------------------------------------------------- 1 | module.exports = class DocumentModelDefinition { 2 | static getDefinition(DataTypes) { 3 | return { 4 | id: { 5 | type: DataTypes.UUID, 6 | primaryKey: true, 7 | defaultValue: DataTypes.UUIDV4 8 | }, 9 | name: { 10 | type: DataTypes.STRING, 11 | allowNull: false 12 | } 13 | }; 14 | } 15 | }; -------------------------------------------------------------------------------- /models/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'account': require('./account'), 3 | 'budget': require('./budget'), 4 | 'category': require('./category'), 5 | 'document': require('./document'), 6 | 'learning': require('./learning'), 7 | 'payee': require('./payee'), 8 | 'plugin-config': require('./plugin-config'), 9 | 'plugin-instance': require('./plugin-instance'), 10 | 'plugin-store': require('./plugin-store'), 11 | 'portion': require('./portion'), 12 | 'session': require('./session'), 13 | 'setting': require('./setting'), 14 | 'share': require('./share'), 15 | 'summary': require('./summary'), 16 | 'transaction': require('./transaction'), 17 | 'unit': require('./unit'), 18 | 'user': require('./user') 19 | }; -------------------------------------------------------------------------------- /models/learning.js: -------------------------------------------------------------------------------- 1 | module.exports = class LearningModelDefinition { 2 | static getDefinition(DataTypes) { 3 | return { 4 | id: { 5 | type: DataTypes.UUID, 6 | primaryKey: true, 7 | defaultValue: DataTypes.UUIDV4 8 | }, 9 | location: { 10 | type: DataTypes.ENUM('payee', 'memo', 'plugin:payee', 'plugin:memo'), 11 | allowNull: false 12 | }, 13 | word: { 14 | type: DataTypes.STRING(32), 15 | allowNull: false 16 | } 17 | }; 18 | } 19 | }; -------------------------------------------------------------------------------- /models/payee.js: -------------------------------------------------------------------------------- 1 | module.exports = class PayeeModelDefinition { 2 | static getDefinition(DataTypes) { 3 | return { 4 | id: { 5 | type: DataTypes.UUID, 6 | primaryKey: true, 7 | defaultValue: DataTypes.UUIDV4 8 | }, 9 | name: { 10 | type: DataTypes.STRING, 11 | allowNull: false 12 | } 13 | }; 14 | } 15 | }; -------------------------------------------------------------------------------- /models/plugin-config.js: -------------------------------------------------------------------------------- 1 | module.exports = class PluginConfigModelDefinition { 2 | static getDefinition(DataTypes) { 3 | return { 4 | id: { 5 | type: DataTypes.UUID, 6 | primaryKey: true, 7 | defaultValue: DataTypes.UUIDV4 8 | }, 9 | key: { 10 | type: DataTypes.STRING, 11 | allowNull: false 12 | }, 13 | value: { 14 | type: DataTypes.STRING(2048), 15 | allowNull: true 16 | } 17 | }; 18 | } 19 | }; -------------------------------------------------------------------------------- /models/plugin-instance.js: -------------------------------------------------------------------------------- 1 | module.exports = class PluginInstanceModelDefinition { 2 | static getDefinition(DataTypes) { 3 | return { 4 | id: { 5 | type: DataTypes.UUID, 6 | primaryKey: true, 7 | defaultValue: DataTypes.UUIDV4 8 | }, 9 | type: { 10 | type: DataTypes.STRING, 11 | allowNull: false 12 | } 13 | }; 14 | } 15 | }; -------------------------------------------------------------------------------- /models/plugin-store.js: -------------------------------------------------------------------------------- 1 | module.exports = class PluginConfigModelDefinition { 2 | static getDefinition(DataTypes) { 3 | return { 4 | id: { 5 | type: DataTypes.UUID, 6 | primaryKey: true, 7 | defaultValue: DataTypes.UUIDV4 8 | }, 9 | key: { 10 | type: DataTypes.STRING, 11 | allowNull: false 12 | }, 13 | value: { 14 | type: DataTypes.TEXT, 15 | allowNull: true 16 | } 17 | }; 18 | } 19 | }; -------------------------------------------------------------------------------- /models/portion.js: -------------------------------------------------------------------------------- 1 | module.exports = class PortionModelDefinition { 2 | static getDefinition(DataTypes) { 3 | return { 4 | id: { 5 | type: DataTypes.UUID, 6 | primaryKey: true, 7 | defaultValue: DataTypes.UUIDV4 8 | }, 9 | month: { 10 | type: DataTypes.STRING(7), 11 | allowNull: false 12 | }, 13 | budgeted: { 14 | type: DataTypes.INTEGER, 15 | allowNull: true 16 | }, 17 | outflow: { 18 | type: DataTypes.INTEGER, 19 | allowNull: false 20 | }, 21 | balance: { 22 | type: DataTypes.INTEGER, 23 | allowNull: false 24 | } 25 | }; 26 | } 27 | }; -------------------------------------------------------------------------------- /models/session.js: -------------------------------------------------------------------------------- 1 | module.exports = class SessionModelDefinition { 2 | static getDefinition(DataTypes) { 3 | return { 4 | id: { 5 | type: DataTypes.UUID, 6 | primaryKey: true, 7 | defaultValue: DataTypes.UUIDV4 8 | }, 9 | name: { 10 | type: DataTypes.STRING, 11 | allowNull: false 12 | }, 13 | url: { 14 | type: DataTypes.STRING, 15 | allowNull: true 16 | }, 17 | secret: { 18 | type: DataTypes.STRING, 19 | allowNull: false 20 | }, 21 | mobilePairing: { 22 | type: DataTypes.BOOLEAN, 23 | allowNull: false, 24 | defaultValue: false 25 | } 26 | }; 27 | } 28 | }; -------------------------------------------------------------------------------- /models/setting.js: -------------------------------------------------------------------------------- 1 | module.exports = class SettingModelDefinition { 2 | static getDefinition(DataTypes) { 3 | return { 4 | id: { 5 | type: DataTypes.UUID, 6 | primaryKey: true, 7 | defaultValue: DataTypes.UUIDV4 8 | }, 9 | key: { 10 | type: DataTypes.STRING, 11 | allowNull: false 12 | }, 13 | value: { 14 | type: DataTypes.STRING, 15 | allowNull: true 16 | } 17 | }; 18 | } 19 | 20 | static getIndexes() { 21 | return [ 22 | { 23 | unique: true, 24 | fields: ['documentId', 'key'] 25 | } 26 | ]; 27 | } 28 | }; -------------------------------------------------------------------------------- /models/share.js: -------------------------------------------------------------------------------- 1 | module.exports = class ShareModelDefinition { 2 | static getDefinition(DataTypes) { 3 | return { 4 | id: { 5 | type: DataTypes.UUID, 6 | primaryKey: true, 7 | defaultValue: DataTypes.UUIDV4 8 | } 9 | }; 10 | } 11 | }; -------------------------------------------------------------------------------- /models/summary.js: -------------------------------------------------------------------------------- 1 | module.exports = class SummaryModelDefinition { 2 | static getDefinition(DataTypes) { 3 | return { 4 | id: { 5 | type: DataTypes.UUID, 6 | primaryKey: true, 7 | defaultValue: DataTypes.UUIDV4 8 | }, 9 | month: { 10 | type: DataTypes.STRING(7), 11 | allowNull: false 12 | }, 13 | available: { 14 | type: DataTypes.INTEGER, 15 | allowNull: false 16 | }, 17 | availableLastMonth: { 18 | type: DataTypes.INTEGER, 19 | allowNull: false 20 | }, 21 | income: { 22 | type: DataTypes.INTEGER, 23 | allowNull: false 24 | }, 25 | budgeted: { 26 | type: DataTypes.INTEGER, 27 | allowNull: false 28 | }, 29 | outflow: { 30 | type: DataTypes.INTEGER, 31 | allowNull: false 32 | }, 33 | balance: { 34 | type: DataTypes.INTEGER, 35 | allowNull: false 36 | } 37 | }; 38 | } 39 | }; -------------------------------------------------------------------------------- /models/transaction.js: -------------------------------------------------------------------------------- 1 | module.exports = class TransactionModelDefinition { 2 | static getDefinition(DataTypes) { 3 | return { 4 | id: { 5 | type: DataTypes.UUID, 6 | primaryKey: true, 7 | defaultValue: DataTypes.UUIDV4 8 | }, 9 | time: { 10 | type: DataTypes.DATE, 11 | allowNull: false 12 | }, 13 | pluginsOwnPayeeId: { 14 | type: DataTypes.STRING, 15 | allowNull: true 16 | }, 17 | approved: { 18 | type: DataTypes.BOOLEAN, 19 | allowNull: false, 20 | defaultValue: false 21 | }, 22 | memo: { 23 | type: DataTypes.STRING(512), 24 | allowNull: true 25 | }, 26 | pluginsOwnMemo: { 27 | type: DataTypes.STRING(512), 28 | allowNull: true 29 | }, 30 | amount: { 31 | type: DataTypes.INTEGER, 32 | allowNull: false 33 | }, 34 | status: { 35 | type: DataTypes.ENUM('pending', 'normal', 'cleared'), 36 | allowNull: false 37 | }, 38 | locationLatitude: { 39 | type: DataTypes.DOUBLE, 40 | allowNull: true 41 | }, 42 | locationLongitude: { 43 | type: DataTypes.DOUBLE, 44 | allowNull: true 45 | }, 46 | locationAccuracy: { 47 | type: DataTypes.INTEGER, 48 | allowNull: true 49 | }, 50 | pluginsOwnId: { 51 | type: DataTypes.STRING, 52 | allowNull: true 53 | }, 54 | isReconciling: { 55 | type: DataTypes.BOOLEAN, 56 | allowNull: false, 57 | defaultValue: false 58 | } 59 | }; 60 | } 61 | }; -------------------------------------------------------------------------------- /models/unit.js: -------------------------------------------------------------------------------- 1 | module.exports = class UnitModelDefinition { 2 | static getDefinition(DataTypes) { 3 | return { 4 | id: { 5 | type: DataTypes.UUID, 6 | primaryKey: true, 7 | defaultValue: DataTypes.UUIDV4 8 | }, 9 | amount: { 10 | type: DataTypes.INTEGER, 11 | allowNull: false 12 | }, 13 | type: { 14 | type: DataTypes.ENUM('INCOME', 'INCOME_NEXT', 'BUDGET', 'TRANSFER'), 15 | allowNull: true 16 | }, 17 | memo: { 18 | type: DataTypes.STRING(512), 19 | allowNull: true 20 | } 21 | }; 22 | } 23 | }; -------------------------------------------------------------------------------- /models/user.js: -------------------------------------------------------------------------------- 1 | module.exports = class UserModelDefinition { 2 | static getDefinition(DataTypes) { 3 | return { 4 | id: { 5 | type: DataTypes.UUID, 6 | primaryKey: true, 7 | defaultValue: DataTypes.UUIDV4 8 | }, 9 | email: { 10 | type: DataTypes.STRING, 11 | allowNull: false, 12 | unique: true 13 | }, 14 | password: { 15 | type: DataTypes.STRING, 16 | allowNull: false 17 | }, 18 | isAdmin: { 19 | type: DataTypes.BOOLEAN, 20 | allowNull: false, 21 | defaultValue: false 22 | }, 23 | keychainKey: { 24 | type: DataTypes.STRING(1024), 25 | allowNull: true, 26 | defaultValue: null 27 | }, 28 | needsPasswordChange: { 29 | type: DataTypes.BOOLEAN, 30 | allowNull: false, 31 | defaultValue: false 32 | }, 33 | acceptedTermVersion: { 34 | type: DataTypes.INTEGER.UNSIGNED, 35 | allowNull: true, 36 | defaultValue: null 37 | }, 38 | otpKey: { 39 | type: DataTypes.STRING, 40 | allowNull: true 41 | }, 42 | otpEnabled: { 43 | type: DataTypes.BOOLEAN, 44 | allowNull: false, 45 | defaultValue: false 46 | } 47 | }; 48 | } 49 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ubud-app/server", 3 | "description": "A small private software for budgeting", 4 | "bin": { 5 | "ubud-db": "./bin/database", 6 | "ubud-plugin": "./bin/plugin", 7 | "ubud-user": "./bin/user", 8 | "ubud-server": "./server.js" 9 | }, 10 | "scripts": { 11 | "test": "mocha | bunyan -o short -l error", 12 | "check": "npm run check:eslint", 13 | "check:quick": "npm run check:eslint", 14 | "check:eslint": "eslint ./" 15 | }, 16 | "author": "Sebastian Pekarek ", 17 | "homepage": "https://ubud.club", 18 | "bugs": { 19 | "url": "https://github.com/ubud-app/client/issues" 20 | }, 21 | "dependencies": { 22 | "@sentry/node": "^8.30.0", 23 | "basic-auth": "^2.0.1", 24 | "bcryptjs": "^2.4.3", 25 | "bunyan": "^1.8.15", 26 | "cli-table": "^0.3.11", 27 | "commander": "^12.1.0", 28 | "cors": "^2.8.5", 29 | "dtrace-provider": "^0.8.8", 30 | "email-validator": "^2.0.4", 31 | "express": "^5.0.0", 32 | "express-fileupload": "^1.5.1", 33 | "hibp": "^14.1.2", 34 | "moment": "^2.30.1", 35 | "mt940-js": "^1.0.0", 36 | "mysql2": "^3.11.3", 37 | "neat-csv": "^7.0.0", 38 | "promised-exec": "^1.0.1", 39 | "semver": "^7.6.3", 40 | "sequelize": "^6.37.3", 41 | "shell-escape": "^0.2.0", 42 | "socket.io": "^4.7.5", 43 | "tslib": "^2.7.0", 44 | "umzug": "^3.8.1", 45 | "underscore": "^1.13.7", 46 | "uuid": "^10.0.0" 47 | }, 48 | "optionalDependencies": { 49 | "@ubud-app/client": "^1.1.0", 50 | "ofx": "^0.4.0" 51 | }, 52 | "devDependencies": { 53 | "@sebbo2002/semantic-release-docker": "^4.0.3", 54 | "@semantic-release/changelog": "^6.0.3", 55 | "@semantic-release/exec": "^6.0.3", 56 | "@semantic-release/git": "^10.0.1", 57 | "@semantic-release/github": "^10.3.4", 58 | "@semantic-release/npm": "^12.0.1", 59 | "eslint": "^8.56.0", 60 | "eslint-plugin-node": "^11.1.0", 61 | "eslint-plugin-security": "^2.1.0", 62 | "mocha": "^10.7.3", 63 | "nyc": "^17.0.0", 64 | "semantic-release": "^24.1.1" 65 | }, 66 | "engines": { 67 | "node": ">=12.0.0" 68 | }, 69 | "version": "0.6.1" 70 | } 71 | -------------------------------------------------------------------------------- /release.config.js: -------------------------------------------------------------------------------- 1 | const plugins = [ 2 | ['@semantic-release/commit-analyzer', { 3 | preset: 'angular', 4 | releaseRules: [ 5 | {type: 'refactor', release: 'patch'}, 6 | {type: 'style', release: 'patch'}, 7 | {type: 'ci', release: 'patch'}, 8 | {type: 'build', scope: 'deps', release: 'patch'} 9 | ], 10 | parserOpts: { 11 | noteKeywords: ['BREAKING CHANGE', 'BREAKING CHANGES', 'BREAKING'] 12 | } 13 | }], 14 | ['@semantic-release/release-notes-generator', { 15 | preset: 'angular', 16 | parserOpts: { 17 | noteKeywords: ['BREAKING CHANGE', 'BREAKING CHANGES', 'BREAKING'] 18 | }, 19 | writerOpts: { 20 | commitsSort: ['subject', 'scope'] 21 | } 22 | }], 23 | '@semantic-release/changelog', 24 | ['@semantic-release/exec', { 25 | prepareCmd: 'VERSION=${nextRelease.version} BRANCH=${options.branch} ./.github/workflows/release-prepare.sh', 26 | successCmd: 'VERSION=${nextRelease.version} BRANCH=${options.branch} ./.github/workflows/release-success.sh' 27 | }], 28 | '@semantic-release/npm', 29 | '@semantic-release/github' 30 | ]; 31 | 32 | // eslint-disable-next-line node/no-process-env 33 | if (process.env.BRANCH === 'main') { 34 | plugins.push(['@semantic-release/git', { 35 | 'assets': ['CHANGELOG.md', 'package.json', 'package-lock.json'], 36 | 'message': 'chore(release): :bookmark: ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}' 37 | }]); 38 | } 39 | 40 | plugins.push(['@sebbo2002/semantic-release-docker', { 41 | images: [ 42 | process.env.DOCKER_LOCAL_IMAGE_DH, 43 | process.env.DOCKER_LOCAL_IMAGE_GH 44 | ] 45 | }]); 46 | 47 | module.exports = { 48 | branches: [ 49 | { 50 | name: 'main', 51 | channel: 'latest', 52 | prerelease: false 53 | }, 54 | { 55 | name: 'develop', 56 | channel: 'next', 57 | prerelease: true 58 | } 59 | ], 60 | plugins 61 | }; 62 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 'use strict'; 3 | 4 | const Server = require('./helpers/server'); 5 | Server.initialize(); 6 | 7 | process.title = 'ubud-server'; -------------------------------------------------------------------------------- /test/migrations.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | const DatabaseHelper = require('../helpers/database'); 3 | 4 | describe('Migrations', function() { 5 | it('should run without errors', async function() { 6 | await DatabaseHelper.reset(); 7 | await DatabaseHelper.getMigrator().up(); 8 | assert.ok(true); 9 | }); 10 | }); 11 | --------------------------------------------------------------------------------