From 35b6c3b1df0427f55c00d7ba96d80ba8b07e72e0 Mon Sep 17 00:00:00 2001 From: Lena Emme <38376566+Feuerhamster@users.noreply.github.com> Date: Sat, 20 Jun 2026 17:16:04 +0200 Subject: [PATCH 01/12] remove old --- .dockerignore | 1 - .editorconfig | 11 - .github/workflows/docker-publish.yml | 37 - .gitignore | 117 - Dockerfile | 41 - README.md | 108 - package-lock.json | 2205 ----------------- package.json | 51 - src/controllers/access.controller.ts | 35 - src/controllers/default.controller.ts | 19 - src/controllers/entries.controller.ts | 262 -- src/controllers/geodata.controller.ts | 36 - src/controllers/report.controller.ts | 51 - src/graphql/management-users.gql | 11 - src/graphql/user-me.gql | 7 - src/graphql/users.gql | 7 - src/main.ts | 30 - src/middleware/auth.middleware.ts | 61 - src/middleware/csrf.middleware.ts | 36 - src/middleware/errorFunction.middleware.ts | 14 - src/middleware/queryArrayParser.middleware.ts | 36 - .../queryNumberParser.middleware.ts | 35 - src/middleware/trimAndNullify.middleware.ts | 35 - src/middleware/validation.middleware.ts | 126 - src/models/database/collectionMeta.model.ts | 16 - src/models/database/entry.model.ts | 54 - src/models/database/geodata.model.ts | 21 - src/models/entryMapping.ts | 17 - src/models/request.ts | 8 - src/models/request/entries.request.ts | 226 -- src/models/request/geo.request.ts | 7 - src/models/request/objectId.request.ts | 9 - src/models/request/report.request.ts | 18 - src/models/request/users.request.ts | 48 - src/models/response.ts | 4 - src/models/response/entries.response.ts | 23 - src/models/response/error.response.ts | 20 - src/models/response/users.response.ts | 7 - src/server.ts | 63 - src/services/cms.service.ts | 63 - src/services/config.service.ts | 158 -- src/services/database.service.ts | 427 ---- src/services/entry.service.ts | 347 --- src/services/osm.service.ts | 59 - src/services/users.service.ts | 92 - src/types/auth.d.ts | 10 - src/types/cms.ts | 36 - src/types/dictionary.d.ts | 5 - src/types/global.d.ts | 15 - src/types/httpStatusCodes.ts | 24 - src/types/osm.d.ts | 23 - src/util/array.util.ts | 30 - src/util/asciiConverter.util.ts | 23 - src/util/customValidators.util.ts | 91 - src/util/filter.util.ts | 31 - src/util/graphql.util.ts | 16 - src/util/regExp.util.ts | 14 - src/util/removeEmpty.util.ts | 15 - src/util/shell.util.ts | 83 - tsconfig.json | 15 - 60 files changed, 5490 deletions(-) delete mode 100644 .dockerignore delete mode 100644 .editorconfig delete mode 100644 .github/workflows/docker-publish.yml delete mode 100644 .gitignore delete mode 100644 Dockerfile delete mode 100644 README.md delete mode 100644 package-lock.json delete mode 100644 package.json delete mode 100644 src/controllers/access.controller.ts delete mode 100644 src/controllers/default.controller.ts delete mode 100644 src/controllers/entries.controller.ts delete mode 100644 src/controllers/geodata.controller.ts delete mode 100644 src/controllers/report.controller.ts delete mode 100644 src/graphql/management-users.gql delete mode 100644 src/graphql/user-me.gql delete mode 100644 src/graphql/users.gql delete mode 100644 src/main.ts delete mode 100644 src/middleware/auth.middleware.ts delete mode 100644 src/middleware/csrf.middleware.ts delete mode 100644 src/middleware/errorFunction.middleware.ts delete mode 100644 src/middleware/queryArrayParser.middleware.ts delete mode 100644 src/middleware/queryNumberParser.middleware.ts delete mode 100644 src/middleware/trimAndNullify.middleware.ts delete mode 100644 src/middleware/validation.middleware.ts delete mode 100644 src/models/database/collectionMeta.model.ts delete mode 100644 src/models/database/entry.model.ts delete mode 100644 src/models/database/geodata.model.ts delete mode 100644 src/models/entryMapping.ts delete mode 100644 src/models/request.ts delete mode 100644 src/models/request/entries.request.ts delete mode 100644 src/models/request/geo.request.ts delete mode 100644 src/models/request/objectId.request.ts delete mode 100644 src/models/request/report.request.ts delete mode 100644 src/models/request/users.request.ts delete mode 100644 src/models/response.ts delete mode 100644 src/models/response/entries.response.ts delete mode 100644 src/models/response/error.response.ts delete mode 100644 src/models/response/users.response.ts delete mode 100644 src/server.ts delete mode 100644 src/services/cms.service.ts delete mode 100644 src/services/config.service.ts delete mode 100644 src/services/database.service.ts delete mode 100644 src/services/entry.service.ts delete mode 100644 src/services/osm.service.ts delete mode 100644 src/services/users.service.ts delete mode 100644 src/types/auth.d.ts delete mode 100644 src/types/cms.ts delete mode 100644 src/types/dictionary.d.ts delete mode 100644 src/types/global.d.ts delete mode 100644 src/types/httpStatusCodes.ts delete mode 100644 src/types/osm.d.ts delete mode 100644 src/util/array.util.ts delete mode 100644 src/util/asciiConverter.util.ts delete mode 100644 src/util/customValidators.util.ts delete mode 100644 src/util/filter.util.ts delete mode 100644 src/util/graphql.util.ts delete mode 100644 src/util/regExp.util.ts delete mode 100644 src/util/removeEmpty.util.ts delete mode 100644 src/util/shell.util.ts delete mode 100644 tsconfig.json diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index b512c09..0000000 --- a/.dockerignore +++ /dev/null @@ -1 +0,0 @@ -node_modules \ No newline at end of file diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index 3ae1186..0000000 --- a/.editorconfig +++ /dev/null @@ -1,11 +0,0 @@ -# EditorConfig is awesome: https://editorconfig.org - -# top-most EditorConfig file -root = true - -# Unix-style newlines with a newline ending every file -[*] -charset = utf-8 -indent_style = tab -indent_size = 4 - diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml deleted file mode 100644 index 4396341..0000000 --- a/.github/workflows/docker-publish.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Docker - -on: - push: - # Publish `main` as Docker `latest` image. - branches: - - main - -jobs: - # Push image to GitHub Packages. - # See also https://docs.docker.com/docker-hub/builds/ - push: - runs-on: ubuntu-latest - if: github.event_name == 'push' - - steps: - - uses: actions/checkout@v2 - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Login to Github container registry - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Build and push - uses: docker/build-push-action@v6 - with: - platforms: linux/amd64,linux/arm64 - push: true - tags: ghcr.io/transdb-de/backend/transdb-backend:latest diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 8b330af..0000000 --- a/.gitignore +++ /dev/null @@ -1,117 +0,0 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -lerna-debug.log* - -# Diagnostic reports (https://nodejs.org/api/report.html) -report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json - -# Runtime data -pids -*.pid -*.seed -*.pid.lock - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul -coverage -*.lcov - -# nyc test coverage -.nyc_output - -# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# Bower dependency directory (https://bower.io/) -bower_components - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (https://nodejs.org/api/addons.html) -build/Release - -# Dependency directories -node_modules/ -jspm_packages/ - -# TypeScript v1 declaration files -typings/ - -# TypeScript cache -*.tsbuildinfo - -# Optional npm cache directory -.npm - -# Optional eslint cache -.eslintcache - -# Microbundle cache -.rpt2_cache/ -.rts2_cache_cjs/ -.rts2_cache_es/ -.rts2_cache_umd/ - -# Optional REPL history -.node_repl_history - -# Output of 'npm pack' -*.tgz - -# Yarn Integrity file -.yarn-integrity - -# dotenv environment variables file -.env -.env.test - -# parcel-bundler cache (https://parceljs.org/) -.cache - -# Next.js build output -.next - -# Nuxt.js build / generate output -.nuxt -dist - -# Gatsby files -.cache/ -# Comment in the public line in if your project uses Gatsby and *not* Next.js -# https://nextjs.org/blog/next-9-1#public-directory-support -# public - -# vuepress build output -.vuepress/dist - -# Serverless directories -.serverless/ - -# FuseBox cache -.fusebox/ - -# DynamoDB Local files -.dynamodb/ - -# TernJS port file -.tern-port - -# Config file -config.json - -# Exclude IDE's -.idea -.vs -.vscode - -# Compiled Javascript - -dist/ -files/ diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 8c3b8f8..0000000 --- a/Dockerfile +++ /dev/null @@ -1,41 +0,0 @@ -FROM node:16.17.0-alpine AS build - -WORKDIR /tmp/ - -# required by npm for github packages -RUN apk add --no-cache git - -# install dependencies -COPY ["package.json", "package-lock.json*", "tsconfig.json", "./"] -RUN npm install - -# copy source files -COPY src/ ./src/ - -# build -RUN npm run build - -FROM node:16.17.0-alpine - -# install required cli tools -RUN apk add --no-cache git && \ - apk add --no-cache mongodb-tools - -WORKDIR /app/ - -COPY --from=build ["tmp/package.json", "tmp/package-lock.json*", "./"] - -ENV NODE_ENV=production - -# install prod dependencies -RUN npm install - -COPY --from=build tmp/dist/ ./dist/ -COPY --from=build tmp/src/graphql/ ./src/graphql/ - -RUN apk del git - -# run once, to create config file -RUN npm start; exit 0 - -CMD ["npm", "start"] diff --git a/README.md b/README.md deleted file mode 100644 index 3db5caf..0000000 --- a/README.md +++ /dev/null @@ -1,108 +0,0 @@ -# TransDB Backend & API - -The backend for the TransDB site, responsible for handling all PI traffic. - -## Core Dependencies - -- [TypeScript](https://www.typescriptlang.org/) Type-safe JavaScript superset. -- [OvernightJS](https://github.com/seanpmaxwell/overnight) A TypeScript express.js wrapper for decorator support and eased type safety. -- [Class Validator](https://github.com/typestack/class-validator) TypeScript compatible validation. - -## Project Structure - -### Directories - -All non-configuration files are within the `./src` parent directory. - -- `controllers` Api routes and endpoints. Each Controller is responsible for one route, and can contain multiple Endpoints. -- `middleware` express.js middleware. Middleware modifies or validates incoming requests. -- `models` Typed classes and interfaces used for compile-time and run-time type validation. -- `services` ES-modules handling the bulk of the backend logic. -- `types` Additional TypeScript types, and express type extensions. -- `util` Collections of functions which can be used anywhere in the project. - -### Services - -- `config.service.ts` Initializes, parses, and updates the backend configuration file. -- `database.service.ts` Wraps database access in a type-safe manner. -- `entry.service.ts` Handles the creation, management, and deletion of entries. -- `osm.serivce.ts` OpenStreetMap Api interaction. Used to retrieve the GeoLocation of Entries. -- `users.service.ts` Handles the creation, management, and deletion of users. - -## Setup - -**Requirements:** NodeJS 14.15.4 or higher, NPM, a running MongoDB server, with MongoDB Database Tools installed. - -**Warning!** MongoDB Database Tools needs to be running on the same machine as NodeJS, -not on MongoDB Server. If MongoDB Database Tools is not installed, database backups will fail! - -1. Download and extract latest release from [releases](/releases/latest). -2. Run `npm install`. -3. Run `npm start`. -4. On first start the application will exit. A `config.json` file will now be in the root directory. -5. Fill out all config fields. -6. Open your MongoDB, create a collection named "geodata" and import the data.json file in GeoDbJson.zip from [Tool Downloads](https://github.com/TransDB-de/Tools/releases/tag/0.1.2) into the collection as json. -7. Run `npm start` again. - -## Contributing - -### Style Guide - -#### Indents - -Use Tab-Stops instead of spaces. Make sure to configure your editor. - -Do not remove the indents from empty lines. This is **not** the default behaviour of most code-editors, so be sure to change it. - -Do not use Hanging indents. - -#### Spacing - -Leave two empty lines between top-level scopes (functions, classes and types). - -Add an empty new-line to the end of a file. - -Leave a space within curly brackets. eg. `import { IRequest } from "express"` - -Leave a space between control statements and brackets. eg. `if (count >= 5) { ... }` - -Do not leave a space between a function call and it's brackets. eg. `filterEntry(newEntry);` - -#### Case - -`camelCase` files, functions, and variables. - -`PascalCase` Classes, Modules, and Decorators. - -#### ES6 - -Use es6 module `import` `export` syntax. - -Use `async` `await` instead of call-backs where possible. - -#### Comments - -JSDoc comment exported functions. Comments should be in English. Capitalize Names. Do not place a full stop on single-line comments. - -#### Naming Convention - -Prefix routes which require a login with `authorized`. - -Prefix routes which require admin credentials with `admin`. - -Using I and E as prefixes to indicate interfaces and enums is optional. - -### Type Safety - -Avoid explicit `any` and `unkown` where possible. Unknown is sometimes required when interacting with the Database, but should not be used outside of the Database Service. -Try using generics and `asserts` to avoid the use of unknown. The filter functions in `util/filter` can help with this for entries and users. - -Do not use `Request` and `Response` from express.js. Use `IRequest` and `IResponse` instead. See `controllers` for usage examples. - -Never try to avoid a type Error with an explicit `any` annotation. - -Make sure to annotate internal-only types with `never`. Omitting them is not sufficient, as typescript still allows this for interfaces. See `models/response` for usage examples. - -Do not use .js files. - -**Warning** You need to add .js endings when importing non-package modules. TypeScript does not throw an error if this is omitted, but a runtime error will be thrown. diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 3c566e4..0000000 --- a/package-lock.json +++ /dev/null @@ -1,2205 +0,0 @@ -{ - "name": "transdb-backend", - "version": "2.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "transdb-backend", - "version": "2.0.0", - "license": "ISC", - "dependencies": { - "@overnightjs/core": "^1.7.6", - "@transdb-de/filter-lang": "github:transdb-de/filter-lang", - "axios": "^0.24.0", - "axios-rate-limit": "^1.9.0", - "class-transformer": "^0.5.1", - "class-validator": "^0.13.2", - "cors": "^2.8.6", - "express": "^4.22.2", - "express-rate-limit": "^5.5.1", - "express-slow-down": "^1.6.0", - "helmet": "^4.6.0", - "jsonwebtoken": "^8.5.1", - "libphonenumber-js": "^1.13.2", - "mongodb": "^4.17.2", - "nanoid": "^3.3.12", - "node-cleanup": "^2.1.2" - }, - "devDependencies": { - "@types/cors": "^2.8.19", - "@types/express": "^4.17.25", - "@types/express-rate-limit": "^5.1.3", - "@types/express-slow-down": "^1.3.5", - "@types/jsonwebtoken": "^8.5.9", - "@types/mongodb": "^4.0.7", - "@types/node": "^16.18.126", - "@types/node-cleanup": "^2.1.5", - "typescript": "^4.9.5" - } - }, - "node_modules/@aws-crypto/crc32": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", - "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-crypto/sha256-browser": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", - "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@aws-crypto/sha256-js": "^5.2.0", - "@aws-crypto/supports-web-crypto": "^5.2.0", - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "@aws-sdk/util-locate-window": "^3.0.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/sha256-js": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", - "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-crypto/supports-web-crypto": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", - "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/util": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", - "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@aws-sdk/types": "^3.222.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-sdk/client-cognito-identity": { - "version": "3.1048.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity/-/client-cognito-identity-3.1048.0.tgz", - "integrity": "sha512-eJUSxEwhz9fKmjhRgOvFTuJ+SjCu15SpgYo3aSJ6rjs2IaWi2F8NcvOuejHb0a01a/J71/9bdjUDrQLH2kUmkg==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.11", - "@aws-sdk/credential-provider-node": "^3.972.42", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/fetch-http-handler": "^5.4.2", - "@smithy/node-http-handler": "^4.7.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/core": { - "version": "3.974.11", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.11.tgz", - "integrity": "sha512-QpnINq5FZH6EOaDEkmHdT7eUunbvD27pDNQypaWjFyYz7Zl1q3UCMQErBZxpmfGfI7MvI2TlK8KTkgNpv8b1ug==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@aws-sdk/xml-builder": "^3.972.24", - "@aws/lambda-invoke-store": "^0.2.2", - "@smithy/core": "^3.24.2", - "@smithy/signature-v4": "^5.4.2", - "@smithy/types": "^4.14.1", - "bowser": "^2.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-cognito-identity": { - "version": "3.972.34", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-cognito-identity/-/credential-provider-cognito-identity-3.972.34.tgz", - "integrity": "sha512-hbOTV+vNi3kKo3J6qxylHSfcnSbnnVoMe1KA1VMjYazAEPmk+HSgLUksOemptoNldvSLTSj82ndB1mcZDMP3iQ==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@aws-sdk/nested-clients": "^3.997.9", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.37", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.37.tgz", - "integrity": "sha512-/jpPvEh6f7ntmIzf7dNxoNX6Q8vt8UpesCjbW6mFfk4V1NW6bIy9qxcQ6WbA8As5yQhsZOe+xeNd4xHX8kdY2Q==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@aws-sdk/core": "^3.974.11", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.39", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.39.tgz", - "integrity": "sha512-pIgTpisWyWg7X1bUbzSjuUYosYTD0Ghz2M0hkSTmb3a6i3qV3uU+NYJPI/E2XSC0HcsZh5rsLPzeXrkb2DS0Cg==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@aws-sdk/core": "^3.974.11", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/fetch-http-handler": "^5.4.2", - "@smithy/node-http-handler": "^4.7.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.41", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.41.tgz", - "integrity": "sha512-u2tyjaxJJzW8UtW4SM1ZcPMDwO6y+kV+llvou+Adts0FAKyzes5jG4izQN+KX3yE8ZROpS5y1LJ//xL2iSf76w==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@aws-sdk/core": "^3.974.11", - "@aws-sdk/credential-provider-env": "^3.972.37", - "@aws-sdk/credential-provider-http": "^3.972.39", - "@aws-sdk/credential-provider-login": "^3.972.41", - "@aws-sdk/credential-provider-process": "^3.972.37", - "@aws-sdk/credential-provider-sso": "^3.972.41", - "@aws-sdk/credential-provider-web-identity": "^3.972.41", - "@aws-sdk/nested-clients": "^3.997.9", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/credential-provider-imds": "^4.3.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.41", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.41.tgz", - "integrity": "sha512-0LBitxXiAiaE5nlFPfpNIww/8FRY/I7WIndWsc9GmNFOM7cE1wNpVNQEGEk9Outg5l8xl+3vybxFyUy4l9q/LQ==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@aws-sdk/core": "^3.974.11", - "@aws-sdk/nested-clients": "^3.997.9", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.42", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.42.tgz", - "integrity": "sha512-D4oon2zbqqsWOJUM99Gm3/ZyJ0IJvTXVN3PyloGb3kQEyI36fjCZheZj422lAgTWWd6TSHgiImLt3RIaLdv3dQ==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.37", - "@aws-sdk/credential-provider-http": "^3.972.39", - "@aws-sdk/credential-provider-ini": "^3.972.41", - "@aws-sdk/credential-provider-process": "^3.972.37", - "@aws-sdk/credential-provider-sso": "^3.972.41", - "@aws-sdk/credential-provider-web-identity": "^3.972.41", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/credential-provider-imds": "^4.3.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.37", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.37.tgz", - "integrity": "sha512-7nVaHBUaWIddASYfVaA9O4D5ZVjewU3sCol9WqZPGfW0nR+0WqE0xHZnD/U2L33PlOB8KNXGKZ6wOES/QijKzg==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@aws-sdk/core": "^3.974.11", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.41", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.41.tgz", - "integrity": "sha512-IOWAWEHe5LkjSKkkUUX9ciV6Y1scHTsnfEkdt5yyC4Slrc7AGbkLPrpntjqh18ksJAMOaVhoBsO8p2WyTcY2wQ==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@aws-sdk/core": "^3.974.11", - "@aws-sdk/nested-clients": "^3.997.9", - "@aws-sdk/token-providers": "3.1048.0", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.41", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.41.tgz", - "integrity": "sha512-mbACk9Yypa8nm4iGZLs0PofOXEcTDOUw6wDnsPXNDNSd2WNXs1tSo+6nc/fh0jLYdfVZThhBL98PHW4aXFsG5A==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@aws-sdk/core": "^3.974.11", - "@aws-sdk/nested-clients": "^3.997.9", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-providers": { - "version": "3.1048.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-providers/-/credential-providers-3.1048.0.tgz", - "integrity": "sha512-qradm+eSLJTWQLd/TOxlETL1rMQ/ozvr2iU7wga5hqoox/FiXV9VLtomv3Cqwa6GdpYGWI8ebfSu6mS18I1PyQ==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@aws-sdk/client-cognito-identity": "3.1048.0", - "@aws-sdk/core": "^3.974.11", - "@aws-sdk/credential-provider-cognito-identity": "^3.972.34", - "@aws-sdk/credential-provider-env": "^3.972.37", - "@aws-sdk/credential-provider-http": "^3.972.39", - "@aws-sdk/credential-provider-ini": "^3.972.41", - "@aws-sdk/credential-provider-login": "^3.972.41", - "@aws-sdk/credential-provider-node": "^3.972.42", - "@aws-sdk/credential-provider-process": "^3.972.37", - "@aws-sdk/credential-provider-sso": "^3.972.41", - "@aws-sdk/credential-provider-web-identity": "^3.972.41", - "@aws-sdk/nested-clients": "^3.997.9", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/credential-provider-imds": "^4.3.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/nested-clients": { - "version": "3.997.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.9.tgz", - "integrity": "sha512-jPR3rnmRI4hWYyzfmTGBr7NblMp8QYYeflHXba1H6+7CGrWVqWKQzaXFQ4qbExqPRsXN3T3L3JxFhr6aouXUGQ==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.11", - "@aws-sdk/signature-v4-multi-region": "^3.996.27", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/fetch-http-handler": "^5.4.2", - "@smithy/node-http-handler": "^4.7.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.27", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.27.tgz", - "integrity": "sha512-0Phbz4t6HI3D3skxvG2uI+VWU034/nSIw1T8d+FPzzQG9EQTrw94o9mOKO2Gv3n3Oc8P7JD7RAUxkoneLWv5Eg==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/signature-v4": "^5.4.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/token-providers": { - "version": "3.1048.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1048.0.tgz", - "integrity": "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@aws-sdk/core": "^3.974.11", - "@aws-sdk/nested-clients": "^3.997.9", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/types": { - "version": "3.973.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz", - "integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/util-locate-window": { - "version": "3.965.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", - "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.24", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.24.tgz", - "integrity": "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@nodable/entities": "2.1.0", - "@smithy/types": "^4.14.1", - "fast-xml-parser": "5.7.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws/lambda-invoke-store": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", - "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", - "license": "Apache-2.0", - "optional": true, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@mongodb-js/saslprep": { - "version": "1.4.11", - "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.11.tgz", - "integrity": "sha512-o9rAHc0IpIjuPSxRutWpE1F62x7n+4mVS4rCNHkzhIUMQcc18bb6xEq5wd2NdN0WjepIyXIppRshYI2kQDOZVA==", - "license": "MIT", - "optional": true, - "dependencies": { - "sparse-bitfield": "^3.0.3" - } - }, - "node_modules/@nodable/entities": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", - "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/nodable" - } - ], - "license": "MIT", - "optional": true - }, - "node_modules/@overnightjs/core": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@overnightjs/core/-/core-1.7.6.tgz", - "integrity": "sha512-2MeVIG8MTLKAqLQKm7UvwDmMchhHka9A36o2PxjT0ES4wHm2lg6RkClRDrsavGU6LmHsx6WDQfVC11/aXZQjkQ==", - "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", - "license": "MIT", - "dependencies": { - "express": "^4.16.3", - "reflect-metadata": "^0.1.13", - "tslib": "^2.0.0" - } - }, - "node_modules/@smithy/core": { - "version": "3.24.3", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.3.tgz", - "integrity": "sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@aws-crypto/crc32": "5.2.0", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/credential-provider-imds": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.3.tgz", - "integrity": "sha512-I2Bti0DKFo2IJyN28ijCsx51BAumEYR4/1yZ1FXyBygy9MqbnMqCev4JPth/MbpRfBSRAX35hITSnAdJRo1u5w==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/fetch-http-handler": { - "version": "5.4.3", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.3.tgz", - "integrity": "sha512-F+DRf8IJazRJgYog2A/yJK7eYVc0rqTlRzO+5ZxjJd4WkZoKz0IJRncf7G6t1pdVT3kryJcwuTFhN1c5m6N47A==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@smithy/node-http-handler": { - "version": "4.7.3", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", - "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/signature-v4": { - "version": "5.4.3", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.3.tgz", - "integrity": "sha512-53+75QuPl6DL+ct6vVEB51FDO5oulXr20TPV46VvJZg76lIlXNWfxi8j+G2V/t0I2qxCBOa3vX/8bmjrpFVo9g==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/types": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.2.tgz", - "integrity": "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@transdb-de/filter-lang": { - "version": "0.1.2", - "resolved": "git+ssh://git@github.com/transdb-de/filter-lang.git#2ed5d1b7acf73c40b33dfd2d4a6ae97bb4c88bab", - "license": "MIT" - }, - "node_modules/@types/body-parser": { - "version": "1.19.6", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", - "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/connect": "*", - "@types/node": "*" - } - }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/cors": { - "version": "2.8.19", - "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", - "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/express": { - "version": "4.17.25", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", - "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.33", - "@types/qs": "*", - "@types/serve-static": "^1" - } - }, - "node_modules/@types/express-rate-limit": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/@types/express-rate-limit/-/express-rate-limit-5.1.3.tgz", - "integrity": "sha512-H+TYy3K53uPU2TqPGFYaiWc2xJV6+bIFkDd/Ma2/h67Pa6ARk9kWE0p/K9OH1Okm0et9Sfm66fmXoAxsH2PHXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/express": "*" - } - }, - "node_modules/@types/express-serve-static-core": { - "version": "4.19.8", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", - "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" - } - }, - "node_modules/@types/express-slow-down": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/express-slow-down/-/express-slow-down-1.3.5.tgz", - "integrity": "sha512-1aN8dIQPFXiYfIJuXRNvzdQcfDVQJHag0OuxAXNpqb4218z+b9QbUCO4/ctwu1u0pXVOwTDJZX+7qankeOHXJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/express": "*" - } - }, - "node_modules/@types/http-errors": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", - "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/jsonwebtoken": { - "version": "8.5.9", - "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-8.5.9.tgz", - "integrity": "sha512-272FMnFGzAVMGtu9tkr29hRL6bZj4Zs1KZNeHLnKqAvp06tAIcarTMwOh8/8bz4FmKRcMxZhZNeUAQsNLoiPhg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/mime": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/mongodb": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/@types/mongodb/-/mongodb-4.0.7.tgz", - "integrity": "sha512-lPUYPpzA43baXqnd36cZ9xxorprybxXDzteVKCPAdp14ppHtFJHnXYvNpmBvtMUTb5fKXVv6sVbzo1LHkWhJlw==", - "deprecated": "mongodb provides its own types. @types/mongodb is no longer needed.", - "dev": true, - "license": "MIT", - "dependencies": { - "mongodb": "*" - } - }, - "node_modules/@types/node": { - "version": "16.18.126", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.126.tgz", - "integrity": "sha512-OTcgaiwfGFBKacvfwuHzzn1KLxH/er8mluiy8/uM3sGXHaRe73RrSIj01jow9t4kJEW633Ov+cOexXeiApTyAw==", - "license": "MIT" - }, - "node_modules/@types/node-cleanup": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@types/node-cleanup/-/node-cleanup-2.1.5.tgz", - "integrity": "sha512-+82RAk5uYiqiMoEv2fPeh03AL4pB5d3TL+Pf+hz31Mme6ECFI1kRlgmxYjdSlHzDbJ9yLorTnKi4Op5FA54kQQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/qs": { - "version": "6.15.1", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", - "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/range-parser": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", - "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/serve-static": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", - "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/http-errors": "*", - "@types/node": "*", - "@types/send": "<1" - } - }, - "node_modules/@types/serve-static/node_modules/@types/send": { - "version": "0.17.6", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", - "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/mime": "^1", - "@types/node": "*" - } - }, - "node_modules/@types/webidl-conversions": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", - "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", - "license": "MIT" - }, - "node_modules/@types/whatwg-url": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-8.2.2.tgz", - "integrity": "sha512-FtQu10RWgn3D9U4aazdwIE2yzphmTJREDqNdODHrbrZmmMqI0vMheC/6NE/J1Yveaj8H+ela+YwWTjq5PGmuhA==", - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@types/webidl-conversions": "*" - } - }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "license": "MIT", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" - }, - "node_modules/axios": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.24.0.tgz", - "integrity": "sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.14.4" - } - }, - "node_modules/axios-rate-limit": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/axios-rate-limit/-/axios-rate-limit-1.9.0.tgz", - "integrity": "sha512-p79ltBSUF+8i4uLYr1lu8s28chIaQ8x/oiV3Vb6NDkNAqkKD/AzTAchRPEXK+weRyAkRTIa/GowKcKDn1BHRPQ==", - "license": "MIT", - "dependencies": { - "axios": ">=0.18.0" - }, - "peerDependencies": { - "axios": "*" - } - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/body-parser": { - "version": "1.20.5", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", - "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "~1.2.0", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "on-finished": "~2.4.1", - "qs": "~6.15.1", - "raw-body": "~2.5.3", - "type-is": "~1.6.18", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/bowser": { - "version": "2.14.1", - "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", - "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", - "license": "MIT", - "optional": true - }, - "node_modules/bson": { - "version": "4.7.2", - "resolved": "https://registry.npmjs.org/bson/-/bson-4.7.2.tgz", - "integrity": "sha512-Ry9wCtIZ5kGqkJoi6aD8KjxFZEx78guTQDnpXWiNthsxzrxAK/i8E6pCHAIZTbaEFWcOCvbecMukfK7XUvyLpQ==", - "license": "Apache-2.0", - "dependencies": { - "buffer": "^5.6.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/buffer-equal-constant-time": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", - "license": "BSD-3-Clause" - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/class-transformer": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", - "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "license": "MIT" - }, - "node_modules/class-validator": { - "version": "0.13.2", - "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.13.2.tgz", - "integrity": "sha512-yBUcQy07FPlGzUjoLuUfIOXzgynnQPPruyK1Ge2B74k9ROwnle1E+NxLWnUv5OLU8hA/qL5leAE9XnXq3byaBw==", - "license": "MIT", - "dependencies": { - "libphonenumber-js": "^1.9.43", - "validator": "^13.7.0" - } - }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", - "license": "MIT" - }, - "node_modules/cors": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", - "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", - "license": "MIT", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ecdsa-sig-formatter": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/express": { - "version": "4.22.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", - "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", - "license": "MIT", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "~1.20.5", - "content-disposition": "~0.5.4", - "content-type": "~1.0.4", - "cookie": "~0.7.1", - "cookie-signature": "~1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "~1.3.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.12", - "proxy-addr": "~2.0.7", - "qs": "~6.15.1", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "~0.19.0", - "serve-static": "~1.16.2", - "setprototypeof": "1.2.0", - "statuses": "~2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express-rate-limit": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-5.5.1.tgz", - "integrity": "sha512-MTjE2eIbHv5DyfuFz4zLYWxpqVhEhkTiwFGuB74Q9CSou2WHO52nlE5y3Zlg6SIsiYUIPj6ifFxnkPz6O3sIUg==", - "license": "MIT" - }, - "node_modules/express-slow-down": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/express-slow-down/-/express-slow-down-1.6.0.tgz", - "integrity": "sha512-M2+Gl6vtvHW5CiRwy82epoiMHWWDVsLws+YnhO2C0lY6eAbRSvRdCA1G1VxSY68y+Urfb74npTO0kdHgC6OPHg==", - "license": "MIT" - }, - "node_modules/fast-xml-builder": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", - "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "optional": true, - "dependencies": { - "path-expression-matcher": "^1.5.0", - "xml-naming": "^0.1.0" - } - }, - "node_modules/fast-xml-parser": { - "version": "5.7.3", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", - "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@nodable/entities": "^2.1.0", - "fast-xml-builder": "^1.1.7", - "path-expression-matcher": "^1.5.0", - "strnum": "^2.2.3" - }, - "bin": { - "fxparser": "src/cli/cli.js" - } - }, - "node_modules/finalhandler": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", - "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "statuses": "~2.0.2", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/helmet": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/helmet/-/helmet-4.6.0.tgz", - "integrity": "sha512-HVqALKZlR95ROkrnesdhbbZJFi/rIVSoNq6f3jA/9u6MIbTsPh3xZwihjeI5+DO/2sOV6HMHooXcEOuwskHpTg==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/jsonwebtoken": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz", - "integrity": "sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w==", - "license": "MIT", - "dependencies": { - "jws": "^3.2.2", - "lodash.includes": "^4.3.0", - "lodash.isboolean": "^3.0.3", - "lodash.isinteger": "^4.0.4", - "lodash.isnumber": "^3.0.3", - "lodash.isplainobject": "^4.0.6", - "lodash.isstring": "^4.0.1", - "lodash.once": "^4.0.0", - "ms": "^2.1.1", - "semver": "^5.6.0" - }, - "engines": { - "node": ">=4", - "npm": ">=1.4.28" - } - }, - "node_modules/jsonwebtoken/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/jwa": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", - "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", - "license": "MIT", - "dependencies": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/jws": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz", - "integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==", - "license": "MIT", - "dependencies": { - "jwa": "^1.4.2", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/libphonenumber-js": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.2.tgz", - "integrity": "sha512-S3kmBrptp3yRTm83NUcHy9g1vbwiWMzI8WvY22+koBJ6zkRteLnedBL2VX0MIAGwx2yiyxX4J85pceZyQ6ffgg==", - "license": "MIT" - }, - "node_modules/lodash.includes": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", - "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", - "license": "MIT" - }, - "node_modules/lodash.isboolean": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", - "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", - "license": "MIT" - }, - "node_modules/lodash.isinteger": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", - "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", - "license": "MIT" - }, - "node_modules/lodash.isnumber": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", - "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", - "license": "MIT" - }, - "node_modules/lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "license": "MIT" - }, - "node_modules/lodash.isstring": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", - "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", - "license": "MIT" - }, - "node_modules/lodash.once": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", - "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", - "license": "MIT" - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/memory-pager": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", - "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", - "license": "MIT", - "optional": true - }, - "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mongodb": { - "version": "4.17.2", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-4.17.2.tgz", - "integrity": "sha512-mLV7SEiov2LHleRJPMPrK2PMyhXFZt2UQLC4VD4pnth3jMjYKHhtqfwwkkvS/NXuo/Fp3vbhaNcXrIDaLRb9Tg==", - "license": "Apache-2.0", - "dependencies": { - "bson": "^4.7.2", - "mongodb-connection-string-url": "^2.6.0", - "socks": "^2.7.1" - }, - "engines": { - "node": ">=12.9.0" - }, - "optionalDependencies": { - "@aws-sdk/credential-providers": "^3.186.0", - "@mongodb-js/saslprep": "^1.1.0" - } - }, - "node_modules/mongodb-connection-string-url": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-2.6.0.tgz", - "integrity": "sha512-WvTZlI9ab0QYtTYnuMLgobULWhokRjtC7db9LtcVfJ+Hsnyr5eo6ZtNAt3Ly24XZScGMelOcGtm7lSn0332tPQ==", - "license": "Apache-2.0", - "dependencies": { - "@types/whatwg-url": "^8.2.1", - "whatwg-url": "^11.0.0" - } - }, - "node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/node-cleanup": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/node-cleanup/-/node-cleanup-2.1.2.tgz", - "integrity": "sha512-qN8v/s2PAJwGUtr1/hYTpNKlD6Y9rc4p8KSmJXyGdYGZsDGKXrGThikLFP9OCHFeLeEpQzPwiAtdIvBLqm//Hw==", - "license": "MIT" - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-expression-matcher": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", - "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/path-to-regexp": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", - "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", - "license": "MIT" - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/qs": { - "version": "6.15.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", - "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/reflect-metadata": { - "version": "0.1.14", - "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.14.tgz", - "integrity": "sha512-ZhYeb6nRaXCfhnndflDK8qI6ZQ/YcWZCISRAWICW9XYqMUwjZM9Z0DveWX/ABN01oxSHwVxKQmxeYZSsm0jh5A==", - "license": "Apache-2.0" - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "license": "ISC", - "bin": { - "semver": "bin/semver" - } - }, - "node_modules/send": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", - "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.1", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "~2.4.1", - "range-parser": "~1.2.1", - "statuses": "~2.0.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/serve-static": { - "version": "1.16.3", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", - "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", - "license": "MIT", - "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "~0.19.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", - "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "license": "MIT", - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks": { - "version": "2.8.9", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", - "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", - "license": "MIT", - "dependencies": { - "ip-address": "^10.1.1", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/sparse-bitfield": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", - "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "memory-pager": "^1.0.2" - } - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/strnum": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", - "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "optional": true - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/tr46": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", - "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", - "license": "MIT", - "dependencies": { - "punycode": "^2.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "license": "MIT", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=4.2.0" - } - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/validator": { - "version": "13.15.35", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz", - "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/whatwg-url": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", - "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", - "license": "MIT", - "dependencies": { - "tr46": "^3.0.0", - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/xml-naming": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", - "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "optional": true, - "engines": { - "node": ">=16.0.0" - } - } - } -} diff --git a/package.json b/package.json deleted file mode 100644 index 57ab443..0000000 --- a/package.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "name": "transdb-backend", - "version": "2.0.0", - "description": "", - "main": "dist/main.js", - "type": "module", - "scripts": { - "build": "npx tsc --sourceMap false", - "start": "node dist/main.js", - "dev": "npx tsc --sourceMap true && node dist/main.js --dev" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/TransDB-de/backend.git" - }, - "author": "", - "license": "ISC", - "bugs": { - "url": "https://github.com/TransDB-de/backend/issues" - }, - "homepage": "https://github.com/TransDB-de/backend#readme", - "dependencies": { - "@overnightjs/core": "^1.7.6", - "@transdb-de/filter-lang": "github:transdb-de/filter-lang", - "axios": "^0.24.0", - "axios-rate-limit": "^1.9.0", - "class-transformer": "^0.5.1", - "class-validator": "^0.13.2", - "cors": "^2.8.6", - "express": "^4.22.2", - "express-rate-limit": "^5.5.1", - "express-slow-down": "^1.6.0", - "helmet": "^4.6.0", - "jsonwebtoken": "^8.5.1", - "libphonenumber-js": "^1.13.2", - "mongodb": "^4.17.2", - "nanoid": "^3.3.12", - "node-cleanup": "^2.1.2" - }, - "devDependencies": { - "@types/cors": "^2.8.19", - "@types/express": "^4.17.25", - "@types/express-rate-limit": "^5.1.3", - "@types/express-slow-down": "^1.3.5", - "@types/jsonwebtoken": "^8.5.9", - "@types/mongodb": "^4.0.7", - "@types/node": "^16.18.126", - "@types/node-cleanup": "^2.1.5", - "typescript": "^4.9.5" - } -} diff --git a/src/controllers/access.controller.ts b/src/controllers/access.controller.ts deleted file mode 100644 index 477a90d..0000000 --- a/src/controllers/access.controller.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { - ClassMiddleware, - Controller, - Middleware, - Post, -} from "@overnightjs/core"; -import { IRequest, IResponse } from "express"; -import rateLimit from "express-rate-limit"; - -import { LoginBody } from "../models/request/users.request.js"; -import validate from "../middleware/validation.middleware.js"; - -import * as UserService from "../services/users.service.js"; -import { config } from "../services/config.service.js"; -import csrfMiddleware from "../middleware/csrf.middleware.js"; - -const loginRateLimiter = rateLimit({ - windowMs: config.rateLimit.login.timeframeMinutes * 60 * 1000, - max: config.rateLimit.login.maxRequests, -}); - -@Controller("access") -@ClassMiddleware(csrfMiddleware) -export default class UsersController { - @Post("login") - @Middleware(loginRateLimiter) - @Middleware(validate(LoginBody)) - async login(req: IRequest, res: IResponse) { - let login = await UserService.login(req.body); - - if (!login) return res.error!("wrong_credentials"); - - res.send(login); - } -} diff --git a/src/controllers/default.controller.ts b/src/controllers/default.controller.ts deleted file mode 100644 index a4a2c10..0000000 --- a/src/controllers/default.controller.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Controller } from "@overnightjs/core"; -import { config } from "../services/config.service.js"; -import { Request, Response } from "express"; -import { Get } from "@overnightjs/core"; -import { ping } from "../services/database.service.js"; - -@Controller("") -export default class DefaultController { - @Get("/") - public info(req: Request, res: Response): void { - res.json(config.info); - } - - @Get("health") - public async healthcheck(req: Request, res: Response) { - const r = await ping(); - res.status(200).json({ db: r }).end(); - } -} diff --git a/src/controllers/entries.controller.ts b/src/controllers/entries.controller.ts deleted file mode 100644 index 30409cb..0000000 --- a/src/controllers/entries.controller.ts +++ /dev/null @@ -1,262 +0,0 @@ -import { - Controller, - Middleware, - Get, - Post, - Patch, - Put, - Delete, - ClassMiddleware, -} from "@overnightjs/core"; -import rateLimit from "express-rate-limit"; -import slowDown from "express-slow-down"; -import { IRequest, IResponse } from "express"; - -import { config } from "../services/config.service.js"; -import * as EntryService from "../services/entry.service.js"; -import * as Database from "../services/database.service.js"; -import * as CMS from "../services/cms.service.js"; - -import queryNumberParser from "../middleware/queryNumberParser.middleware.js"; -import queryArrayParser from "../middleware/queryArrayParser.middleware.js"; -import trimAndNullifyMiddleware from "../middleware/trimAndNullify.middleware.js"; - -import { - AdminFilteredEntries, - PublicEntry, - QueriedEntries, -} from "../models/response/entries.response.js"; -import authenticate from "../middleware/auth.middleware.js"; -import { - EditEntry, - Entry, - FilterFull, - FilterQuery, -} from "../models/request/entries.request.js"; -import validate, { - validateFilterQuery, - validateId, - validateOptional, -} from "../middleware/validation.middleware.js"; -import { StatusCode } from "../types/httpStatusCodes.js"; -import { ObjectId } from "../models/request/objectId.request.js"; -import { filterEntry } from "../util/filter.util.js"; -import { ECMSTicketType } from "../types/cms.js"; -import csrfMiddleware from "../middleware/csrf.middleware.js"; - -const newEntryLimiter = rateLimit({ - windowMs: config.rateLimit.newEntries.timeframeMinutes * 60 * 1000, - max: config.rateLimit.newEntries.maxRequests, -}); - -const entryDataSpeedLimiter = slowDown({ - windowMs: config.slowDown.entries.timeframeSeconds * 1000, - delayAfter: config.slowDown.entries.maxRequests, - delayMs: config.slowDown.entries.delayMs, - maxDelayMs: config.slowDown.entries.maxDelayMs, -}); - -@Controller("entries") -@ClassMiddleware(csrfMiddleware) -export default class EntriesController { - @Get("/") - @Middleware(entryDataSpeedLimiter) - @Middleware(queryNumberParser("lat", "long", "page")) - @Middleware(queryArrayParser("offers", "attributes")) - @Middleware(validateFilterQuery) - async getEntries( - req: IRequest<{}, FilterQuery>, - res: IResponse - ) { - res.send(await EntryService.filter(req.query)); - } - - @Post("/") - @Middleware(newEntryLimiter) - @Middleware(trimAndNullifyMiddleware) - @Middleware(validate(Entry, { validationGroupFromEntryType: true })) - async submitNewEntry(req: IRequest, res: IResponse) { - const id = await EntryService.addEntry(req.body); - - res.status(StatusCode.Created).end(); - - // DiscordService.sendNewEntryNotification(req.body.name, req.body.type); - CMS.createTicket( - req.body.name, - id.toString(), - ECMSTicketType.NEW, - null - ); - } - - @Get("unapproved") - @Middleware(authenticate()) - @Middleware(queryNumberParser("page")) - async authorizedGetUnapproved( - req: IRequest<{}, { page: number }>, - res: IResponse - ) { - res.send( - await EntryService.getUnapproved( - req.query.page ? req.query.page : 0 - ) - ); - } - - @Get("backup") - @Middleware(authenticate({ admin: true })) - async adminGetBackup(req: IRequest, res: IResponse) { - let exported = await Database.exportEntries(); - - if (!exported) { - res.error!("backup_failed"); - return; - } - - res.download(exported); - } - - @Post("full") - @Middleware(authenticate()) - @Middleware(validate(FilterFull)) - async adminGetFullFilteredEntries( - req: IRequest, - res: IResponse - ) { - let entries = await EntryService.filterWithFilterLang(req.body); - - if (entries === null) { - res.error!("compilation_failed"); - return; - } - - res.send(entries); - } - - @Get(":id") - @Middleware(validateId) - async getSingleEntry( - req: IRequest<{}, {}, ObjectId>, - res: IResponse - ) { - let entry = await Database.getEntryById(req.params.id); - - if (!entry) { - res.error!("not_found"); - return; - } - - filterEntry(entry); - - res.send(entry); - } - - @Patch(":id/approve") - @Middleware(authenticate()) - @Middleware(validateId) - async approveEntry(req: IRequest<{}, {}, ObjectId>, res: IResponse) { - let entry = await Database.getEntryById(req.params.id); - - if (!entry) { - res.error!("not_found"); - return; - } - - // Update entry with new approved state - let updated = await EntryService.approve(entry, req.user!.id); - - if (!updated) { - res.error!("not_updated"); - return; - } - - res.status(StatusCode.OK).end(); - } - - @Patch(":id/blocklist") - @Middleware(authenticate()) - @Middleware(validateId) - async blockEntry(req: IRequest<{}, {}, ObjectId>, res: IResponse) { - let entry = await Database.getEntryById(req.params.id); - - if (!entry) { - res.error!("not_found"); - return; - } - - // Update entry with new status - let updated = await EntryService.update(entry, { - blocked: true, - possibleDuplicate: undefined, - }); - - if (!updated) { - res.error!("not_updated"); - return; - } - - res.status(StatusCode.OK).end(); - } - - @Patch(":id/edit") - @Middleware(authenticate({ admin: true })) - @Middleware(trimAndNullifyMiddleware) - @Middleware(validateId) - @Middleware(validateOptional(EditEntry)) - async adminUpdateEntry( - req: IRequest, - res: IResponse - ) { - let entry = await Database.getEntryById(req.params.id); - - if (!entry) { - res.error!("not_found"); - return; - } - - let updated = await EntryService.update(entry, req.body); - - if (!updated) { - res.error!("not_updated"); - } - - res.status(StatusCode.OK).end(); - } - - @Patch(":id/updateGeo") - @Middleware(authenticate({ admin: true })) - @Middleware(validateId) - async adminUpdateGeo(req: IRequest<{}, {}, ObjectId>, res: IResponse) { - let entry = await Database.getEntryById(req.params.id); - - if (!entry) { - res.error!("not_found"); - return; - } - - EntryService.updateGeoLocation(entry); - - res.status(StatusCode.Accepted).end(); - } - - @Delete(":id") - @Middleware(authenticate()) - @Middleware(validateId) - async deleteEntry(req: IRequest<{}, {}, ObjectId>, res: IResponse) { - let entry = await Database.getEntryById(req.params.id); - - if (!entry) { - res.error!("not_found"); - return; - } - - let deleted = await Database.deleteEntry(req.params.id); - - if (!deleted) { - res.error!("not_deleted"); - return; - } - - res.status(StatusCode.OK).end(); - } -} diff --git a/src/controllers/geodata.controller.ts b/src/controllers/geodata.controller.ts deleted file mode 100644 index 604448a..0000000 --- a/src/controllers/geodata.controller.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { - ClassMiddleware, - Controller, - Get, - Middleware, -} from "@overnightjs/core"; -import { IRequest, IResponse } from "express"; - -import validate, { - EValidationDataSource, -} from "../middleware/validation.middleware.js"; -import { SearchGeoLocation } from "../models/request/geo.request.js"; -import { StatusCode } from "../types/httpStatusCodes.js"; - -import * as Database from "../services/database.service.js"; -import { GeoPlace } from "../models/database/geodata.model.js"; -import csrfMiddleware from "../middleware/csrf.middleware.js"; - -@Controller("geodata") -@ClassMiddleware(csrfMiddleware) -export default class GeodataController { - @Get("/") - @Middleware( - validate(SearchGeoLocation, { source: EValidationDataSource.Query }) - ) - async searchGeoLocation( - req: IRequest<{}, SearchGeoLocation>, - res: IResponse - ) { - let data = await Database.findGeoLocation(req.query.search); - - if (!data[0]) return res.status(StatusCode.NotFound).end(); - - res.send(data); - } -} diff --git a/src/controllers/report.controller.ts b/src/controllers/report.controller.ts deleted file mode 100644 index 639a0c8..0000000 --- a/src/controllers/report.controller.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { - ClassMiddleware, - Controller, - Middleware, - Post, -} from "@overnightjs/core"; -import rateLimit from "express-rate-limit"; -import { IRequest, IResponse } from "express"; - -import { StatusCode } from "../types/httpStatusCodes.js"; -import { Report } from "../models/request/report.request.js"; -import validate from "../middleware/validation.middleware.js"; - -import { config } from "../services/config.service.js"; -import * as Database from "../services/database.service.js"; -import * as CMS from "../services/cms.service.js"; -import { ECMSTicketType } from "../types/cms.js"; -import csrfMiddleware from "../middleware/csrf.middleware.js"; - -const newReportLimiter = rateLimit({ - windowMs: config.rateLimit.report.timeframeMinutes * 60 * 1000, - max: config.rateLimit.report.maxRequests, -}); - -@Controller("report") -@ClassMiddleware(csrfMiddleware) -export default class ReportController { - @Post("/") - @Middleware(newReportLimiter) - @Middleware(validate(Report)) - public async report(req: IRequest, res: IResponse) { - let entry = await Database.getEntryById(req.body.id); - - if (!entry) - return res - .status(StatusCode.NotFound) - .send({ error: "entry_not_found" }) - .end(); - - let sent = await CMS.createTicket( - entry.name, - req.body.id, - req.body.type as ECMSTicketType, - req.body.message - ); - - if (!sent) return res.status(StatusCode.InternalServerError).end(); - - res.status(200).end(); - } -} diff --git a/src/graphql/management-users.gql b/src/graphql/management-users.gql deleted file mode 100644 index 624abdd..0000000 --- a/src/graphql/management-users.gql +++ /dev/null @@ -1,11 +0,0 @@ -query { - management_users { - user { - id - first_name - last_name - } - status - admin - } -} \ No newline at end of file diff --git a/src/graphql/user-me.gql b/src/graphql/user-me.gql deleted file mode 100644 index 6eb3a19..0000000 --- a/src/graphql/user-me.gql +++ /dev/null @@ -1,7 +0,0 @@ -query { - users_me { - id - first_name - last_name - } -} \ No newline at end of file diff --git a/src/graphql/users.gql b/src/graphql/users.gql deleted file mode 100644 index dbadee2..0000000 --- a/src/graphql/users.gql +++ /dev/null @@ -1,7 +0,0 @@ -query { - users { - id - first_name - last_name - } -} \ No newline at end of file diff --git a/src/main.ts b/src/main.ts deleted file mode 100644 index 64c6f98..0000000 --- a/src/main.ts +++ /dev/null @@ -1,30 +0,0 @@ -import TransDBBackendServer from "./server.js" -import cleanup from "node-cleanup" -import * as Config from "./services/config.service.js" -import * as Database from "./services/database.service.js" -import * as UserService from "./services/users.service.js" -import * as Shell from "./util/shell.util.js" - -// Config -Config.initConfig(); - -if(!process.argv.includes("--dev")) { - await Shell.testForCommands(); -} - -Database.purgeBackups(); - -Database.connect(); - -UserService.loadUserNameCache(); - -// Start server -const server = new TransDBBackendServer(); -server.start(Config.config.web.port); - - -cleanup(() => { - Database.client.close() - server.stop(); - console.log("Application shutdown successful"); -}); diff --git a/src/middleware/auth.middleware.ts b/src/middleware/auth.middleware.ts deleted file mode 100644 index a4a8ae8..0000000 --- a/src/middleware/auth.middleware.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { IRequest, IResponse, NextFunction } from "express" -import jwt from "jsonwebtoken" - -import { AuthOptions, TokenData } from "../types/auth" -export { AuthOptions, TokenData } - -import { config } from "../services/config.service.js" - -// Middleware to authenticate and authorize users with jsonwebtoken -function _authMiddleware(req: IRequest, res: IResponse, next: NextFunction, options: AuthOptions) { - - if (!req.headers.authorization) { - res.error!("invalid_authorization_header") - - return; - } - - // Match the auth header with RegExp - let bearer = /Bearer (.+)/.exec(req.headers.authorization); - - // Check match of the header - if (!bearer) { - res.error!("invalid_authorization_header"); - - return; - } - - // Get jwt from match< - let token = bearer[1]; - let decodedToken: TokenData; - - // Try to verify the jwt - try { - decodedToken = jwt.verify(token, config.jwt.secret) as TokenData; - } catch(err) { - res.error!("unauthorized"); - return; - } - - // Check permissions - if (options.admin && !decodedToken.admin) { - res.error!("no_admin"); - - return; - } - - // Set token payload to req.user - req.user = decodedToken; - - next(); -} - -/** - * Express.js Middleware builder. - * The returned Middleware authenticates and authorizes users with jsonwebtoken, - * by mutating the Request to "DecodedRequest". - * @returns Express.js Middleware using given AuthOptions - */ -export default function authenticate(options: AuthOptions = {}) { - return (req: IRequest, res: IResponse, next: NextFunction) => _authMiddleware(req, res, next, options); -} diff --git a/src/middleware/csrf.middleware.ts b/src/middleware/csrf.middleware.ts deleted file mode 100644 index 76fcbf2..0000000 --- a/src/middleware/csrf.middleware.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { IRequest, IResponse, NextFunction } from "express"; -import jwt from "jsonwebtoken"; - -import { CSRFTokenData } from "../types/auth"; -export { CSRFTokenData }; - -import { config } from "../services/config.service.js"; - -export default function csrfMiddleware( - req: IRequest, - res: IResponse, - next: NextFunction -) { - if (!config.csrfProtection.active) { - next(); - return; - } - - let token = req.headers["x-csrf-token"]; - - if (!token) { - res.error!("invalid_csrf_token"); - return; - } - - try { - jwt.verify( - token as string, - config.csrfProtection.secret - ) as CSRFTokenData; - } catch (err) { - res.error!("invalid_csrf_token"); - return; - } - next(); -} diff --git a/src/middleware/errorFunction.middleware.ts b/src/middleware/errorFunction.middleware.ts deleted file mode 100644 index 8ae0942..0000000 --- a/src/middleware/errorFunction.middleware.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { ValidationError } from "class-validator" -import { IRequest, IResponse, NextFunction } from "express" -import { responseErrorCodes, ResponseErrorCode } from "../models/response/error.response.js" - -/** - * Middleware to extend the express response object with an error function - */ -export function errorFunctionMiddleware(req: IRequest, res: IResponse, next: NextFunction) { - res.error = function (error: ResponseErrorCode, problems?: ValidationError[]) { - return res.status(responseErrorCodes[error]).json({ error, problems }).end(); - } - - return next(); -} diff --git a/src/middleware/queryArrayParser.middleware.ts b/src/middleware/queryArrayParser.middleware.ts deleted file mode 100644 index 4a0acda..0000000 --- a/src/middleware/queryArrayParser.middleware.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { NextFunction, Request, Response } from "express"; -import IDictionary from "../types/dictionary"; - -/** - * Express.js Middleware to parse query parameters always as array. - * If the query param is present multiple times, express does this automatically, but if we want an array when a param exists only once, express passes a string. - * To avoid getting a string this middleware will format it into an array. - */ -function _queryArrayParser(req: Request, res: Response, next: NextFunction, fields: string[]) { - if (req.query) { - let query = req.query as IDictionary; - - for (let [ key, value ] of Object.entries(query)) { - - if ( !fields.includes(key) ) continue; - - if ( !Array.isArray(value) && typeof value !== 'object') { - query[ key ] = [value]; - } - - } - - } - - next(); -} - - -/** - * Parses query parameters to always be an array - * @param fields Array of query fields to parse - * @returns Middleware - */ -export default function queryArrayParser(...fields: string[]){ - return (req: Request, res: Response, next: NextFunction) => _queryArrayParser(req, res, next, fields); -} diff --git a/src/middleware/queryNumberParser.middleware.ts b/src/middleware/queryNumberParser.middleware.ts deleted file mode 100644 index b48887c..0000000 --- a/src/middleware/queryNumberParser.middleware.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { NextFunction, Request, Response } from "express"; -import IDictionary from "../types/dictionary"; - -/** - * Express.js Middleware to parse numbers in the querystring of a request to an actual number (float). - */ -function _queryNumberParser(req: Request, res: Response, next: NextFunction, fields: string[]) { - if (req.query) { - let query = req.query as IDictionary; - - for (let [ key, value ] of Object.entries(query)) { - if ( !fields.includes(key) ) continue; - - let parsed = parseFloat(value as string); - - if ( !isNaN(parsed) ) { - query[ key ] = parsed; - } - - } - - } - - next(); -} - - -/** - * Parses the given fields of a querystring to an actual number (float) - * @param fields Array of query fields to parse - * @returns Middleware - */ -export default function queryNumberParser(...fields: string[]) { - return (req: Request, res: Response, next: NextFunction) => _queryNumberParser(req, res, next, fields); -} diff --git a/src/middleware/trimAndNullify.middleware.ts b/src/middleware/trimAndNullify.middleware.ts deleted file mode 100644 index e3b5d05..0000000 --- a/src/middleware/trimAndNullify.middleware.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { IRequest, IResponse, NextFunction } from "express" -import type IDictionary from "../types/dictionary" - -function trimAndNullifiyValues(obj: IDictionary): IDictionary { - for (const key in obj) { - const val = obj[key]; - - if (val === undefined || val === null) continue; - - if (typeof val === "string") { - let value: string | null = val.trim(); - - if (value === "") { - value = null; - } - - obj[key] = value; - } else if (typeof val === "object" && !Array.isArray(val)) { - obj[key] = trimAndNullifiyValues(val); - } - } - - return obj; -} - -/** - * Middleware to trim and nullify all values in the request body. - */ -export default function trimAndNullifyMiddleware(req: IRequest, res: IResponse, next: NextFunction) { - if (req.body) { - req.body = trimAndNullifiyValues(req.body); - } - - next(); -} diff --git a/src/middleware/validation.middleware.ts b/src/middleware/validation.middleware.ts deleted file mode 100644 index e163e85..0000000 --- a/src/middleware/validation.middleware.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { IRequest, IResponse, NextFunction } from "express" -import * as ClassValidator from "class-validator" -import { plainToInstance } from "class-transformer" - -import {Entry, FilterQuery} from "../models/request/entries.request.js" -import { RequestBody, Query } from "../models/request.js" -import { ObjectId } from "../models/request/objectId.request.js" - -export enum EValidationDataSource { - Body, - Query -} - -export interface ValidationMiddlewareOptions { - validationGroupFromEntryType ?: boolean; - skipMissingProperties ?: boolean; - source ?: EValidationDataSource; - groups ?: string[]; -} - -type Schema = { new(): RequestBody } | { new(): Query } - -async function validateBody(req: IRequest, res: IResponse, next: NextFunction, schema: Schema, options?: ValidationMiddlewareOptions) { - - let _options = { - validationGroupFromEntryType: options?.validationGroupFromEntryType ?? false, - skipMissingProperties: options?.skipMissingProperties ?? false, - source: options?.source ?? EValidationDataSource.Body - } - - switch (_options.source) { - case EValidationDataSource.Body: { - req.body = plainToInstance(schema, req.body); - break; - } - case EValidationDataSource.Query: { - req.query = plainToInstance(schema, req.query); - break; - } - } - - const validatorOptions: ClassValidator.ValidatorOptions = { - always: true, - whitelist: true, - forbidNonWhitelisted: true, - forbidUnknownValues: true, - skipMissingProperties: _options.skipMissingProperties, - groups: options?.groups ?? [], - validationError: { - target: false, - value: false - } - }; - - if (_options.validationGroupFromEntryType && req.body instanceof Entry) validatorOptions.groups = [req.body.type, ...validatorOptions.groups!]; - - let errors: ClassValidator.ValidationError[] = await ClassValidator.validate( - _options.source == EValidationDataSource.Query ? req.query : req.body, - validatorOptions - ); - - if (errors.length < 1) return next(); - - return res.error!("validation_error", errors); - -} - -/** - * Validate RequestBody or Query - * @param schema Class to use as schema - * @param options - * @returns validateBody Middleware - */ -export default function validate(schema: Schema, options?: ValidationMiddlewareOptions) { - return (req: IRequest, res: IResponse, next: NextFunction) => validateBody(req, res, next, schema, options); -} - -/** - * Validate RequestBody treating all fields as optional - * @param schema Class to use as schema - * @returns validateBody Middleware - */ -export function validateOptional(schema: Schema) { - let options = { - skipMissingProperties: true, - validationGroupFromEntryType: true - } - - return (req: IRequest, res: IResponse, next: NextFunction) => validateBody(req, res, next, schema, options); -} - -/** - * Validate the entries FilterQuery - */ -export async function validateFilterQuery(req: IRequest<{}, FilterQuery>, res: IResponse, next: NextFunction) { - - let options: ValidationMiddlewareOptions = { - source: EValidationDataSource.Query - } - - if (req.query.lat || req.query.long) { - options.groups = ["hasCoords"]; - } else { - options.groups = ["noCoords"]; - } - - await validateBody(req, res, next, FilterQuery, options); -} - -/** - * Middleware to validate MongoDB's ObjectId in url params - */ -export async function validateId(req: IRequest, res: IResponse, next: NextFunction) { - - req.params = plainToInstance(ObjectId, req.params); - - let errors: ClassValidator.ValidationError[] = await ClassValidator.validate(req.params, { - always: true, - whitelist: true - }); - - // Go next if there is no errors - if (errors.length < 1) return next(); - - return res.error!("validation_error", errors); -} diff --git a/src/models/database/collectionMeta.model.ts b/src/models/database/collectionMeta.model.ts deleted file mode 100644 index 41bb475..0000000 --- a/src/models/database/collectionMeta.model.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { ObjectId } from "mongodb" - -export interface CollectionMeta { - _id ?: io extends "in" ? ObjectId : string - about: string -} - -export interface EntriesCollectionMeta extends CollectionMeta { - lastChangeTimestamp: number, - lastExportTimestamp: number -} - -export const enum CollectionMetaUpdateType { - Changed, - Exported -} diff --git a/src/models/database/entry.model.ts b/src/models/database/entry.model.ts deleted file mode 100644 index 6e4f430..0000000 --- a/src/models/database/entry.model.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { ObjectId } from "mongodb" -import { GeoJsonPoint } from "./geodata.model.js" - -/** - * Entry object as stored in database - * - * The Entry<"in"> and Entry<"out"> variants exist because some fields change type - * when mongodb outputs them. - * - * "in" is for Entries which go into the database, or **in**putted Entries. - * - * "out" is for Entries which are returned by the database, or **out**putted Entries. - */ -export interface DatabaseEntry { - _id?: io extends "in" ? ObjectId : string, - approved?: boolean, - blocked?: boolean, - type: string, - name: string, - telephone?: string | null, - website?: string | null, - email?: string | null, - academicTitle?: string | null, - firstName?: string | null, - lastName?: string | null, - address: DatabaseAddress, - location: GeoJsonPoint | null, - meta: DatabaseEntryMeta, - accessible?: "yes" | "no" | "unknown" | null, - - submittedTimestamp: number, - approvedTimestamp?: number, - - /** id of user who approved entry */ - approvedBy?: string, - distance?: io extends "out" ? number : undefined - - possibleDuplicate?: io extends "in" ? ObjectId : string -} - -export interface DatabaseAddress { - city: string, - plz?: string, - street?: string, - house?: string -} - -export interface DatabaseEntryMeta { - attributes?: string[] | null, - specials?: string | null, - subject?: string | null, - offers?: string[] | null, - minAge?: number | null -} diff --git a/src/models/database/geodata.model.ts b/src/models/database/geodata.model.ts deleted file mode 100644 index c0d19f2..0000000 --- a/src/models/database/geodata.model.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { ObjectId } from "mongodb"; - -export interface GeoJsonPoint { - type: "Point", - coordinates: [number, number] -} - -export interface GeoData { - _id: string | ObjectId, - level: number, - name: string, - ascii: string, - plz: string, - location: GeoJsonPoint | null, - referenceLocation: GeoJsonPoint | null -} - -export interface GeoPlace { - name: string, - location: GeoJsonPoint -} diff --git a/src/models/entryMapping.ts b/src/models/entryMapping.ts deleted file mode 100644 index 77b9df5..0000000 --- a/src/models/entryMapping.ts +++ /dev/null @@ -1,17 +0,0 @@ -import IDictionary from "../types/dictionary" - -export const typeMapping: IDictionary = { - group: "Gruppe/Verein", - therapist: "Therapeut*in/Psychiater*in", - surveyor: "Gutachter*in", - endocrinologist: "Endokrinologische Praxis", - surgeon: "Operateur*in", - logopedics: "Logopäd*in", - hairremoval: "Haarentfernung" -} - -export const reportTypeMapping = { - edit: "Änderungsvorschlag", - report: "Nicht empfehlenswert", - other: "Sonstiges" -} \ No newline at end of file diff --git a/src/models/request.ts b/src/models/request.ts deleted file mode 100644 index ffada41..0000000 --- a/src/models/request.ts +++ /dev/null @@ -1,8 +0,0 @@ -import * as express from "express" - -/** - * Base class for Request objects - */ -export abstract class RequestBody {} - -export abstract class Query {} diff --git a/src/models/request/entries.request.ts b/src/models/request/entries.request.ts deleted file mode 100644 index ca518e8..0000000 --- a/src/models/request/entries.request.ts +++ /dev/null @@ -1,226 +0,0 @@ -import { - ArrayNotEmpty, - IsBoolean, - IsEmail, - IsEmpty, - IsIn, - IsInt, - IsNumber, - IsObject, - IsOptional, - IsUrl, - Length, - ValidateNested, -} from "class-validator"; - -import * as FilterLang from "@transdb-de/filter-lang"; -import { - ArrayExclusively, - IsEmptyArray, - KeyedArrayExclusively, -} from "../../util/customValidators.util.js"; -import { allExcept, mergeArrays } from "../../util/array.util.js"; -import { RequestBody, Query } from "../request.js"; -import { Type } from "class-transformer"; - -const types = [ - "group", - "therapist", - "surveyor", - "endocrinologist", - "surgeon", - "logopedics", - "hairremoval", - "urologist", - "gynecologist", - "GP", - "pharmacy", - "cryo", -] as const; - -const academicTitles = ["dr", "prof", "prof_dr"] as const; - -const accessibility = ["yes", "no", "unknown"] as const; - -const attributes = { - group: ["trans", "regularMeetings", "consulting", "activities", "remote"], - surveyor: ["enby", "remote"], - surgeon: ["selfPayedOnly", "remote"], - endocrinologist: ["treatsNB", "remote"], - hairremoval: ["insurancePay", "transfriendly", "hasDoctor"], - therapist: ["selfPayedOnly", "youthOnly", "treatsNB", "remote"], - urologist: ["treatsNB", "transFem", "transMasc", "remote"], - gynecologist: ["treatsNB", "transFem", "transMasc", "remote"], - GP: ["treatsNB", "remote"], - logopedics: ["remote"], - pharmacy: ["shipping", "singleUseVials", "reuseVial", "prefilled"], - cryo: ["insurancePay"], -} as const; - -const offers = { - surgeon: [ - "mastectomy", - "vaginPI", - "vaginCombined", - "ffs", - "penoid", - "breast", - "hyst", - "orch", - "clitPI", - "bodyfem", - "glottoplasty", - "fms", - ], - hairremoval: ["laser", "ipl", "electro", "electroAE"], - therapist: ["indication", "therapy"], - urologist: ["hrt", "medication"], - gynecologist: ["hrt", "medication"], - GP: ["hrt", "medication"], - pharmacy: ["eInjection", "cpa"], - cryo: ["freezesSperm", "freezesEggs"], -} as const; - -export class Entry extends RequestBody { - @IsIn(types) - type!: (typeof types)[number]; - - @Length(1, 160) - name!: string; - - @IsOptional() - @IsIn(academicTitles) - academicTitle?: (typeof academicTitles)[number]; - - @IsOptional() - @Length(2, 30) - firstName?: string; - - @IsOptional() - @Length(2, 30) - lastName?: string; - - @IsOptional() - @Length(5, 320) - @IsEmail() - email?: string; - - @IsOptional() - @Length(5, 500) - @IsUrl({ require_protocol: true }) - website?: string; - - @IsOptional() - @Length(5, 30) - telephone?: string; - - @IsOptional() - @IsIn(accessibility) - accessible?: (typeof accessibility)[number]; - - @ValidateNested() - @Type(() => Address) - address!: Address; - - @ValidateNested() - @Type(() => Meta) - meta!: Meta; -} - -export class Address { - @Length(2, 50) - city!: string; - - @IsOptional() - @Length(0, 10) - plz?: string; - - @IsOptional() - @Length(0, 50) - street?: string; - - @IsOptional() - @Length(0, 10) - house?: string; -} - -export class Meta { - @IsOptional() - @KeyedArrayExclusively(attributes) - attributes?: string[]; - - @IsOptional({ groups: allExcept(types, ...Object.keys(offers)) }) - @IsEmptyArray({ groups: allExcept(types, ...Object.keys(offers)) }) - @ArrayNotEmpty({ groups: Object.keys(offers) }) - @KeyedArrayExclusively(offers) - offers?: string[]; - - @IsOptional() - @Length(0, 280) - specials?: string; - - @IsEmpty({ groups: allExcept(types, "group") }) - @IsOptional({ groups: ["group"] }) - @IsNumber({}, { groups: ["group"] }) - minAge?: number; - - @IsEmpty({ groups: allExcept(types, "therapist") }) - @IsIn(["therapist", "psychologist", "naturopath", "other"], { - groups: ["therapist"], - }) - subject?: string; -} - -export class EditEntry extends Entry { - @IsBoolean() - approved!: boolean; - - @IsBoolean() - blocked!: boolean; -} - -export class FilterQuery extends Query { - @IsEmpty({ groups: ["noCoords"] }) - @IsNumber({}, { groups: ["hasCoords"] }) - lat?: number; - - @IsEmpty({ groups: ["noCoords"] }) - @IsNumber({}, { groups: ["hasCoords"] }) - long?: number; - - @IsOptional() - @IsIn(types) - type?: (typeof types)[number]; - - @IsOptional() - @ArrayExclusively(mergeArrays(offers)) - offers?: string[]; - - @IsOptional() - @ArrayExclusively(mergeArrays(attributes)) - attributes?: string[]; - - @IsOptional() - @Length(2, 120) - location?: string; - - @IsOptional() - @Length(0, 120) - text?: string; - - @IsOptional() - @IsNumber() - page?: number; - - @IsOptional() - @IsIn(accessibility) - accessible?: (typeof accessibility)[number]; -} - -export class FilterFull { - @IsObject() - filter!: FilterLang.IntermediateFormat.AbstractFilters; - - @IsInt() - page!: number; -} diff --git a/src/models/request/geo.request.ts b/src/models/request/geo.request.ts deleted file mode 100644 index ba969d4..0000000 --- a/src/models/request/geo.request.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Length } from "class-validator" -import { Query } from "../request.js" - -export class SearchGeoLocation extends Query { - @Length(1, 200) - search !: string; -} diff --git a/src/models/request/objectId.request.ts b/src/models/request/objectId.request.ts deleted file mode 100644 index 4bedcc2..0000000 --- a/src/models/request/objectId.request.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Matches } from "class-validator" -import { RequestBody } from "../request.js" - -export const idRegex = /^[0-9a-f]{24}$/i; - -export class ObjectId { - @Matches(idRegex) - id !: string -} diff --git a/src/models/request/report.request.ts b/src/models/request/report.request.ts deleted file mode 100644 index 65f4a1a..0000000 --- a/src/models/request/report.request.ts +++ /dev/null @@ -1,18 +0,0 @@ -import {IsIn, Length, Matches} from "class-validator" -import { RequestBody } from "../request.js" -import { idRegex } from "./objectId.request.js" - -const types = [ - "edit", "report", "other" -] as const; - -export class Report extends RequestBody { - @Matches(idRegex) - id !: string; - - @IsIn(types) - type !: typeof types[number]; - - @Length(10, 1200) - message !: string; -} diff --git a/src/models/request/users.request.ts b/src/models/request/users.request.ts deleted file mode 100644 index ab208e8..0000000 --- a/src/models/request/users.request.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { RequestBody } from "../request.js" -import { Length, IsEmail, IsBoolean } from "class-validator" - - -const usernameLength: [number, number] = [4, 256]; -const passwordLength: [number, number] = [8, 2024]; - - -export class CreateUser extends RequestBody { - @Length(...usernameLength) - username !: string; - - @IsEmail() - email !: string; - - @IsBoolean() - admin !: boolean; -} - - -export class LoginBody extends RequestBody { - @Length(...usernameLength) - username !: string; - - @Length(...passwordLength) - password !: string; -} - - -export class UpdatePassword extends RequestBody { - @Length(...passwordLength) - old !: string; - - @Length(...passwordLength) - new !: string; -} - - -export class UpdateEmail extends RequestBody { - @IsEmail() - email !: string; -} - - -export class UpdateUsername extends RequestBody { - @Length(...usernameLength) - username !: string; -} diff --git a/src/models/response.ts b/src/models/response.ts deleted file mode 100644 index 3f9805e..0000000 --- a/src/models/response.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** - * Base class for Response objects - */ -export default abstract class ResponseBody {} diff --git a/src/models/response/entries.response.ts b/src/models/response/entries.response.ts deleted file mode 100644 index b513d03..0000000 --- a/src/models/response/entries.response.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { DatabaseEntry } from "../database/entry.model.js" -import ResponseBody from "../response.js" - - -export interface PublicEntry extends Omit< DatabaseEntry<"out">, "approvedBy" | "approvedTimestamp" | "submittedTimestamp" | "location" | "blocked"> { - approvedBy?: never, - approvedTimestamp?: never, - submittedTimestamp?: never, - blocked?: never, - location?: never -} - - -export interface QueriedEntries extends ResponseBody { - entries: PublicEntry[] | null, - locationName?: string, - more: boolean -} - -export interface AdminFilteredEntries extends ResponseBody { - entries: DatabaseEntry<"out">[] | null, - more: boolean -} diff --git a/src/models/response/error.response.ts b/src/models/response/error.response.ts deleted file mode 100644 index 073003b..0000000 --- a/src/models/response/error.response.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { StatusCode } from "../../types/httpStatusCodes"; - -export const responseErrorCodes = { - "invalid_authorization_header": StatusCode.Unauthorized, - "invalid_csrf_token": StatusCode.Forbidden, - "unauthorized": StatusCode.Unauthorized, - "no_admin": StatusCode.Forbidden, - "user_exists": StatusCode.Conflict, - "wrong_credentials": StatusCode.Unauthorized, - "validation_error": StatusCode.UnprocessableEntity, - "invalid_verification": StatusCode.BadRequest, - "not_found": StatusCode.NotFound, - "reset_failed": StatusCode.InternalServerError, - "backup_failed": StatusCode.InternalServerError, - "compilation_failed": StatusCode.UnprocessableEntity, - "not_updated": StatusCode.InternalServerError, - "not_deleted": StatusCode.InternalServerError -} as const; - -export type ResponseErrorCode = keyof typeof responseErrorCodes; diff --git a/src/models/response/users.response.ts b/src/models/response/users.response.ts deleted file mode 100644 index 9cbc343..0000000 --- a/src/models/response/users.response.ts +++ /dev/null @@ -1,7 +0,0 @@ -import ResponseBody from "../../models/response.js" - -export interface PublicUser extends ResponseBody { - id: string; - username: string; - admin: boolean; -} diff --git a/src/server.ts b/src/server.ts deleted file mode 100644 index 48b7ece..0000000 --- a/src/server.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { Server } from "@overnightjs/core"; -import express from "express"; -import { Server as HttpServer } from "http"; -import helmet from "helmet"; -import { config } from "./services/config.service.js"; -import cors from "cors"; - -import { errorFunctionMiddleware } from "./middleware/errorFunction.middleware.js"; -import csrfMiddleware from "./middleware/csrf.middleware.js"; - -export default class TransDBBackendServer extends Server { - private server!: HttpServer; - - constructor() { - super(process.env.NODE_ENV === "development"); - - this.app.set("trust proxy", config.web.trustProxy); - this.app.use(helmet()); - this.app.use(express.json()); - this.app.use(cors({ origin: config.web.CORSOrigins })); - this.app.use(errorFunctionMiddleware); - - this.setupControllers(); - } - - private async setupControllers(): Promise { - // Dynamic imports are required here to load these modules after the config has been loaded. - const DefaultController = ( - await import("./controllers/default.controller.js") - ).default; - const EntriesController = ( - await import("./controllers/entries.controller.js") - ).default; - const GeodataController = ( - await import("./controllers/geodata.controller.js") - ).default; - const ReportController = ( - await import("./controllers/report.controller.js") - ).default; - const AccessController = ( - await import("./controllers/access.controller.js") - ).default; - - super.addControllers([ - new DefaultController(), - new EntriesController(), - new GeodataController(), - new ReportController(), - new AccessController(), - ]); - } - - public start(port: number): void { - this.server = this.app.listen(port, () => { - console.log("[express] Server running on " + port); - }); - } - - public stop(): void { - this.server.close(); - console.log("[express] Server stopped"); - } -} diff --git a/src/services/cms.service.ts b/src/services/cms.service.ts deleted file mode 100644 index cc23ae8..0000000 --- a/src/services/cms.service.ts +++ /dev/null @@ -1,63 +0,0 @@ -import axios from "axios"; -import { CMSManagementUser, CMSManagementUsersGQLResponse, CMSUser, CMSUserMeGQLResponse, type CMSUsersGQLResponse, ECMSTicketType } from "../types/cms.js"; -import { config } from "./config.service.js"; -import { loadGQLFiles } from "../util/graphql.util.js"; - -type GQLQueryNames = "users" | "user-me" | "management-users"; - -const gqlQueries = loadGQLFiles("./src/graphql/"); - -export function cmsRequestFactory(path: string, accessToken?: string) { - const url = new URL(path, config.cms.url).href; - - const token = accessToken ? accessToken : config.cms.access_token; - - const options = { headers: { Authorization: "Bearer " + token } }; - - return { - url, - options - } -} - -export async function createTicket(title: string, entryId: string | null, type: ECMSTicketType, description: string | null) { - const newTicket = { - title, - description, - type, - entry_id: entryId - }; - - const { url, options } = cmsRequestFactory("/items/" + config.cms.ticket_collection); - - try { - await axios.post(url, newTicket, options); - return true; - } catch(e) { - return false; - } -} - -export async function fetchUsers(): Promise { - const query = gqlQueries.get("users"); - const { url, options } = cmsRequestFactory("/graphql/system"); - - const res = await axios.post(url, query, options); - return res.data.data.users; -} - -export async function getOwnUser(accessToken: string): Promise { - const query = gqlQueries.get("user-me"); - const { url, options } = cmsRequestFactory("/graphql/system", accessToken); - - const res = await axios.post(url, query, options); - return res.data.data.users_me; -} - -export async function getManagementUsers(): Promise { - const query = gqlQueries.get("management-users"); - const { url, options } = cmsRequestFactory("/graphql"); - - const res = await axios.post(url, query, options); - return res.data.data.management_users; -} \ No newline at end of file diff --git a/src/services/config.service.ts b/src/services/config.service.ts deleted file mode 100644 index 393d733..0000000 --- a/src/services/config.service.ts +++ /dev/null @@ -1,158 +0,0 @@ -import * as fs from "fs"; -import { customAlphabet } from "nanoid"; -import IDictionary from "../types/dictionary"; - -const nanoid = customAlphabet('0123456789abcdefghijklmnopqurstuvxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_.', 64); - -const defaultConfig = { - info: { - name: "TransDB", - github: "", - version: "0.1.0" - }, - mongodb: { - host: "localhost", - username: "transdb", - password: "", - database: "transdb", - itemsPerPage: 10, - backupFolder: "./files/backups/" - }, - entryDuplicateSearchThreshold: 3, - web: { - port: 1300, - enableCORS: true, - trustProxy: true, - CORSOrigins: ["http://localhost:8080"] - }, - osm: { - apiUrl: "https://nominatim.openstreetmap.org/search", - userAgent: "transdb.de/2.0.0 (axios)" - }, - jwt: { - secret: "", - expiresIn: "1h" - }, - csrfProtection: { - active: false, - secret: "" - }, - rateLimit: { - newEntries: { - timeframeMinutes: 5, - maxRequests: 3 - }, - report: { - timeframeMinutes: 5, - maxRequests: 3 - }, - login: { - timeframeMinutes: 5, - maxRequests: 5 - } - }, - slowDown: { - entries: { - timeframeSeconds: 20, - maxRequests: 5, - delayMs: 200, - maxDelayMs: 1200, - } - }, - cms: { - url: "", - access_token: "", - ticket_collection: "", - access_colelction: "" - }, - legacyUsers: { - - } -}; - -const configPath = "./config.json"; - -export let config = defaultConfig; - -/** - * Loads the config from disk, or creates a template config file if none was found - */ -export function initConfig() { - - // create config with default schema if not exists - if (!fs.existsSync(configPath) || fs.readFileSync(configPath).toString() === "") { - - config.jwt.secret = nanoid(); - config.csrfProtection.secret = nanoid(); - - fs.writeFileSync(configPath, JSON.stringify(config, null, '\t')); - - console.warn("--- [ Config ] ---\n" + - "No config file found. A new one has been created. Please fill in data and restart the application!"); - - process.exit(); - - } - - // get the config - let cfgFile = fs.readFileSync(configPath).toString(); - - config = JSON.parse(cfgFile); - - // check if new fields were added to the default config, and not yet updated in the live config - let sameFields = checkFields(config, defaultConfig); - - if (!sameFields) { - fs.writeFileSync(configPath, JSON.stringify(config, null, 4)); - console.warn("--- [ Config ] ---\n" + - "Config fields were updated in the release, which were not yet updated in the config file!\n"+ - "Fields were automatically added to config. Please check these new settings, and change them where required."); - } - - console.log("[Config] Loaded"); -} - -/** - * Checks if an object has all fields from compare. - * If a field is missing, copy the contents of compare, and return false. - * @param object the object to check - * @param compare the object to comapre to - */ -function checkFields(object: IDictionary, compare: IDictionary): boolean { - - let matches = true; - - for (let [key, val] of Object.entries( compare )) { - - // copy value from compare if key does not exists on object - if ( !(key in object) ) { - - object[ key ] = compare[ key ]; - matches = false; - - } else { - // recursivly check nested objects - if (typeof val === 'object' && !Array.isArray(val) && val !== null) { - matches = checkFields(object[key] as IDictionary, val) && matches; - } - - } - - } - - return matches; -} - -export function getMongoUrl() { - // additional assertion to avoid config file set-up mistakes - for (let [key, val] of Object.entries( config.mongodb )) { - if (val === "") { - console.warn("--- [ Config ] ---\n" + - `Please fill in "${key}" under "mongodb" in the config file. Settings in "mongodb" must not be empty!`); - - process.exit(); - } - } - - return `mongodb://${config.mongodb.username}:${config.mongodb.password}@${config.mongodb.host}/${config.mongodb.database}?authSource=${config.mongodb.database}`; -} diff --git a/src/services/database.service.ts b/src/services/database.service.ts deleted file mode 100644 index a73bada..0000000 --- a/src/services/database.service.ts +++ /dev/null @@ -1,427 +0,0 @@ -import MongoDB from "mongodb"; -import * as fs from "fs"; -import path from "path"; - -import * as Config from "./config.service.js"; - -import * as Shell from "../util/shell.util.js"; -import { convertToAscii } from "../util/asciiConverter.util.js"; - -import { GeoJsonPoint, GeoPlace } from "../models/database/geodata.model.js"; - -import { DatabaseEntry } from "../models/database/entry.model.js"; -import { GeoData } from "../models/database/geodata.model.js"; -import { - CollectionMeta, - EntriesCollectionMeta, - CollectionMetaUpdateType, -} from "../models/database/collectionMeta.model.js"; -import { PublicEntry } from "../models/response/entries.response.js"; -import { filterEntries } from "../util/filter.util.js"; - -// ------ Globals ------ - -export let client: MongoDB.MongoClient; - -let db: MongoDB.Db; - -/** - * callbak event functions. - * register callback functions here - */ -export const events = { - connected: () => {}, -}; - -// ------ Functions ------ - -export function connect() { - client = new MongoDB.MongoClient(Config.getMongoUrl(), { tls: false }); - - client.connect((err) => { - if (err) { - console.error(err); - return; - } - - db = client.db(Config.config.mongodb.database); - - console.log("[mongodb] Successful connected"); - - // Resolve all db promises in parallel, then callback sucessfull connection - Promise.all([ - // Add index for genoear queries - db - .collection>("entries") - .createIndex({ location: "2dsphere" }), - db - .collection("geodata") - .createIndex({ location: "2dsphere" }), - - db.collection>("entries").createIndex({ - name: "text", - academicTitle: "text", - firstName: "text", - lastName: "text", - email: "text", - webiste: "text", - telephone: "text", - "address.city": "text", - "address.plz": "text", - "address.street": "text", - "address.house": "text", - "meta.specials": "text", - }), - - db - .collection("geodata") - .createIndex({ name: "text", plz: "text", ascii: "text" }), - db - .collection>("meta") - .createIndex({ about: "text" }), - ]).then(() => { - // Call connected method (specified in main.js) - events.connected(); - }); - }); -} - -export async function ping() { - try { - const r = await db.command({ ping: 1 }); - return r.ok === 1; - } catch { - return false; - } -} - -// ------ Entry management ------ - -/** - * Add an entry to the database - * @param entry A full entry object - * @returns Boolean indicating if the entry was added - */ -export async function addEntry( - entry: DatabaseEntry<"in"> -): Promise { - let res = await db - .collection>("entries") - .insertOne(entry); - - updateEntriesCollectionMeta(); - return res.insertedId; -} - -/** - * Get an entry by id - * @param entryId - * @returns The entry - */ -export async function getEntryById( - entryId: string | number -): Promise | null> { - return (await db.collection>("entries").findOne({ - _id: new MongoDB.ObjectId(entryId), - })) as unknown as DatabaseEntry<"out">; -} - -/** - * Find entries with a custom mongodb query - * @param query - * @param page - * @returns Array with entry objects - */ -export async function findEntries( - query: MongoDB.Filter>, - page: number -): Promise { - let limit = Config.config.mongodb.itemsPerPage; - let skip = limit * page; - - let entries = await db - .collection>("entries") - .aggregate([ - { - $match: query, - }, - { - $sort: { - approvedTimestamp: -1, - submittedTimestamp: -1, - _id: -1, - }, - }, - { $skip: skip }, - { $limit: limit }, - ]) - .toArray(); - - filterEntries(entries); - return entries; -} - -/** - * Find all entries close to given location - * @param locaction - * @param query MongoDB Query - * @param page Defaults to 0 - * @returns Array with entry objects - */ -export async function findEntriesAtLocation( - locaction: GeoJsonPoint, - query: MongoDB.Filter> = {}, - page = 0 -): Promise { - let limit = Config.config.mongodb.itemsPerPage; - let skip = limit * page; - - let entries = await db - .collection>("entries") - .aggregate([ - { - $geoNear: { - near: locaction, - distanceField: "distance", - distanceMultiplier: 0.001, - query: query, - }, - }, - { $sort: { distance: 1 } }, - { $skip: skip }, - { $limit: limit }, - { $set: { distance: { $round: ["$distance", 2] } } }, - ]) - .toArray(); - - filterEntries(entries); - return entries; -} - -/** - * Find entries with all fields via mongodb pipeline - * @param pipeline aggregation pipeline - */ -export async function findEntriesRaw( - pipeline: object[] | undefined -): Promise[]> { - return (await db - .collection>("entries") - .aggregate(pipeline) - .toArray()) as unknown as DatabaseEntry<"out">[]; -} - -/** - * Update an entry by id - * @param entry - * @param updater - * @param additionalUpdater - * @returns boolean indicating the success of the update - */ -export async function updateEntry( - entry: DatabaseEntry<"out">, - updater: Partial>, - additionalUpdater: MongoDB.UpdateFilter> = {} -): Promise { - let res = await db - .collection>("entries") - .updateOne( - { _id: new MongoDB.ObjectId(entry._id) }, - { $set: updater, ...additionalUpdater } - ); - - let updated = Boolean(res.modifiedCount); - - updateEntriesCollectionMeta(); - - return updated; -} - -/** - * Delete an entry by id - * @param id - * @returns boolean indicating the success of the delete - */ -export async function deleteEntry(id: string): Promise { - let res = await db - .collection>("entries") - .deleteOne({ _id: new MongoDB.ObjectId(id) }); - - updateEntriesCollectionMeta(); - return Boolean(res.deletedCount); -} - -export async function exportEntries(): Promise { - // get meta information about entries collection - let meta = (await db.collection>("meta").findOne({ - about: "entries", - })) as unknown as EntriesCollectionMeta<"out">; - let backupPath = path.join( - Config.config.mongodb.backupFolder, - meta.lastExportTimestamp.toString(), - "/entries.json" - ); - let success = true; - - // Change occured after last export - if ( - meta.lastChangeTimestamp > meta.lastExportTimestamp || - !fs.existsSync(backupPath) - ) { - let timestamp = Date.now(); - - backupPath = path.join( - Config.config.mongodb.backupFolder, - timestamp.toString(), - "entries.json" - ); - - success = await Shell.exportEntries(Config.getMongoUrl(), backupPath); - - if (success) { - updateEntriesCollectionMeta( - CollectionMetaUpdateType.Exported, - timestamp - ); - } - } - - // Return path to new export, or false if export failed - return success ? backupPath : false; -} - -export function purgeBackups(): void { - let backupFolder = Config.config.mongodb.backupFolder; - - if (!fs.existsSync(backupFolder)) { - return; - } - - fs.rmSync(backupFolder, { recursive: true, force: true }); -} - -/** - * Updates the metadata for the entry collection. - * Should be called after every write to the collection - * @param type - * @param timestamp - */ -async function updateEntriesCollectionMeta( - type = CollectionMetaUpdateType.Changed, - timestamp: number = Date.now() -): Promise { - // Check if entry meta exists - let meta = await db - .collection>("meta") - .findOne({ about: "entries" }); - - // Create one if it dosn't - if (!meta) { - const entriesMeta: EntriesCollectionMeta<"in"> = { - about: "entries", - lastChangeTimestamp: timestamp, - lastExportTimestamp: - type === CollectionMetaUpdateType.Exported ? timestamp : 0, - }; - - await db - .collection>("meta") - .insertOne(entriesMeta); - } else { - let updater: Partial> = {}; - - if (type === CollectionMetaUpdateType.Changed) { - updater = { - lastChangeTimestamp: timestamp, - }; - } else if (type === CollectionMetaUpdateType.Exported) { - updater = { - lastExportTimestamp: timestamp, - }; - } - - // Update in Database - await db - .collection>("meta") - .updateOne({ about: "entries" }, { $set: updater }); - } -} - -/* - Geodata management - Based on: http://opengeodb.giswiki.org/wiki/OpenGeoDB - */ - -/** - * Find geo data by city name or postal code - * @param search Either postalcode or city name - * @returns Array with objects of cityname, and location - */ -export async function findGeoLocation(search: string): Promise { - search = search.toString(); - - let ascii = convertToAscii(search); - - // Search for input or ascii - // the ascii is aditionally included to cover more edge cases - let searchStr = `${search} ${ascii}`; - - return await db - .collection("geodata") - .aggregate([ - { - $match: { - $text: { $search: searchStr }, - }, - }, - { $sort: { score: { $meta: "textScore" }, level: 1 } }, - { $limit: 6 }, - ]) - .project({ - name: true, - location: { $ifNull: ["$location", "$referenceLocation"] }, - _id: false, - }) - .toArray(); -} - -/** - * Finds the name of a nearest location by GeoJsonPoint - * @param location GeoJsonPoint location - * @returns Single length array with object of cityname, and location - */ -export async function findGeoName(location: GeoJsonPoint): Promise { - return await db - .collection("geodata") - .aggregate([ - { - $geoNear: { - near: location, - distanceField: "distance", - }, - }, - { $limit: 1 }, - ]) - .project({ - name: true, - location: true, - _id: false, - }) - .toArray(); -} - -/** - * Sets or updates the geolocation field of an entry - * @param id Id of the entry - * @param location GeoJsonPoint - */ -export async function setGeolocation( - id: string, - location: GeoJsonPoint -): Promise { - let updateResult = await db - .collection>("entries") - .updateOne({ _id: new MongoDB.ObjectId(id) }, { $set: { location } }); - - return Boolean(updateResult.modifiedCount); -} diff --git a/src/services/entry.service.ts b/src/services/entry.service.ts deleted file mode 100644 index 39ca18b..0000000 --- a/src/services/entry.service.ts +++ /dev/null @@ -1,347 +0,0 @@ -import MongoDB from "mongodb" -import FilterLang from "@transdb-de/filter-lang" -import { config } from "./config.service.js" - -import * as Database from "./database.service.js" -import * as OSM from "./osm.service.js" - -import { stringToRegex } from "../util/regExp.util.js" - -import { GeoJsonPoint } from "../models/database/geodata.model.js" -import { DatabaseEntry, DatabaseAddress } from "../models/database/entry.model.js" -import { Entry, FilterFull, FilterQuery } from "../models/request/entries.request.js" -import { AdminFilteredEntries, PublicEntry, QueriedEntries } from "../models/response/entries.response.js" -import { parsePhoneNumberFromString, PhoneNumber } from "libphonenumber-js" -import removeEmptyUtil from "../util/removeEmpty.util.js" -import { userNameCache } from "./users.service.js" - -/** - * Add an entry - * @param object body of the new entry request - * @returns The new entry object - */ -export async function addEntry(object: Entry) { - let address: DatabaseAddress = object.address; - let internationalPhoneNumber = object.telephone ?? null; - - // Parse and unify phone number - if (object.telephone) { - let t: string = object.telephone.replace(/[^0-9+()]/g, ""); - let phoneNumber: PhoneNumber | undefined = parsePhoneNumberFromString(t, "DE"); - - if (phoneNumber) { - internationalPhoneNumber = phoneNumber.formatInternational(); - } - } - - let possibleDuplicate: MongoDB.ObjectId | undefined = undefined; - - try { - //possibleDuplicate = await findPossibleDuplicate(object, config.entryDuplicateSearchThreshold); - } catch(e) { - console.error("Error while searching for possible duplicate:", e); - } - - // Build the entry object - let entry: DatabaseEntry<"in"> = { - type: object.type, - approved: false, - name: object.name, - academicTitle: object.academicTitle ?? null, - firstName: object.firstName ?? null, - lastName: object.lastName ?? null, - email: object.email, - website: object.website ?? null, - telephone: internationalPhoneNumber, - address: address, - location: null, - meta: { - attributes: object.meta.attributes ?? null, - specials: object.meta.specials ?? null, - minAge: object.meta.minAge ?? null, - subject: object.meta.subject ?? null, - offers: object.meta.offers ?? null, - }, - accessible: object.accessible ?? null, - submittedTimestamp: Date.now() - }; - - if (possibleDuplicate) { - entry.possibleDuplicate = possibleDuplicate; - } - - return await Database.addEntry(entry); -} - -/** - * Filter and get all entry objects (approved and non-blocked) - * @param filters - */ -export async function filter(filters: FilterQuery) : Promise { - let entries: PublicEntry[]; - let page = filters.page ? filters.page : 0; - let geoLoc: GeoJsonPoint | null = null; - let locationName: string = ""; - - let query: MongoDB.Filter> = { - approved: true, - blocked: { $ne: true } - }; - - if (filters.type) { - query.type = filters.type; - } - - if (filters.offers) { - query["meta.offers"] = { $in: filters.offers }; - } - - if (filters.attributes) { - query["meta.attributes"] = { $in: filters.attributes }; - } - - if (filters.text) { - query.$or = [ - { name: stringToRegex(filters.text, "i") }, - { firstName: stringToRegex(filters.text, "i") }, - { lastName: stringToRegex(filters.text, "i") } - ] - } - - if (filters.accessible) { - query.accessible = filters.accessible; - } - - // Searched with location - if (filters.lat && filters.long) { - - let geodata = await Database.findGeoName({ - type: "Point", - coordinates: [ filters.long, filters.lat ] - }); - - locationName = geodata[0].name; - geoLoc = geodata[0].location; - - } - // Not searched with geolocation - else if (filters.location) { - - // Add geolocation by plz or city - - let geodata = await Database.findGeoLocation(filters.location); - - if ( geodata[0] ) { - locationName = geodata[0].name; - geoLoc = geodata[0].location; - } - - } - - if (geoLoc) { - entries = await Database.findEntriesAtLocation(geoLoc, query, page); - } else { - entries = await Database.findEntries(query, page); - } - - let more = !(entries.length < config.mongodb.itemsPerPage); - - - - return { entries, locationName, more }; -} - - -/** - * Retrieve full entries via a filter-lang filter - * @param filters filter-lang generated filters - */ -export async function filterWithFilterLang({filter, page}: FilterFull): Promise { - let pipeline: object[]; - - // fetch coordinates for location search - let loc: GeoJsonPoint | undefined; - if (filter.location && filter.location.locationName) { - - let geo = await Database.findGeoLocation(filter.location.locationName); - - if (geo.length > 0) { - loc = geo[0].location; - } - - } - - // inser objectID - const replacer: FilterLang.Compiler.Replacer = { - _id: (val) => { return new MongoDB.ObjectId(val); } - } - - // attempt compilation - try { - pipeline = FilterLang.Compiler.compileToMongoDB(filter, {}, ["approvedBy"], loc, replacer); - } catch { - return null; - } - - let limit = config.mongodb.itemsPerPage; - let skip = limit * page; - - // append pagination to pipeline - pipeline = [...pipeline, - { $skip: skip }, - { $limit: limit } - ]; - - let entries = await Database.findEntriesRaw(pipeline); - - // re map usernames - for (let entry of entries) { - let id = ""; - - if (entry.approvedBy) { - // legacy user treatment - if (MongoDB.ObjectId.isValid(entry.approvedBy)) { - id = (new MongoDB.ObjectId(entry.approvedBy)).toString(); - } else { - id = entry.approvedBy; - } - } - - const userName = userNameCache.get(id); - - if (userName) { - entry.approvedBy = userName; - } - } - - let more = !(entries.length < config.mongodb.itemsPerPage); - - return { entries, more }; -} - -/** - * Get all unnaproved entries on a page as an array - * @param page (optional) Page number to get unapproved entries for. Defaults to 0 - */ -export async function getUnapproved(page = 0): Promise { - let entries = await Database.findEntries( { - approved: false, - blocked: { $ne: true } - }, page); - - let more = !(entries.length < config.mongodb.itemsPerPage); - - return { entries, more }; -} - -/** - * Approve an entry - * @param entry the entry to approve - * @param userId the id of the user who approved the entry - * @param approve (optional) set false to unapprove entry - * @return whether the entry was updated - */ -export async function approve(entry: DatabaseEntry<"out">, userId: string, approve = true): Promise { - let updater: Partial> = { - approved: approve - }; - - if (approve) { - updater.approvedBy = userId; - updater.approvedTimestamp = Date.now(); - } - - return await update(entry, updater, { $unset: { possibleDuplicate: 1 } }); -} - -/** - * Update a single Entry and it's Geodata - * @param entry Entry to update - * @param updater Patrial Entry acting as updated - * @param additionalUpdater Additional MongoDB update commands - * @returns whether the entry was updated - */ -export async function update(entry: DatabaseEntry<"out">, updater: Partial>, additionalUpdater: MongoDB.UpdateFilter> = {}): Promise { - let updated = await Database.updateEntry(entry, updater, additionalUpdater); - - if (updated) { - updateGeoLocation(entry); - } - - return updated; -} - -/** - * Queues a GeoLocation update. May take long, do not await this function! - * @param entry Entry to fetch new GeoLocation for - */ -export async function updateGeoLocation(entry: DatabaseEntry<"out">) { - try { - let loc = await OSM.getGeoByAddress( entry.address ); - - if (loc === null) throw new Error("location not found"); - - Database.setGeolocation(entry._id!, loc); - } catch(e: any) { - // TODO : Error logging? - console.error("Failed to update GeoLocation: " + e.message); - } -} - -/** - * Find an entry that is similar to the given entry - * @param entry Entry to find similar to - * @param scoreThreshold minimum score to consider a similar entry (default 3: Address matches exactly) - * @returns Id of the similar entry or null if none was found - */ -export async function findPossibleDuplicate(entry: Entry, scoreThreshold: number = 3): Promise { - entry = removeEmptyUtil(entry) as Entry; - - let duplicates = await Database.findEntriesRaw([ - { $match: { type: entry.type } }, // Pre-filter by entry type to save the pipeline work in the next steps - { // Address score is calculated separately because we want to match each address field individually - $addFields: { - scoreAddress: { // Gains one point per every matching address field - $size: { - $setIntersection: [ // Create array of all matching fields - { $objectToArray: "$address" }, - { $objectToArray: entry.address } - ] - } - }, - scoreOther: { // Gains one point per every exactly matching field - $size: { - $setIntersection: [ // Create array of all matching fields - { $objectToArray: "$$ROOT" }, - { $objectToArray: entry } - ] - } - } - } - }, - { - $addFields: { - score: { // Sum of all scores - $add: [ - "$scoreOther", - { - $multiply: ["$scoreAddress", 0.5] // Address fields are worth half as much as other fields, because they are less specific - } - ] - } - } - }, - { - $match: { score: { $gt: scoreThreshold } } // Apply minimum score filter - }, - { $sort: { score: -1 } }, // Order descending by score - { $project: { _id: 1, score: 1 } }, // Only get score - { $limit: 1 } // We want only one result with the highest score - ]); - - if (duplicates.length > 0) { - return new MongoDB.ObjectId(duplicates[0]._id) ?? undefined; - } else { - return; - } -} diff --git a/src/services/osm.service.ts b/src/services/osm.service.ts deleted file mode 100644 index 30f26cb..0000000 --- a/src/services/osm.service.ts +++ /dev/null @@ -1,59 +0,0 @@ -import axios from "axios" -import axiosRateLimit from "axios-rate-limit" - -import type { GeoJsonPoint } from "../models/database/geodata.model.js" -import type { OSMSearch } from "../types/osm" - -import { config } from "./config.service.js" - -import { DatabaseAddress } from "../models/database/entry.model.js" - -import removeEmpty from "../util/removeEmpty.util.js" - -const axiosRl = axiosRateLimit(axios.create(), { maxRequests: 1, perMilliseconds: 1100 }); - -/** - * Get the coordinates in geojson format from address - * @returns legacy coordinates array or null if request failed - */ -export async function getGeoByAddress(address: DatabaseAddress) { - - let data; - - let partialAddress = removeEmpty(address); - - let street = partialAddress.street; - - if (street && partialAddress.house) { - street = partialAddress.house + " " + street; - } - - try { - let response = await axiosRl.get( config.osm.apiUrl, { - params: { - city: partialAddress.city, - postalcode: partialAddress.plz, - street, - format: "geojson" - }, - headers: { - "user-agent": config.osm.userAgent - } - }); - - data = response.data?.features[0]; - - } catch (e) { - return null; - } - - if ( !data || !data.geometry ) { - return null - } - - return data.geometry as GeoJsonPoint; - -} - -// double export to support named and default exports -export default getGeoByAddress; diff --git a/src/services/users.service.ts b/src/services/users.service.ts deleted file mode 100644 index 970d092..0000000 --- a/src/services/users.service.ts +++ /dev/null @@ -1,92 +0,0 @@ -import jwt from "jsonwebtoken"; - -import { config } from "./config.service.js"; - -import { LoginBody } from "../models/request/users.request.js"; -import { cmsRequestFactory, fetchUsers, getManagementUsers, getOwnUser } from "./cms.service.js"; -import axios from "axios"; -import { CMSManagementUser, CMSUser } from "../types/cms.js"; -import { PublicUser } from "../models/response/users.response.js"; - -export const userNameCache = new Map(); - -export async function loadUserNameCache() { - let users: CMSUser[] = []; - - try { - users = await fetchUsers(); - } catch(e) { - console.error(e); - throw new Error("Failed to fetch users from cms"); - } - - for (const user of users) { - const name = user.first_name + (user.last_name ? " " + user.last_name : ""); - - userNameCache.set(user.id, name); - } - - const legacyUsers = Object.entries(config.legacyUsers); - - for (const [id, name] of legacyUsers) { - userNameCache.set(id, name); - } - - console.log("[UserService] user cache loaded"); -} - -/** - * Function to log in a user - * @param loginBody The body of the login request - * @returns user object and token, or false if login failed - */ -export async function login(loginBody: LoginBody): Promise<{ user: PublicUser, token: string } | false> { - const { url } = cmsRequestFactory("/auth/login"); - - let accessToken = null; - - try { - const res = await axios.post<{ data: { access_token: string } }>(url, { email: loginBody.username, password: loginBody.password }); - accessToken = res.data.data.access_token; - } catch(e) { - return false; - } - - if (!accessToken) { - return false; - } - - let managementUser: CMSManagementUser | undefined; - - try { - const u = await getOwnUser(accessToken); - - const m = await getManagementUsers() - - managementUser = m.find(m => m.user.id === u.id); - } catch(e) { - return false; - } - - if (!managementUser) { - return false; - } - - if (managementUser.status !== "active") { - return false; - } - - let token = jwt.sign({ id: managementUser.user.id, admin: managementUser.admin }, config.jwt.secret, { - expiresIn: config.jwt.expiresIn, - }); - - const username = managementUser.user.first_name + (managementUser.user.last_name ? " " + managementUser.user.last_name : ""); - - const user: PublicUser = { - id: managementUser.user.id, - admin: managementUser.admin, - username - } - - return { user, token }; -} diff --git a/src/types/auth.d.ts b/src/types/auth.d.ts deleted file mode 100644 index 98a6463..0000000 --- a/src/types/auth.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -export interface AuthOptions { - admin?: boolean -} - -export interface TokenData { - id: string, - admin: boolean -} - -export interface CSRFTokenData {} diff --git a/src/types/cms.ts b/src/types/cms.ts deleted file mode 100644 index c05a91a..0000000 --- a/src/types/cms.ts +++ /dev/null @@ -1,36 +0,0 @@ -export enum ECMSTicketType { - NEW = "new-entry", - REPORT = "report", - EDIT = "edit", - OTHER = "other" -} - -export interface CMSUser { - id: string; - first_name: string; - last_name: string; -} - -export interface CMSUsersGQLResponse { - data: { - users: CMSUser[]; - } -} - -export interface CMSUserMeGQLResponse { - data: { - users_me: CMSUser; - } -} - -export interface CMSManagementUser { - user: CMSUser; - status: "active" | "inactive"; - admin: boolean; -} - -export interface CMSManagementUsersGQLResponse { - data: { - management_users: CMSManagementUser[]; - } -} \ No newline at end of file diff --git a/src/types/dictionary.d.ts b/src/types/dictionary.d.ts deleted file mode 100644 index 2274db9..0000000 --- a/src/types/dictionary.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -type primitive = string | number | boolean | null - -export default interface IDictionary { - [key: string]: IDictionary | primitive | Array | Array -} diff --git a/src/types/global.d.ts b/src/types/global.d.ts deleted file mode 100644 index 68dc525..0000000 --- a/src/types/global.d.ts +++ /dev/null @@ -1,15 +0,0 @@ -import * as express from "express" -import {ResponseErrorCode} from "../models/response/error.response.js"; -import {ValidationError} from "class-validator"; - -declare module "express" { - interface IRequest extends express.Request { - /** Request includes this field after the auth middleware was called */ - user?: TokenData - clientUID?: string - } - - interface IResponse extends express.Response { - error?: (err: ResponseErrorCode, problems?: ValidationError[]) => void - } -} diff --git a/src/types/httpStatusCodes.ts b/src/types/httpStatusCodes.ts deleted file mode 100644 index 1266165..0000000 --- a/src/types/httpStatusCodes.ts +++ /dev/null @@ -1,24 +0,0 @@ -export const enum StatusCode { - OK = 200, - Created = 201, - Accepted = 202, - NonAuthoritativeInformation = 203, - NoContent = 204, - MultipleChoices = 200, - MovedPermanently = 301, - Found = 302, - SeeOther = 303, - NotModified = 304, - TemporaryRedirect = 307, - BadRequest = 400, - Unauthorized = 401, - Forbidden = 403, - NotFound = 404, - NotAcceptable = 406, - Conflict = 409, - PreconditionFailed = 412, - UnsupportedMediaType = 415, - UnprocessableEntity = 422, - InternalServerError = 500, - NotImplemented = 501 -} diff --git a/src/types/osm.d.ts b/src/types/osm.d.ts deleted file mode 100644 index 6b1cffb..0000000 --- a/src/types/osm.d.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { GeoJsonPoint } from "../models/database/geodata.model" - -export interface OSMSearch { - type: "FeatureCollection", - licence: string, - features: OSMFeature[] -} - -export interface OSMFeature { - type: "Feature", - properties: { - place_id: string, - osm_type: string, - osm_id: string, - display_name: string, - place_rank: string, - category: string, - type: string, - importance: number - }, - bbox: number[], - geometry: GeoJsonPoint -} diff --git a/src/util/array.util.ts b/src/util/array.util.ts deleted file mode 100644 index 1e66e2a..0000000 --- a/src/util/array.util.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Returns a new array containing all values from the given array, except those specified in args - * @param array Array to filter - * @param args Values to filter - * @returns New filtered Array - */ -export function allExcept(array: readonly string[], ...args : string[]) { - var new_arr = array.filter(val => !args.includes(val)); - return new_arr; -} - - -interface ArrayDict { - [key: string]: readonly string[] -} - -/** - * Merges child string arrays of an object into a single string array - * @param dictionary - * @returns New merged array - */ -export function mergeArrays(dictionary: ArrayDict) { - let new_arr: string[] = []; - - for (let key in dictionary) { - new_arr = [...new_arr, ...dictionary[key]] - } - - return new_arr; -} diff --git a/src/util/asciiConverter.util.ts b/src/util/asciiConverter.util.ts deleted file mode 100644 index 3ebca41..0000000 --- a/src/util/asciiConverter.util.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Converts an input string to an "ascii" string, as used in OpenGeoDB data. - * Removes special characters, and makes all letters upper case. - * Example: "Düsseldorf" becomes "DUESSELDORF". - * @param input string to convert - */ - export function convertToAscii(input: string): string { - // special conversion cases - let str = input; - - str = str.replace(/ß|ẞ/g, "ss"); // Match both, as the i flag does not catch capital ß - str = str.replace(/ä/ig, "ae"); - str = str.replace(/ö/ig, "oe"); - str = str.replace(/ü/ig, "ue"); - - str = str.replace(/sankt/ig, "ST."); - - // remove additional accents - let norm = str.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); - - // OpenGeoDB stores all "ascii" names in upper case - return norm.toUpperCase(); -} diff --git a/src/util/customValidators.util.ts b/src/util/customValidators.util.ts deleted file mode 100644 index 531ca69..0000000 --- a/src/util/customValidators.util.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { registerDecorator, ValidationOptions, ValidationArguments } from "class-validator"; - -/** - * Decorator - * - * Validates if an array contains only the given values - * @param exclusively Array of values with the exclusively allowed values for incoming arrays - */ -export function ArrayExclusively(exclusively: readonly string[], validationOptions?: ValidationOptions) { - return function (object: Object, propertyName: string) { - registerDecorator({ - name: "arrayExclusively", - target: object.constructor, - propertyName: propertyName, - constraints: [exclusively], - options: {...validationOptions, message: `Array should exclusively contain these values: ${exclusively.join(", ")}`}, - validator: { - validate(value: any, args: ValidationArguments) { - if (!Array.isArray(value)) return false; - - for (let valueElement of value) { - if (!args.constraints[0].includes(valueElement)) { - return false; - } - } - return true; - } - } - }); - }; -} - -/** - * Decorator - * - * Validates if an object is an empty array - * - * @param validationOptions ClassValidator options - */ -export function IsEmptyArray(validationOptions?: ValidationOptions) { - return function (object: Object, propertyName: string) { - registerDecorator({ - name: "isEmptyArray", - target: object.constructor, - propertyName: propertyName, - constraints: [], - options: {...validationOptions, message: `Should be an empty Array`}, - validator: { - validate(value: any, args: ValidationArguments) { - return Array.isArray(value) && value.length === 0; - } - } - }); - }; -} - -/** - * Decorator - * - * ArrayExclusively Validator, which extracts it's constraints from an object, - * and automatically assings groups based on the objects keys - * @param arrayObj Object containing an array per key with the exclusively allowed values - */ -export function KeyedArrayExclusively(arrayObj: {[key: string]: readonly string[]}) { - return function (object: Object, propertyName: string) { - for (const key in arrayObj) { - const exclusively = arrayObj[key]; - const validationOptions = { groups: [key] }; - - registerDecorator({ - name: "arrayExclusively", - target: object.constructor, - propertyName: propertyName, - constraints: [exclusively], - options: {...validationOptions, message: `Array should exclusively contain these values: ${exclusively.join(", ")}`}, - validator: { - validate(value: any, args: ValidationArguments) { - if (!Array.isArray(value)) return false; - - for (let valueElement of value) { - if (!args.constraints[0].includes(valueElement)) { - return false; - } - } - return true; - } - } - }); - } - }; -} diff --git a/src/util/filter.util.ts b/src/util/filter.util.ts deleted file mode 100644 index 1b5195b..0000000 --- a/src/util/filter.util.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { PublicUser } from "../models/response/users.response"; -import { PublicEntry } from "../models/response/entries.response"; -import { DatabaseEntry } from "../models/database/entry.model"; - -/* -This utility module provides various functions to filter outgoing data. -The filters ensure that no sensitive data is leaked -*/ - -/** - * Filters an entry to remove data which should not be returned by the public api - * @param entry entry to filter - */ -export function filterEntry(entry: Partial< DatabaseEntry<"out"> >): asserts entry is PublicEntry { - delete entry.approvedBy; - delete entry.approvedTimestamp; - delete entry.submittedTimestamp; - delete entry.location; - delete entry.blocked; -} - -/** - * Utility to filter mutliple entries at once - * @param entries array of entries to filter - * @see filterEntry(entry) - */ -export function filterEntries(entries: Partial< DatabaseEntry<"out"> >[]): asserts entries is PublicEntry[] { - for (let entry of entries) { - filterEntry(entry); - } -} diff --git a/src/util/graphql.util.ts b/src/util/graphql.util.ts deleted file mode 100644 index f93dec0..0000000 --- a/src/util/graphql.util.ts +++ /dev/null @@ -1,16 +0,0 @@ -import fs from "fs"; -import path from "path"; - -export function loadGQLFiles(queriesPath: string) { - const files = fs.readdirSync(queriesPath); - const queries = new Map(); - - for (const file of files) { - const p = path.join(queriesPath, file); - const raw = fs.readFileSync(p, "utf8"); - - queries.set(file.split(".")[0] as K, { query: raw }); - } - - return queries; -} \ No newline at end of file diff --git a/src/util/regExp.util.ts b/src/util/regExp.util.ts deleted file mode 100644 index 29cdbc3..0000000 --- a/src/util/regExp.util.ts +++ /dev/null @@ -1,14 +0,0 @@ -/* -This module provide utility methods for dealing with Regular Expressions -*/ - -/** - * Makes a regular expression from a user provided input string. - * Escapes the user input, so no regular expressions can be injected. - * @param string string to convert - * @param flags flags to set on RegExp - */ - export function stringToRegex(string: string, flags?: string): RegExp { - const escapedStr = string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - return new RegExp(escapedStr, flags); -} diff --git a/src/util/removeEmpty.util.ts b/src/util/removeEmpty.util.ts deleted file mode 100644 index 2e6e02e..0000000 --- a/src/util/removeEmpty.util.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type IDictionary from "../types/dictionary" - -/** - * Removes all null properties from an object recursively. - * @param obj object to remove empty properties from - * @returns filtered object without null properties - */ -export default function removeEmptyUtil(obj: T): Partial { - let entries = Object.entries(obj); - - entries = entries.filter(([_, v]) => v != null); - entries = entries.map(([k, v]) => [k, v === Object(v) ? removeEmptyUtil(v) : v]); - - return Object.fromEntries(entries) as Partial; -} diff --git a/src/util/shell.util.ts b/src/util/shell.util.ts deleted file mode 100644 index e125daf..0000000 --- a/src/util/shell.util.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { exec } from "child_process"; -import { promisify } from "util"; - -// Helper module to execute shell commands, from a set of commands -// and to check if all commands are installed - -// convert exec to async -const asyncExec = promisify(exec); - -/** - * All avalible commands - */ -const commands = { - exportEntries: { - getCommand: (args: string[]) => `mongoexport --uri "${args[0]}" --out "${args[1]}" --collection entries --jsonArray --quiet`, - getTest: () => `mongoexport --version` - } -} - -/** - * Export entries collection to a gzipped json file - * @param dbUri uri to database to export entries from - * @param outFile path to save file to - * @returns sucess - */ -export async function exportEntries(dbUri: string, outFile: string): Promise { - let [sucess] = await runCommand("exportEntries", dbUri, outFile); - return sucess; -} - -/** - * Exectues a command - * @returns Tuple: [ did command succeed, command output ] - */ -async function runCommand(command: keyof typeof commands, ...stringArgs: string[]): Promise<[boolean, string]> { - - try { - - let { stdout, stderr } = await asyncExec( - commands[command].getCommand( stringArgs ) - ); - - if (stderr) { - return [ false, stderr ]; - } else { - return [ true, stdout ]; - } - - } catch(e: any) { - - return [ false, e ]; - - } - -} - -/** - * Tests if all commands are installed on this machine - */ -export async function testForCommands(): Promise { - let command = ""; - - try { - - for (let [key, val] of Object.entries(commands) ) { - - command = val.getTest(); - let { stderr } = await asyncExec( command ); - - if (stderr) { - throw(stderr); - } - - } - - } catch(e) { - - console.log(`Command "${command}" failed to run.`); - console.log(`Check if the required command is installed on this machine!`); - console.error(e); - - } -} diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index c361f29..0000000 --- a/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "compilerOptions": { - "target": "es2020", - "module": "esnext", - "moduleResolution": "node", - "sourceMap": true, - "outDir": "./dist", - "rootDir": "./src", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "experimentalDecorators": true, - } -} From 85dafaf77eacbb7868317cc833ada32d0eae46a6 Mon Sep 17 00:00:00 2001 From: Lena Emme <38376566+Feuerhamster@users.noreply.github.com> Date: Sat, 20 Jun 2026 17:06:40 +0200 Subject: [PATCH 02/12] initial upload of new backend --- .dockerignore | 25 ++ .gitignore | 10 + Attributes/ValidateCaptchaAttribute.cs | 49 +++ Controllers/ActivityController.cs | 61 ++++ Controllers/AdminEntriesController.cs | 136 ++++++++ Controllers/AuthController.cs | 60 ++++ Controllers/DefaultController.cs | 24 ++ Controllers/EntriesController.cs | 97 ++++++ Controllers/ReportController.cs | 47 +++ Controllers/UsersController.cs | 52 +++ Dockerfile | 23 ++ Errors/ApiErrors.cs | 71 +++++ Models/Config.cs | 71 +++++ Models/Database/Entry.cs | 126 ++++++++ Models/Database/EntryActivity.cs | 191 +++++++++++ Models/Database/EntryRevocationToken.cs | 30 ++ Models/Database/GeoJsonPoint.cs | 20 ++ Models/Request/AdminEntriesFilterRequest.cs | 23 ++ Models/Request/CommentedRequest.cs | 9 + Models/Request/CreateEntryRequest.cs | 120 +++++++ Models/Request/EditEntryRequest.cs | 65 ++++ Models/Request/EntriesFilterRequest.cs | 83 +++++ Models/Request/LoginRequest.cs | 12 + Models/Request/PatchEntryStatusRequest.cs | 48 +++ Models/Request/ReportRequest.cs | 29 ++ Models/Response/EntryResponse.cs | 82 +++++ Models/Response/LoginResponse.cs | 8 + Models/Response/UserResponse.cs | 9 + Program.cs | 133 ++++++++ README.md | 93 ++++++ Schema/EntryEnums.cs | 115 +++++++ Schema/EntrySchema.cs | 52 +++ Services/AuthService.cs | 49 +++ Services/CaptchaService.cs | 46 +++ Services/CmsService.cs | 223 +++++++++++++ Services/DatabaseService.cs | 267 ++++++++++++++++ Services/EntryActivityService.cs | 124 ++++++++ Services/EntryRevocationService.cs | 49 +++ Services/EntryService.cs | 282 ++++++++++++++++ Services/GeocodingService.cs | 77 +++++ Services/NominatimService.cs | 109 +++++++ Setup/CookieSetup.cs | 33 ++ Setup/HttpClientsSetup.cs | 43 +++ Setup/MongoDbSetup.cs | 42 +++ Setup/ValidationSetup.cs | 74 +++++ Utils/DuplicateHints.cs | 129 ++++++++ Utils/ObjectIdJsonConverter.cs | 14 + Utils/Result.cs | 75 +++++ appsettings.Development.json | 19 ++ appsettings.json | 36 +++ transdb-backend-net.csproj | 25 ++ transdb-backend-net.sln | 31 ++ transdb-migration/migrate.js | 336 ++++++++++++++++++++ 53 files changed, 4057 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Attributes/ValidateCaptchaAttribute.cs create mode 100644 Controllers/ActivityController.cs create mode 100644 Controllers/AdminEntriesController.cs create mode 100644 Controllers/AuthController.cs create mode 100644 Controllers/DefaultController.cs create mode 100644 Controllers/EntriesController.cs create mode 100644 Controllers/ReportController.cs create mode 100644 Controllers/UsersController.cs create mode 100644 Dockerfile create mode 100644 Errors/ApiErrors.cs create mode 100644 Models/Config.cs create mode 100644 Models/Database/Entry.cs create mode 100644 Models/Database/EntryActivity.cs create mode 100644 Models/Database/EntryRevocationToken.cs create mode 100644 Models/Database/GeoJsonPoint.cs create mode 100644 Models/Request/AdminEntriesFilterRequest.cs create mode 100644 Models/Request/CommentedRequest.cs create mode 100644 Models/Request/CreateEntryRequest.cs create mode 100644 Models/Request/EditEntryRequest.cs create mode 100644 Models/Request/EntriesFilterRequest.cs create mode 100644 Models/Request/LoginRequest.cs create mode 100644 Models/Request/PatchEntryStatusRequest.cs create mode 100644 Models/Request/ReportRequest.cs create mode 100644 Models/Response/EntryResponse.cs create mode 100644 Models/Response/LoginResponse.cs create mode 100644 Models/Response/UserResponse.cs create mode 100644 Program.cs create mode 100644 README.md create mode 100644 Schema/EntryEnums.cs create mode 100644 Schema/EntrySchema.cs create mode 100644 Services/AuthService.cs create mode 100644 Services/CaptchaService.cs create mode 100644 Services/CmsService.cs create mode 100644 Services/DatabaseService.cs create mode 100644 Services/EntryActivityService.cs create mode 100644 Services/EntryRevocationService.cs create mode 100644 Services/EntryService.cs create mode 100644 Services/GeocodingService.cs create mode 100644 Services/NominatimService.cs create mode 100644 Setup/CookieSetup.cs create mode 100644 Setup/HttpClientsSetup.cs create mode 100644 Setup/MongoDbSetup.cs create mode 100644 Setup/ValidationSetup.cs create mode 100644 Utils/DuplicateHints.cs create mode 100644 Utils/ObjectIdJsonConverter.cs create mode 100644 Utils/Result.cs create mode 100644 appsettings.Development.json create mode 100644 appsettings.json create mode 100644 transdb-backend-net.csproj create mode 100644 transdb-backend-net.sln create mode 100644 transdb-migration/migrate.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..cd967fc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,25 @@ +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/.idea +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4d387a3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +bin/ +obj/ +/packages/ +riderModule.iml +/_ReSharper.Caches/ +/transdb-migration/* +!/transdb-migration/migrate.js +.idea +Properties +*.DotSettings.user \ No newline at end of file diff --git a/Attributes/ValidateCaptchaAttribute.cs b/Attributes/ValidateCaptchaAttribute.cs new file mode 100644 index 0000000..a56e3c0 --- /dev/null +++ b/Attributes/ValidateCaptchaAttribute.cs @@ -0,0 +1,49 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Options; +using transdb_backend_net.Exceptions; +using transdb_backend_net.Models.Config; +using transdb_backend_net.Services; + +namespace transdb_backend_net.Attributes; + +/// +/// Action filter that verifies the Cap CAPTCHA token from the X-Cap-Token request header +/// before allowing the action to execute. Verification is skipped when CAPTCHA is disabled in config. +/// Apply to public endpoints that should be protected against automated submissions. +/// +[AttributeUsage(AttributeTargets.Method)] +public class ValidateCaptchaAttribute : Attribute, IAsyncActionFilter +{ + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + var config = context.HttpContext.RequestServices + .GetRequiredService>().Value; + + if (!config.Enabled) + { + await next(); + return; + } + + var token = context.HttpContext.Request.Headers["X-Cap-Token"].FirstOrDefault(); + + if (string.IsNullOrEmpty(token)) + { + context.Result = new CaptchaVerificationError(); + return; + } + + var captcha = context.HttpContext.RequestServices.GetRequiredService(); + + var valid = await captcha.VerifyAsync(token); + + if (!valid) + { + context.Result = new CaptchaVerificationError(); + return; + } + + await next(); + } +} diff --git a/Controllers/ActivityController.cs b/Controllers/ActivityController.cs new file mode 100644 index 0000000..6fb477d --- /dev/null +++ b/Controllers/ActivityController.cs @@ -0,0 +1,61 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using MongoDB.Bson; +using System.Security.Claims; +using System.Text.Json; +using transdb_backend_net.Exceptions; +using transdb_backend_net.Models.Database; +using transdb_backend_net.Models.Request; +using transdb_backend_net.Services; + +namespace transdb_backend_net.Controllers; + +[ApiController] +[Route("activities")] +[Authorize] +public class ActivityController(IEntryActivityService activityService, IEntryService entryService, IDatabaseService databaseService) : ControllerBase +{ + /// Returns a paginated list of all activity events across all entries. + [HttpGet] + public async Task>> GetAll([FromQuery] int page = 0) + { + var activities = await activityService.GetAllAsync(page); + return Ok(activities); + } + + /// Returns a paginated list of activity events for a specific entry. + [HttpGet("entry/{id}")] + public async Task>> GetByEntry(ObjectId id, [FromQuery] int page = 0) + { + var entryResult = await entryService.GetEntryByIdAsync(id); + if (entryResult.IsFailed) return NotFound(); + + var activities = await activityService.GetByEntryAsync(id, page); + return Ok(activities); + } + + /// + /// Reverts the effect of an activity. + /// Supported: DuplicateDetected (removes duplicate link), Deleted (restores entry with original ID), + /// Blocked/Unblocked (toggles blocked status), Archived (unarchives entry). + /// + [HttpPost("{activityId}/revert")] + [Authorize(Policy = "AdminOnly")] + public async Task RevertActivity(ObjectId activityId, [FromBody] CommentedRequest request) + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier)!; + + var activity = await databaseService.GetActivityByIdAsync(activityId); + if (activity == null) return new NotFoundApiError("activity not found"); + + var result = await activityService.RevertAsync(activity, userId, request.Comment); + if (result.IsFailed) + { + return result.FailureDetails?.Contains("not found") == true + ? new NotFoundApiError(result.FailureDetails) + : new InvalidRequestApiError(result.FailureDetails); + } + + return Ok(); + } +} diff --git a/Controllers/AdminEntriesController.cs b/Controllers/AdminEntriesController.cs new file mode 100644 index 0000000..443fc43 --- /dev/null +++ b/Controllers/AdminEntriesController.cs @@ -0,0 +1,136 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using MongoDB.Bson; +using System.Security.Claims; +using System.Text.Json; +using MongoDB.Bson.IO; +using transdb_backend_net.Exceptions; +using transdb_backend_net.Models.Database; +using transdb_backend_net.Models.Request; +using transdb_backend_net.Models.Response; +using transdb_backend_net.Services; + +namespace transdb_backend_net.Controllers; + +[ApiController] +[Route("admin/entries")] +[Authorize] +public class AdminEntriesController( + IEntryService entryService, + IEntryActivityService activityService) : ControllerBase +{ + /// + /// Returns a paginated list of entries for admin review with full field visibility. + /// Supports optional filtering by approved/blocked/archived status and full-text search. + /// Returns all entries when no filters are applied. + /// + [HttpGet] + public async Task>> GetEntries([FromQuery] AdminEntriesFilterRequest filter) + { + var result = await entryService.GetFullEntriesForElevatedUsageAsync(filter); + return Ok(result); + } + + /// Returns a single entry by ID with full field visibility. + [HttpGet("{id}")] + public async Task> GetEntry(ObjectId id) + { + var result = await entryService.GetEntryByIdAsync(id); + if (result.IsFailed) return new NotFoundApiError(result.FailureDetails); + + return Ok(result.Value!); + } + + /// Partially updates an entry's approved, blocked, and/or archived status. + [HttpPatch("{id}/status")] + public async Task PatchEntryStatus(ObjectId id, [FromBody] PatchEntryStatusRequest request) + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier)!; + + var existingResult = await entryService.GetEntryByIdAsync(id); + if (existingResult.IsFailed) return new NotFoundApiError(existingResult.FailureDetails); + var existing = existingResult.Value!; + + var result = await entryService.PatchEntryAsync(id, request); + if (result.IsFailed) + { + return result.SelectApiError( + expected: new NotFoundApiError(result.FailureDetails), + unexpected: new OperationFailedApiError(result.FailureDetails) + ); + } + + await activityService.LogStatusChangesAsync(id, userId, existing, + new EntryStatusChange(request.Approved, request.Blocked, request.Archived, request.Comment)); + + return Ok(); + } + + /// + /// Fully replaces an entry with the submitted data. + /// A geo location update is triggered in the background if the address changed. + /// + [HttpPut("{id}")] + [Authorize(Policy = "AdminOnly")] + public async Task EditEntry(ObjectId id, [FromBody] EditEntryRequest request) + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier)!; + + var existingResult = await entryService.GetEntryByIdAsync(id); + if (existingResult.IsFailed) return new NotFoundApiError(existingResult.FailureDetails); + var existing = existingResult.Value!; + + var originalEntry = JsonSerializer.Deserialize(JsonSerializer.Serialize(existing)); + + var result = await entryService.EditEntryAsync(id, request); + if (result.IsFailed) + { + return result.SelectApiError( + expected: new NotFoundApiError(result.FailureDetails), + unexpected: new OperationFailedApiError(result.FailureDetails) + ); + } + + await activityService.LogAsync(EntryActivity.Edited(id, userId, request.Comment, originalEntry, request)); + await activityService.LogStatusChangesAsync(id, userId, existing, + new EntryStatusChange(request.Status.Approved, request.Status.Blocked, null, request.Comment)); + + return Ok(); + } + + /// Manually triggers a geo location update for an entry using its current address. + [HttpPut("{id}/updateGeo")] + [Authorize(Policy = "AdminOnly")] + public async Task UpdateGeo(ObjectId id) + { + var result = await entryService.UpdateGeoLocationAsync(id); + if (result.IsFailed) + { + return result.SelectApiError( + expected: new NotFoundApiError(result.FailureDetails), + unexpected: new OperationFailedApiError(result.FailureDetails) + ); + } + + return Ok(); + } + + + /// Permanently deletes an entry from the database. This cannot be undone. + [HttpDelete("{id}")] + [Authorize(Policy = "AdminOnly")] + public async Task DeleteEntry(ObjectId id, [FromBody] CommentedRequest request) + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier)!; + + var existingResult = await entryService.GetEntryByIdAsync(id); + if (existingResult.IsFailed) return new NotFoundApiError(existingResult.FailureDetails); + + var result = await entryService.DeleteEntryAsync(id); + if (result.IsFailed) return new NotFoundApiError(result.FailureDetails); + + await activityService.LogAsync(EntryActivity.Deleted(existingResult.Value!, userId, request.Comment)); + + return Ok(); + } +} diff --git a/Controllers/AuthController.cs b/Controllers/AuthController.cs new file mode 100644 index 0000000..2a52c82 --- /dev/null +++ b/Controllers/AuthController.cs @@ -0,0 +1,60 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Mvc; +using System.Security.Claims; +using transdb_backend_net.Attributes; +using transdb_backend_net.Exceptions; +using transdb_backend_net.Models.Request; +using transdb_backend_net.Models.Response; +using transdb_backend_net.Services; +using transdb_backend_net.Utils; + +namespace transdb_backend_net.Controllers; + +[ApiController] +[Route("auth")] +public class AuthController(IAuthService authService) : ControllerBase +{ + /// + /// Authenticates a user via Directus and issues a session cookie on success. + /// The cookie is not persistent and expires when the browser session ends. + /// + [HttpPost("login")] + [ValidateCaptcha] + public async Task> Login([FromBody] LoginRequest request) + { + var loginResult = await authService.LoginAsync(request); + + if (loginResult.IsFailed) + { + return loginResult.SelectApiError( + expected: new LoginFailedApiError(loginResult.FailureDetails), + unexpected: new CmsInteractionFailedError(loginResult.FailureDetails) + ); + } + + var login = loginResult.Value!; + + await HttpContext.SignInAsync( + CookieAuthenticationDefaults.AuthenticationScheme, + login.Principal, + new AuthenticationProperties { IsPersistent = false }); + + var userId = login.Principal.FindFirstValue(ClaimTypes.NameIdentifier)!; + + return Ok(new LoginResponse + { + Id = userId, + Username = login.Username, + Admin = login.IsAdmin + }); + } + + /// Signs the current user out by clearing the session cookie. + [HttpPost("logout")] + public async Task Logout() + { + await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + return Ok(); + } +} diff --git a/Controllers/DefaultController.cs b/Controllers/DefaultController.cs new file mode 100644 index 0000000..8e947f5 --- /dev/null +++ b/Controllers/DefaultController.cs @@ -0,0 +1,24 @@ +using Microsoft.AspNetCore.Mvc; +using transdb_backend_net.Exceptions; +using transdb_backend_net.Services; + +namespace transdb_backend_net.Controllers; + +[ApiController] +[Route("/")] +public class DefaultController(IDatabaseService db) : ControllerBase +{ + /// Returns 200 OK if the server is reachable. + [HttpGet("health")] + public async Task Healthcheck() + { + var healthy = await db.Healthcheck(); + + if (!healthy) + { + return new ApplicationUnhealthyApiError(); + } + + return Ok(); + } +} diff --git a/Controllers/EntriesController.cs b/Controllers/EntriesController.cs new file mode 100644 index 0000000..accfa92 --- /dev/null +++ b/Controllers/EntriesController.cs @@ -0,0 +1,97 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using MongoDB.Bson; + +using transdb_backend_net.Attributes; +using transdb_backend_net.Exceptions; +using transdb_backend_net.Models.Database; +using transdb_backend_net.Models.Request; +using transdb_backend_net.Models.Response; +using transdb_backend_net.Services; +using transdb_backend_net.Utils; + +namespace transdb_backend_net.Controllers; + +[ApiController] +[Route("entries")] +public class EntriesController( + IEntryService entryService, + IEntryRevocationService revocationService, + IEntryActivityService activityService) : ControllerBase +{ + /// + /// Returns a paginated list of approved entries matching the given filters. + /// Supports full-text search, type/offer/attribute filtering, and optional location-based sorting. + /// + [HttpGet] + [ValidateCaptcha] + public async Task>> Filter([FromQuery] EntriesFilterRequest filter) + { + var result = await entryService.FilterEntriesForPublicUsageAsync(filter); + return Ok(result); + } + + /// + /// Submits a new entry for review. Creates a CMS ticket automatically. + /// Rate-limited to prevent spam submissions. + /// + [HttpPost] + [EnableRateLimiting("newEntry")] + [ValidateCaptcha] + public async Task> CreateEntry([FromBody] CreateEntryRequest request) + { + var result = await entryService.CreateEntryAsync(request); + if (result.IsFailed) return new OperationFailedApiError(result.FailureDetails); + + var entry = result.Value!; + await activityService.LogAsync(EntryActivity.Submitted(entry.Id)); + if (entry.PossibleDuplicate != null) + await activityService.LogAsync(EntryActivity.DuplicateDetected(entry.Id, entry.PossibleDuplicate)); + + var userAgent = Request.Headers.UserAgent.ToString(); + var revocationToken = await revocationService.GenerateTokenAsync(entry.Id, userAgent); + + DuplicateMatch? possibleDuplicate = null; + + if (entry.PossibleDuplicate != null) + { + var dupResult = await entryService.GetEntryByIdAsync(entry.PossibleDuplicate.EntryId); + if (dupResult.IsOk && dupResult.Value!.Status.ShouldBePubliclyVisible) + possibleDuplicate = entry.PossibleDuplicate; + } + + return Ok(new CreateEntryResponse(entry, revocationToken, possibleDuplicate)); + } + + /// Returns a single publicly visible entry by its ID. + [HttpGet("{id}")] + public async Task> GetEntry(ObjectId id) + { + var result = await entryService.GetPublicEntryByIdAsync(id); + if (result.IsFailed) return new NotFoundApiError(result.FailureDetails); + + return Ok(new PublicEntryResponse(result.Value!)); + } + + /// Permanently deletes a newly created entry using a single-use revocation token. + [HttpDelete("{id}/revoke/{token}")] + [EnableRateLimiting("revoke")] + public async Task RevokeEntry(ObjectId id, string token) + { + var userAgent = Request.Headers.UserAgent.ToString(); + if (!await revocationService.ValidateTokenAsync(token, id, userAgent)) + { + return new InvalidRequestApiError("revocation token not found or already used"); + } + + var result = await entryService.DeleteEntryAsync(id); + if (result.IsFailed) + { + return new InvalidRequestApiError(result.FailureDetails); + } + + await revocationService.InvalidateTokenAsync(token); + + return Ok(); + } +} diff --git a/Controllers/ReportController.cs b/Controllers/ReportController.cs new file mode 100644 index 0000000..dc2895a --- /dev/null +++ b/Controllers/ReportController.cs @@ -0,0 +1,47 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using transdb_backend_net.Attributes; +using transdb_backend_net.Exceptions; +using transdb_backend_net.Models.Database; +using transdb_backend_net.Models.Request; +using transdb_backend_net.Services; + +namespace transdb_backend_net.Controllers; + +[ApiController] +[Route("report")] +public class ReportController(IEntryService entryService, ICmsService cms, IEntryActivityService activityService) : ControllerBase +{ + /// + /// Submits a report about an existing entry and creates a CMS ticket for review. + /// Rate-limited to prevent abuse. + /// + [HttpPost] + [EnableRateLimiting("report")] + [ValidateCaptcha] + public async Task Report([FromBody] ReportRequest request) + { + var entryResult = await entryService.GetEntryByIdAsync(request.Id); + if (entryResult.IsFailed) + { + return new NotFoundApiError(entryResult.FailureDetails); + } + + var cmsType = request.Type switch + { + ReportType.Edit => CmsTicketType.Edit, + ReportType.Other => CmsTicketType.Other, + _ => CmsTicketType.Report, + }; + + var ticketResult = await cms.CreateTicketAsync(entryResult.Value!.Name, request.Id.ToString(), cmsType, request.Message); + if (ticketResult.IsFailed) + { + return new OperationFailedApiError(ticketResult.FailureDetails); + } + + await activityService.LogAsync(EntryActivity.Reported(request.Id, request.Type, ticketResult.Value, request.Message)); + + return Ok(); + } +} diff --git a/Controllers/UsersController.cs b/Controllers/UsersController.cs new file mode 100644 index 0000000..7aba402 --- /dev/null +++ b/Controllers/UsersController.cs @@ -0,0 +1,52 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Memory; +using transdb_backend_net.Exceptions; +using transdb_backend_net.Models.Response; +using transdb_backend_net.Services; + +namespace transdb_backend_net.Controllers; + +[ApiController] +[Route("users")] +[Authorize] +public class UsersController(ICmsService cms, IMemoryCache cache) : ControllerBase +{ + private const string CacheKey = "cms_users"; + + [HttpGet("{id}")] + public async Task> GetUser(string id) + { + var users = await GetCachedUsersAsync(); + if (users == null) + { + return new OperationFailedApiError(); + } + + var user = users.FirstOrDefault(u => u.Id == id); + + if (user == null) + { + return new NotFoundApiError("user not found"); + } + + return Ok(new UserResponse(user)); + } + + private async Task?> GetCachedUsersAsync() + { + if (cache.TryGetValue(CacheKey, out List? cached)) + { + return cached; + } + + var result = await cms.GetAllUsersAsync(); + if (result.IsFailed) + { + return null; + } + + cache.Set(CacheKey, result.Value, TimeSpan.FromHours(24)); + return result.Value; + } +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..48d5c31 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base + +WORKDIR /app +EXPOSE 8080 +EXPOSE 8081 + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY ["transdb-backend-net/transdb-backend-net.csproj", "transdb-backend-net/"] +RUN dotnet restore "transdb-backend-net/transdb-backend-net.csproj" +COPY . . +WORKDIR "/src/transdb-backend-net" +RUN dotnet build "./transdb-backend-net.csproj" -c $BUILD_CONFIGURATION -o /app/build + +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "./transdb-backend-net.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "transdb-backend-net.dll"] diff --git a/Errors/ApiErrors.cs b/Errors/ApiErrors.cs new file mode 100644 index 0000000..6b52267 --- /dev/null +++ b/Errors/ApiErrors.cs @@ -0,0 +1,71 @@ +using System.Text.RegularExpressions; +using Microsoft.AspNetCore.Mvc; + +namespace transdb_backend_net.Exceptions; + +/// +/// Base class for all structured API error responses. +/// +public class ApiError : ObjectResult +{ + public ApiError(int statusCode, string failure, string? details = null) + : base(new { failure, details }) + { + StatusCode = statusCode; + } +} + +/// 401 — credentials were rejected by the CMS (wrong username or password). +public class LoginFailedApiError(string? details = null) + : ApiError(StatusCodes.Status401Unauthorized, "login_failed", details); + +/// 404 — the requested resource does not exist. +public class NotFoundApiError(string? details = null) + : ApiError(StatusCodes.Status404NotFound, "not_found", details); + +/// 400 — the request was syntactically valid but logically rejected (e.g. cannot revert this activity type). +public class InvalidRequestApiError(string? details = null) + : ApiError(StatusCodes.Status400BadRequest, "invalid_request", details); + +/// 401 — the request requires authentication but none was provided. +public class UnauthorizedApiError(string? details = null) + : ApiError(StatusCodes.Status401Unauthorized, "unauthorized", details); + +/// 403 — the authenticated user lacks permission for this action. +public class ForbiddenApiError(string? details = null) + : ApiError(StatusCodes.Status403Forbidden, "forbidden", details); + +/// 403 — the CAPTCHA token was missing or failed verification. +public class CaptchaVerificationError(string? details = null) + : ApiError(StatusCodes.Status403Forbidden, "captcha_verification_failed", details); + +/// A single field-level constraint violation returned in . +/// camelCase JSON property path (e.g. "email", "address.city"). +/// Machine-readable constraint code for frontend i18n (e.g. "required", "length", "email"). +public partial class ValidationProblem(string property, string code) +{ + public string Property { get; set; } + public string Code { get; set; } = code; +} + +/// 422 — the request body failed model validation. Includes a structured problems list for frontend i18n. +public class ValidationApiError : ObjectResult +{ + public ValidationApiError(IList problems) + : base(new { failure = "validation_error", problems }) + { + StatusCode = StatusCodes.Status422UnprocessableEntity; + } +} + +/// 500 — an internal operation failed unexpectedly (e.g. a downstream service call). +public class OperationFailedApiError(string? details = null) + : ApiError(StatusCodes.Status500InternalServerError, "operation_failed", details); + +/// 503 — the application or its dependencies are not healthy. +public class ApplicationUnhealthyApiError(string? details = null) + : ApiError(StatusCodes.Status503ServiceUnavailable, "application_unhealthy", details); + +/// 400 — a CMS interaction was attempted but failed (e.g. ticket creation rejected by Directus). +public class CmsInteractionFailedError(string? details = null) + : ApiError(StatusCodes.Status400BadRequest, "cms_interaction_failed", details); diff --git a/Models/Config.cs b/Models/Config.cs new file mode 100644 index 0000000..a6f23b6 --- /dev/null +++ b/Models/Config.cs @@ -0,0 +1,71 @@ +namespace transdb_backend_net.Models.Config; + +public class GeocodingConfig +{ + public const string ConfigKey = "Geocoding"; + + public string Url { get; set; } = string.Empty; + public string ApiKey { get; set; } = string.Empty; +} + +public class CaptchaConfig +{ + public const string ConfigKey = "Captcha"; + + public string InstanceUrl { get; set; } = string.Empty; + public string SiteKey { get; set; } = string.Empty; + public string Secret { get; set; } = string.Empty; + public bool Enabled { get; set; } = true; +} + +public class CmsConfig +{ + public const string ConfigKey = "Cms"; + + public string Url { get; set; } = string.Empty; + public string AccessToken { get; set; } = string.Empty; + public string TicketCollection { get; set; } = "transdb_tickets"; +} + +public class MongoDbConfig +{ + public const string ConfigKey = "MongoDB"; + + public string ConnectionUri { get; set; } = string.Empty; + public int ItemsPerPage { get; set; } = 10; + public int AdminItemsPerPage { get; set; } = 20; + public int ActivityItemsPerPage { get; set; } = 35; + public double DuplicateProbabilityThreshold { get; set; } = 0.55; + public TimeSpan RevocationTokenLifetime { get; set; } = TimeSpan.FromHours(24); +} + +public class NominatimConfig +{ + public const string ConfigKey = "Nominatim"; + + public string Url { get; set; } = "https://nominatim.openstreetmap.org"; + public string UserAgent { get; set; } = string.Empty; +} + +public class RateLimiterPolicyConfig +{ + public TimeSpan Window { get; set; } = TimeSpan.FromMinutes(5); + public int PermitLimit { get; set; } = 3; +} + +public class CorsConfig +{ + public const string ConfigKey = "Cors"; + + /// Allowed origins for the development CORS policy (used when ASPNETCORE_ENVIRONMENT=Development). + public string[] DevOrigins { get; set; } = ["http://localhost:5173"]; +} + +public class RateLimiterConfig +{ + public const string ConfigKey = "RateLimiter"; + public RateLimiterPolicyConfig NewEntry { get; set; } = new(); + public RateLimiterPolicyConfig Report { get; set; } = new(); + public RateLimiterPolicyConfig Login { get; set; } = new() { PermitLimit = 5 }; + public RateLimiterPolicyConfig Revoke { get; set; } = new() { PermitLimit = 5 }; +} diff --git a/Models/Database/Entry.cs b/Models/Database/Entry.cs new file mode 100644 index 0000000..6cfa328 --- /dev/null +++ b/Models/Database/Entry.cs @@ -0,0 +1,126 @@ +using System.Text.Json.Serialization; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; +using transdb_backend_net.Models.Request; +using transdb_backend_net.Schema; + +namespace transdb_backend_net.Models.Database; + +/// Optional contact person associated with an entry (e.g. a therapist's name). +public class ContactPerson +{ + public AcademicTitle? AcademicTitle { get; set; } + public string? FirstName { get; set; } + public string? LastName { get; set; } + + public ContactPerson() { } + + public ContactPerson(ContactPersonRequest request) + { + AcademicTitle = request.AcademicTitle; + FirstName = request.FirstName; + LastName = request.LastName; + } +} + +/// Physical address of an entry. Only is required. +public class Address +{ + public string City { get; set; } = string.Empty; + public string? Plz { get; set; } + public string? Street { get; set; } + public string? House { get; set; } + + public Address() { } + + public Address(AddressRequest request) + { + City = request.City; + Plz = request.Plz; + Street = request.Street; + House = request.House; + } +} + +/// Moderation flags controlling whether an entry is visible and editable. +public class EntryStatus +{ + public bool Approved { get; set; } + public bool Blocked { get; set; } + public bool Archived { get; set; } + + /// + /// Whether this entry should be shown to unauthenticated users. + /// Not persisted — evaluated at runtime from the status flags. + /// + [BsonIgnore] + [JsonIgnore] + public bool ShouldBePubliclyVisible => this.Approved && !this.Blocked && !this.Archived; +} + +/// +/// Result of the duplicate detection check, stored on the entry and returned in the API response. +/// is the ratio of the achieved score to the maximum possible score +/// for this entry's field set (0–1), so entries with fewer optional fields are not penalised. +/// +public class DuplicateMatch +{ + public ObjectId EntryId { get; set; } + public double Probability { get; set; } +} + +/// Core domain entity representing a trans-relevant healthcare or community resource. +public class Entry +{ + [BsonId] + public ObjectId Id { get; set; } + + public EntryStatus Status { get; set; } = new(); + + public EEntryType Type { get; set; } + public string Name { get; set; } = string.Empty; + + [BsonIgnoreIfNull] + public ContactPerson? Contact { get; set; } + + public string? Email { get; set; } + public string? Telephone { get; set; } + public string? Website { get; set; } + public bool? Accessible { get; set; } + + public Address Address { get; set; } = new(); + public GeoJsonPoint? Location { get; set; } + + public List Attributes { get; set; } = []; + public List Offers { get; set; } = []; + public string? Specials { get; set; } + public TherapistSubject? Subject { get; set; } + + /// Set when a likely duplicate is detected on submission. Cleared manually by an admin. + [BsonIgnoreIfNull] + public DuplicateMatch? PossibleDuplicate { get; set; } + + public Entry() { } + + public Entry(CreateEntryRequest request) + { + Type = request.Type; + Name = request.Name; + Contact = request.Contact != null ? new ContactPerson(request.Contact) : null; + Email = request.Email; + Telephone = request.Telephone; + Website = request.Website; + Accessible = request.Accessible; + Address = new Address(request.Address); + Attributes = request.Attributes; + Offers = request.Offers; + Specials = request.Specials; + Subject = request.Subject; + } +} + +/// Entry projected from a geo-aggregation pipeline, carrying the distance in kilometres. +public class EntryWithDistance : Entry +{ + public double? Distance { get; set; } +} diff --git a/Models/Database/EntryActivity.cs b/Models/Database/EntryActivity.cs new file mode 100644 index 0000000..8fd1627 --- /dev/null +++ b/Models/Database/EntryActivity.cs @@ -0,0 +1,191 @@ +using System.Text.Json.Serialization; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; +using MongoDB.Bson.Serialization.Options; +using transdb_backend_net.Models.Request; + +namespace transdb_backend_net.Models.Database; + +/// Carries the fields the caller wants to change in a status patch. Null means "leave unchanged". +public record EntryStatusChange(bool? Approved, bool? Blocked, bool? Archived, string? Comment); + +/// Audit event types that can be logged against an entry. +public enum EntryActivityType +{ + Submitted, + DuplicateDetected, + Approved, + Archived, + Edited, + Blocked, + Unblocked, + Reported, + Deleted, + Restored, + GeoLocationFailed +} + +/// Keys for the dictionary. +public enum EntryActivityAttachment +{ + CmsTicketId, + ReportType, + OriginalEntryState, + EntryChangeState, + PossibleDuplicate, + RevertedActivityId +} + +/// An immutable audit record describing a single state change on an entry. +public class EntryActivity +{ + [BsonId] + public ObjectId Id { get; set; } + public ObjectId EntryId { get; set; } + public EntryActivityType Type { get; set; } + public DateTime? Timestamp { get; set; } = null; + + [BsonIgnoreIfNull] + public string? UserId { get; set; } + + [BsonIgnoreIfNull] + public string? Comment { get; set; } + + [JsonIgnore] + [BsonIgnoreIfDefault] + [BsonDictionaryOptions(DictionaryRepresentation.ArrayOfArrays)] + public Dictionary Attachments { get; set; } = new(); + + private static readonly HashSet HiddenAttachments = + [ + EntryActivityAttachment.OriginalEntryState, + EntryActivityAttachment.EntryChangeState + ]; + + /// + /// Filtered view of that is safe to expose via the API. + /// Strips entry states to avoid returning full entry snapshots to clients. + /// + [BsonIgnore] + [JsonPropertyName("attachments")] + public Dictionary PublicAttachments => + Attachments.Where(kv => !HiddenAttachments.Contains(kv.Key)) + .ToDictionary(kv => kv.Key, kv => kv.Value); + + [BsonConstructor] + private EntryActivity() { } + + public static EntryActivity Submitted(ObjectId entryId) => new() + { + EntryId = entryId, + Type = EntryActivityType.Submitted, + Timestamp = DateTime.UtcNow + }; + + public static EntryActivity DuplicateDetected(ObjectId entryId, DuplicateMatch duplicate) => new() + { + EntryId = entryId, + Type = EntryActivityType.DuplicateDetected, + Timestamp = DateTime.UtcNow, + Attachments = new Dictionary + { + [EntryActivityAttachment.PossibleDuplicate] = duplicate + } + }; + + public static EntryActivity Approved(ObjectId entryId, string userId) => new() + { + EntryId = entryId, + UserId = userId, + Type = EntryActivityType.Approved, + Timestamp = DateTime.UtcNow + }; + + public static EntryActivity Archived(ObjectId entryId, string? userId, string? comment) => new() + { + EntryId = entryId, + UserId = userId, + Comment = comment, + Type = EntryActivityType.Archived, + Timestamp = DateTime.UtcNow + }; + + public static EntryActivity Edited(ObjectId entryId, string userId, string? comment, Entry original, EditEntryRequest changes) => new() + { + EntryId = entryId, + UserId = userId, + Comment = comment, + Type = EntryActivityType.Edited, + Timestamp = DateTime.UtcNow, + Attachments = new Dictionary + { + [EntryActivityAttachment.OriginalEntryState] = original, + [EntryActivityAttachment.EntryChangeState] = changes + } + }; + + public static EntryActivity Blocked(ObjectId entryId, string userId, string? comment) => new() + { + EntryId = entryId, + UserId = userId, + Comment = comment, + Type = EntryActivityType.Blocked, + Timestamp = DateTime.UtcNow + }; + + public static EntryActivity Unblocked(ObjectId entryId, string userId, string? comment) => new() + { + EntryId = entryId, + UserId = userId, + Comment = comment, + Type = EntryActivityType.Unblocked, + Timestamp = DateTime.UtcNow + }; + + public static EntryActivity Reported(ObjectId entryId, ReportType reportType, string cmsTicketId, string comment) => new() + { + EntryId = entryId, + Type = EntryActivityType.Reported, + Timestamp = DateTime.UtcNow, + Comment = comment, + Attachments = new Dictionary + { + [EntryActivityAttachment.ReportType] = reportType.ToString(), + [EntryActivityAttachment.CmsTicketId] = cmsTicketId + } + }; + + public static EntryActivity Deleted(Entry entry, string userId, string? comment) => new() + { + EntryId = entry.Id, + UserId = userId, + Comment = comment, + Type = EntryActivityType.Deleted, + Timestamp = DateTime.UtcNow, + Attachments = new Dictionary + { + [EntryActivityAttachment.OriginalEntryState] = entry + } + }; + + public static EntryActivity Restored(ObjectId entryId, string userId, string? comment, ObjectId revertedActivityId) => new() + { + EntryId = entryId, + UserId = userId, + Comment = comment, + Type = EntryActivityType.Restored, + Timestamp = DateTime.UtcNow, + Attachments = new Dictionary + { + [EntryActivityAttachment.RevertedActivityId] = revertedActivityId.ToString() + } + }; + + /// Logged when an automatic geocoding update fails after an entry is approved or edited. + public static EntryActivity GeoLocationFailed(ObjectId entryId) => new() + { + EntryId = entryId, + Type = EntryActivityType.GeoLocationFailed, + Timestamp = DateTime.UtcNow + }; +} diff --git a/Models/Database/EntryRevocationToken.cs b/Models/Database/EntryRevocationToken.cs new file mode 100644 index 0000000..01bbc2a --- /dev/null +++ b/Models/Database/EntryRevocationToken.cs @@ -0,0 +1,30 @@ +using System.Security.Cryptography; +using System.Text; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; +using transdb_backend_net.Services; + +namespace transdb_backend_net.Models.Database; + +public class EntryRevocationToken +{ + [BsonId] + public ObjectId Id { get; set; } + + public string Token { get; set; } = string.Empty; + public ObjectId EntryId { get; set; } + public string UserAgentHash { get; set; } = string.Empty; + public DateTime ExpiresAt { get; set; } + + public EntryRevocationToken() {} + + public EntryRevocationToken(ObjectId entryId, string userAgent, DateTime expiresAt) + { + this.Token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant(); + this.UserAgentHash = EntryRevocationService.HashUserAgent(userAgent); + this.ExpiresAt = expiresAt; + this.EntryId = entryId; + } + + +} diff --git a/Models/Database/GeoJsonPoint.cs b/Models/Database/GeoJsonPoint.cs new file mode 100644 index 0000000..38c67e6 --- /dev/null +++ b/Models/Database/GeoJsonPoint.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Serialization; +using MongoDB.Bson.Serialization.Attributes; + +namespace transdb_backend_net.Models.Database; + +public class GeoJsonPoint +{ + public string Type { get; set; } = "Point"; + + // [longitude, latitude] + public double[] Coordinates { get; set; } = []; + + [BsonIgnore] + [JsonIgnore] + public double Lat => Coordinates[1]; + + [BsonIgnore] + [JsonIgnore] + public double Lng => Coordinates[0]; +} diff --git a/Models/Request/AdminEntriesFilterRequest.cs b/Models/Request/AdminEntriesFilterRequest.cs new file mode 100644 index 0000000..af0b01f --- /dev/null +++ b/Models/Request/AdminEntriesFilterRequest.cs @@ -0,0 +1,23 @@ +using MongoDB.Driver; +using transdb_backend_net.Models.Database; + +namespace transdb_backend_net.Models.Request; + +public class AdminEntriesFilterRequest : EntriesFilterRequest +{ + public bool? Approved { get; set; } + public bool? Blocked { get; set; } + public bool? Archived { get; set; } + + protected override void AddExtraConditions(List> conditions) + { + if (Approved.HasValue) + conditions.Add(Builders.Filter.Eq(e => e.Status.Approved, Approved.Value)); + + if (Blocked.HasValue) + conditions.Add(Builders.Filter.Eq(e => e.Status.Blocked, Blocked.Value)); + + if (Archived.HasValue) + conditions.Add(Builders.Filter.Eq(e => e.Status.Archived, Archived.Value)); + } +} diff --git a/Models/Request/CommentedRequest.cs b/Models/Request/CommentedRequest.cs new file mode 100644 index 0000000..614fcb4 --- /dev/null +++ b/Models/Request/CommentedRequest.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace transdb_backend_net.Models.Request; + +public class CommentedRequest +{ + [Required(ErrorMessage = "required"), StringLength(2000, MinimumLength = 1, ErrorMessage = "length")] + public string Comment { get; set; } = string.Empty; +} diff --git a/Models/Request/CreateEntryRequest.cs b/Models/Request/CreateEntryRequest.cs new file mode 100644 index 0000000..0d754b4 --- /dev/null +++ b/Models/Request/CreateEntryRequest.cs @@ -0,0 +1,120 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.RegularExpressions; +using PhoneNumbers; +using transdb_backend_net.Schema; + +namespace transdb_backend_net.Models.Request; + +public class AddressRequest +{ + [Required(ErrorMessage = "required"), StringLength(50, MinimumLength = 2, ErrorMessage = "length")] + public string City { get; set; } = string.Empty; + + [StringLength(10, ErrorMessage = "length")] + public string? Plz { get; set; } + + [StringLength(80, ErrorMessage = "length")] + public string? Street { get; set; } + + [StringLength(10, ErrorMessage = "length")] + public string? House { get; set; } +} + + +/// Optional contact person fields submitted alongside an entry. +public class ContactPersonRequest +{ + [EnumDataType(typeof(AcademicTitle), ErrorMessage = "invalid_value")] + public AcademicTitle? AcademicTitle { get; set; } + + [StringLength(50, MinimumLength = 2, ErrorMessage = "length")] + public string? FirstName { get; set; } + + [StringLength(50, MinimumLength = 2, ErrorMessage = "length")] + public string? LastName { get; set; } +} + +/// +/// Payload for submitting a new entry. Implements to enforce +/// that the supplied offers and attributes are valid for the chosen . +/// +public class CreateEntryRequest : IValidatableObject +{ + [Required(ErrorMessage = "required"), StringLength(260, MinimumLength = 1, ErrorMessage = "length")] + public string Name { get; set; } = string.Empty; + + public ContactPersonRequest? Contact { get; set; } + + [EmailAddress(ErrorMessage = "malformed_email"), StringLength(320, MinimumLength = 5, ErrorMessage = "length")] + public string? Email { get; set; } + + [Url(ErrorMessage = "malformed_url"), StringLength(500, MinimumLength = 5, ErrorMessage = "length")] + public string? Website { get; set; } + + [StringLength(30, MinimumLength = 5, ErrorMessage = "length")] + public string? Telephone { get; set; } + + [Required(ErrorMessage = "required")] + [EnumDataType(typeof(EEntryType), ErrorMessage = "invalid_value")] + public EEntryType Type { get; set; } + + public bool? Accessible { get; set; } + + [Required(ErrorMessage = "required")] + public AddressRequest Address { get; set; } = new(); + + public List Offers { get; set; } = []; + public List Attributes { get; set; } = []; + + [StringLength(280, ErrorMessage = "length")] + public string? Specials { get; set; } + + [EnumDataType(typeof(TherapistSubject), ErrorMessage = "invalid_value")] + public TherapistSubject? Subject { get; set; } + + public IEnumerable Validate(ValidationContext validationContext) + { + var validationResults = new List(); + + if (Telephone != null) + { + try + { + var util = PhoneNumberUtil.GetInstance(); + var parsedPhoneNumber = util.Parse(Telephone, "DE"); + if (util.IsValidNumber(parsedPhoneNumber)) + { + this.Telephone = util.Format(parsedPhoneNumber, PhoneNumberFormat.INTERNATIONAL); + } + else + { + validationResults.Add(new ValidationResult("invalid_telephone_number", ["telephone"])); + } + } + catch (Exception e) + { + validationResults.Add(new ValidationResult("invalid_format", ["telephone"])); + } + } + + + var allowedOffers = EntrySchema.GetAllowedOffers(Type); + if (Offers.Any(o => !allowedOffers.Contains(o))) + { + validationResults.Add(new ValidationResult("value_not_allowed", ["offers"])); + } + + var allowedAttributes = EntrySchema.GetAllowedAttributes(Type); + if (Attributes.Any(o => !allowedAttributes.Contains(o))) + { + validationResults.Add(new ValidationResult("value_not_allowed", ["attributes"])); + } + + if (Subject != null && Type != EEntryType.Therapist) + { + validationResults.Add(new ValidationResult("value_not_allowed", ["subject"])); + } + + return validationResults; + } +} diff --git a/Models/Request/EditEntryRequest.cs b/Models/Request/EditEntryRequest.cs new file mode 100644 index 0000000..a51b44f --- /dev/null +++ b/Models/Request/EditEntryRequest.cs @@ -0,0 +1,65 @@ +using System.ComponentModel.DataAnnotations; +using transdb_backend_net.Models.Database; + +namespace transdb_backend_net.Models.Request; + +/// Carries the result of applying an edit request, indicating whether downstream side-effects are needed. +public record EditEntryRequestApplyResult(bool IsAddressChanged); + +/// +/// Extends with status flags and a mandatory comment for auditing. +/// Used by admins to fully replace an entry's content while preserving its identity. +/// +public class EditEntryRequest : CreateEntryRequest +{ + public EntryStatus Status { get; set; } = new(); + + public bool? RemoveDuplication { get; set; } + + [Required(ErrorMessage = "required"), StringLength(2000, MinimumLength = 1, ErrorMessage = "length")] + public string Comment { get; set; } = string.Empty; + + /// Applies all request fields onto an existing entry, preserving identity and audit metadata. + public EditEntryRequestApplyResult ApplyTo(Entry entry) + { + entry.Type = Type; + entry.Name = Name; + entry.Contact = Contact != null ? new ContactPerson(Contact) : null; + entry.Email = Email; + entry.Telephone = Telephone; + entry.Website = Website; + entry.Accessible = Accessible; + entry.Telephone = Telephone; + + entry.Address.City = Address.City; + entry.Address.Plz = Address.Plz; + entry.Address.Street = Address.Street; + entry.Address.House = Address.House; + + entry.Attributes = Attributes; + entry.Offers = Offers; + entry.Specials = Specials; + entry.Subject = Subject; + entry.Status.Approved = Status.Approved; + entry.Status.Blocked = Status.Blocked; + entry.Status.Archived = Status.Archived; + + if (RemoveDuplication == true) + { + entry.PossibleDuplicate = null; + } + + return new EditEntryRequestApplyResult(AddressChanged(entry.Address)); + } + + /// + /// Checks if this request intent to update the address compared to an existing address + /// + /// Address object from database entry + /// if address has been updated + private bool AddressChanged(Address existing) => + existing.City != this.Address.City || + existing.Plz != this.Address.Plz || + existing.Street != this.Address.Street || + existing.House != this.Address.House; +} diff --git a/Models/Request/EntriesFilterRequest.cs b/Models/Request/EntriesFilterRequest.cs new file mode 100644 index 0000000..ecbb74f --- /dev/null +++ b/Models/Request/EntriesFilterRequest.cs @@ -0,0 +1,83 @@ +using System.ComponentModel.DataAnnotations; +using MongoDB.Driver; +using transdb_backend_net.Models.Database; +using transdb_backend_net.Schema; + +namespace transdb_backend_net.Models.Request; + +/// +/// Query parameters for the public entry list endpoint. +/// Translates HTTP query values into a set of MongoDB filter definitions via . +/// +public class EntriesFilterRequest +{ + [EnumDataType(typeof(EEntryType), ErrorMessage = "invalid_value")] + public EEntryType? Type { get; set; } + public string? Text { get; set; } + public string? Location { get; set; } + + public double? Lat { get; set; } + public double? Long { get; set; } + + /// + /// Constructs a from and when both are provided. + /// GeoJSON coordinates are [longitude, latitude] — note the reversed order. + /// + public GeoJsonPoint? GeoLocation + { + get + { + if (this.Lat.HasValue && this.Long.HasValue) + { + return new GeoJsonPoint { Coordinates = [this.Long.Value, this.Lat.Value] }; + } + + return null; + } + } + + public List? Offers { get; set; } + public List? Attributes { get; set; } + public bool? Accessible { get; set; } + public int Page { get; set; } = 0; + + /// + /// Lazily builds and caches the list of MongoDB filter conditions derived from the request properties. + /// Subclasses can inject additional conditions via . + /// Built once on first access; subsequent reads return the cached list. + /// + public List> DatabaseConditions + { + get + { + if (field != null) return field; + + field = new List>(); + + if (Type != null) + field.Add(Builders.Filter.Eq(e => e.Type, Type)); + + if (Accessible.HasValue) + field.Add(Builders.Filter.Eq(e => e.Accessible, Accessible.Value)); + + if (Offers?.Count > 0) + field.Add(Builders.Filter.AnyIn(e => e.Offers, Offers)); + + if (Attributes?.Count > 0) + field.Add(Builders.Filter.AnyIn(e => e.Attributes, Attributes)); + + if (!string.IsNullOrWhiteSpace(Text)) + field.Add(Builders.Filter.Text(Text)); + + AddExtraConditions(field); + + return field; + } + } + + /// + /// Template method for subclasses to append additional filter conditions + /// without reimplementing the shared base logic. + /// + protected virtual void AddExtraConditions(List> conditions) { } +} diff --git a/Models/Request/LoginRequest.cs b/Models/Request/LoginRequest.cs new file mode 100644 index 0000000..0da9c06 --- /dev/null +++ b/Models/Request/LoginRequest.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; + +namespace transdb_backend_net.Models.Request; + +public class LoginRequest +{ + [Required(ErrorMessage = "required"), EmailAddress(ErrorMessage = "malformed_email")] + public string Email { get; set; } = string.Empty; + + [Required(ErrorMessage = "required")] + public string Password { get; set; } = string.Empty; +} diff --git a/Models/Request/PatchEntryStatusRequest.cs b/Models/Request/PatchEntryStatusRequest.cs new file mode 100644 index 0000000..b61ac8a --- /dev/null +++ b/Models/Request/PatchEntryStatusRequest.cs @@ -0,0 +1,48 @@ +using System.ComponentModel.DataAnnotations; +using transdb_backend_net.Models.Database; + +namespace transdb_backend_net.Models.Request; + +public class PatchEntryStatusRequest : CommentedRequest, IValidatableObject +{ + public bool? Approved { get; set; } + public bool? Blocked { get; set; } + public bool? Archived { get; set; } + + public bool? RemoveDuplication { get; set; } + + public void ApplyTo(Entry entry) + { + if (Approved.HasValue) + { + entry.Status.Approved = Approved.Value; + + // remove only on approval if not specifically requested not to + if (RemoveDuplication != false) + { + entry.PossibleDuplicate = null; + } + } + + if (Blocked.HasValue) + { + entry.Status.Blocked = Blocked.Value; + } + + if (Archived.HasValue) + { + entry.Status.Archived = Archived.Value; + } + + if (RemoveDuplication.HasValue && RemoveDuplication == true) + { + entry.PossibleDuplicate = null; + } + } + + public IEnumerable Validate(ValidationContext validationContext) + { + if ((Blocked == true || Archived == true) && string.IsNullOrWhiteSpace(Comment)) + yield return new ValidationResult("required", [nameof(Comment)]); + } +} diff --git a/Models/Request/ReportRequest.cs b/Models/Request/ReportRequest.cs new file mode 100644 index 0000000..4927c76 --- /dev/null +++ b/Models/Request/ReportRequest.cs @@ -0,0 +1,29 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using MongoDB.Bson; + +namespace transdb_backend_net.Models.Request; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ReportType +{ + [JsonStringEnumMemberName("report")] + Report, + [JsonStringEnumMemberName("edit")] + Edit, + [JsonStringEnumMemberName("other")] + Other, +} + +public class ReportRequest +{ + [Required(ErrorMessage = "required")] + public ObjectId Id { get; set; } + + [Required(ErrorMessage = "required")] + [EnumDataType(typeof(ReportType), ErrorMessage = "invalid_value")] + public ReportType Type { get; set; } + + [StringLength(2000, ErrorMessage = "length")] + public string? Message { get; set; } +} diff --git a/Models/Response/EntryResponse.cs b/Models/Response/EntryResponse.cs new file mode 100644 index 0000000..2e55add --- /dev/null +++ b/Models/Response/EntryResponse.cs @@ -0,0 +1,82 @@ +using MongoDB.Bson; +using transdb_backend_net.Models.Database; +using transdb_backend_net.Schema; + +namespace transdb_backend_net.Models.Response; + +/// +/// Projection of an that is safe to expose to unauthenticated users. +/// Strips internal status flags and the duplicate match, keeping only publicly relevant fields. +/// +public class PublicEntryResponse +{ + public ObjectId Id { get; set; } + public EEntryType Type { get; set; } + public string Name { get; set; } = string.Empty; + public ContactPerson? Contact { get; set; } + public string? Email { get; set; } + public string? Telephone { get; set; } + public string? Website { get; set; } + public bool? Accessible { get; set; } + public Address? Address { get; set; } + public List Attributes { get; set; } = []; + public List Offers { get; set; } = []; + public string? Specials { get; set; } + public TherapistSubject? Subject { get; set; } + public double? Distance { get; set; } + + public PublicEntryResponse() { } + + public PublicEntryResponse(Entry entry) + { + Id = entry.Id; + Type = entry.Type; + Name = entry.Name; + Contact = entry.Contact; + Email = entry.Email; + Telephone = entry.Telephone; + Website = entry.Website; + Accessible = entry.Accessible; + Address = entry.Address; + Attributes = entry.Attributes; + Offers = entry.Offers; + Specials = entry.Specials; + Subject = entry.Subject; + Distance = (entry as EntryWithDistance)?.Distance; + } +} + +/// Response returned after a successful entry submission, including the revocation token and any detected duplicate. +public class CreateEntryResponse +{ + public PublicEntryResponse Entry { get; set; } + public string RevocationToken { get; set; } + public DuplicateMatch? PossibleDuplicate { get; set; } + + public CreateEntryResponse(Entry entry, string revocationToken, DuplicateMatch? possibleDuplicate) + { + Entry = new PublicEntryResponse(entry); + RevocationToken = revocationToken; + PossibleDuplicate = possibleDuplicate; + } +} + +/// +/// Generic paginated response wrapper for entry list endpoints. +/// is true when the returned page is full, signalling that another page likely exists. +/// +public class PaginatedEntryResponse +{ + public List Entries { get; set; } = []; + /// Indicates that at least one more page of results may be available. + public bool More { get; set; } + public string? LocationName { get; set; } + + public PaginatedEntryResponse(List entries, string? locationName, int itemsPerPage) + { + this.Entries = entries; + // If the page is exactly full there may be more results, if its short, this was the last page. + this.More = entries.Count >= itemsPerPage; + this.LocationName = locationName; + } +} diff --git a/Models/Response/LoginResponse.cs b/Models/Response/LoginResponse.cs new file mode 100644 index 0000000..5245821 --- /dev/null +++ b/Models/Response/LoginResponse.cs @@ -0,0 +1,8 @@ +namespace transdb_backend_net.Models.Response; + +public class LoginResponse +{ + public string Id { get; set; } = string.Empty; + public string Username { get; set; } = string.Empty; + public bool Admin { get; set; } +} diff --git a/Models/Response/UserResponse.cs b/Models/Response/UserResponse.cs new file mode 100644 index 0000000..8efc649 --- /dev/null +++ b/Models/Response/UserResponse.cs @@ -0,0 +1,9 @@ +using transdb_backend_net.Services; + +namespace transdb_backend_net.Models.Response; + +public class UserResponse(DirectusUser user) +{ + public string Id { get; set; } = user.Id; + public string Name => user.FirstName + (user.LastName != null ? " " + user.LastName : ""); +} \ No newline at end of file diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..14d0131 --- /dev/null +++ b/Program.cs @@ -0,0 +1,133 @@ +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.AspNetCore.RateLimiting; +using transdb_backend_net.Models.Config; +using transdb_backend_net.Services; +using transdb_backend_net.Setup; +using transdb_backend_net.Utils; + +var builder = WebApplication.CreateBuilder(args); + +builder.Logging.ClearProviders(); +builder.Logging.AddSimpleConsole(options => +{ + options.TimestampFormat = "[dd.MM HH:mm:ss] "; + options.SingleLine = true; +}); + +builder.Services.AddControllers().AddJsonOptions(options => +{ + options.JsonSerializerOptions.AllowOutOfOrderMetadataProperties = true; + options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); + options.JsonSerializerOptions.Converters.Add(new ObjectIdJsonConverter()); +}); +builder.Services.ConfigureOptions(); +builder.Services.ConfigureOptions(); + +builder.Services.AddOpenApi(); +builder.Services.AddMongoDb(builder.Configuration); +builder.Services.AddApplicationHttpClients(builder.Configuration); + +// Services +builder.Services.AddMemoryCache(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// Cookie authentication (session-only, no persistent cookie) +builder.Services.ConfigureOptions(); +builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(); + +builder.Services.AddAuthorization(options => +{ + options.AddPolicy("AdminOnly", policy => + { + policy.RequireAuthenticatedUser(); + policy.RequireClaim("isAdmin", "true"); + }); +}); + +// Rate limiting +var rateLimiterConfig = builder.Configuration.GetSection(RateLimiterConfig.ConfigKey) + .Get() ?? new RateLimiterConfig(); + +builder.Services.AddRateLimiter(options => +{ + options.AddSlidingWindowLimiter("newEntry", opt => + { + opt.Window = rateLimiterConfig.NewEntry.Window; + opt.PermitLimit = rateLimiterConfig.NewEntry.PermitLimit; + opt.SegmentsPerWindow = 4; + opt.QueueLimit = 0; + }); + options.AddFixedWindowLimiter("report", opt => + { + opt.Window = rateLimiterConfig.Report.Window; + opt.PermitLimit = rateLimiterConfig.Report.PermitLimit; + opt.QueueLimit = 0; + }); + options.AddSlidingWindowLimiter("login", opt => + { + opt.Window = rateLimiterConfig.Login.Window; + opt.PermitLimit = rateLimiterConfig.Login.PermitLimit; + opt.SegmentsPerWindow = 4; + opt.QueueLimit = 0; + }); + options.AddSlidingWindowLimiter("revoke", opt => + { + opt.Window = rateLimiterConfig.Revoke.Window; + opt.PermitLimit = rateLimiterConfig.Revoke.PermitLimit; + opt.SegmentsPerWindow = 4; + opt.QueueLimit = 0; + }); + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; +}); + +// CORS +var corsConfig = builder.Configuration.GetSection(CorsConfig.ConfigKey).Get() ?? new CorsConfig(); +builder.Services.AddCors(options => +{ + options.AddDefaultPolicy(policy => + { + policy.WithOrigins(corsConfig.DevOrigins); + policy.AllowCredentials(); + policy.AllowAnyHeader(); + policy.AllowAnyMethod(); + }); + options.AddPolicy("prod", policy => + { + policy.WithOrigins("https://transdb.de", "https://www.transdb.de"); + policy.AllowCredentials(); + policy.AllowAnyHeader(); + policy.AllowAnyMethod(); + }); +}); + +// Reverse proxy headers +builder.Services.Configure(options => +{ + options.ForwardedHeaders = ForwardedHeaders.All; + options.KnownIPNetworks.Clear(); + options.KnownProxies.Clear(); +}); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); + app.UseCors(); +} +else +{ + app.UseForwardedHeaders(); + app.UseCors("prod"); +} + +app.UseRouting(); +app.UseAuthentication(); +app.UseAuthorization(); +app.UseRateLimiter(); +app.MapControllers(); + +app.Run(); diff --git a/README.md b/README.md new file mode 100644 index 0000000..9794d2e --- /dev/null +++ b/README.md @@ -0,0 +1,93 @@ +# TransDB Backend & API + +Das Backend für die Trans\*DB Website. Es ist für alles rund um die Datenverarbeitung der Einträge auf der Seite verantwortlich. + +> Disclaimer: Dies ist der neuere .NET Rewrite des Backends, welches früher auf TypeScript basierte. +> Für die Migration wurde in Teilen generatives Machine-Learning zur Unterstützung eingesetzt. +> Der gesamte Code wurde manuell von einer menschlichen Software-Entwicklerin überprüft und verifiziert. + +## Contribution +Trans\*DB gilt als Source-Available, alle Rechte liegen bei den ursprünglichen Entwicklern. + +Der Grundgedanke von Trans\*DB ist es, Hilfe möglichst einfach, schnell und zentralisiert zu vermitteln. +Früher musste man in Selbsthilfegruppen nach einzelnen Listen fragen oder selbst im Internet recherchieren. +Wenn jetzt die Software von anderen einfach wiederverwendet wird, um eigene Seiten zu hosten, sind wir ganz schnell wieder bei dem ursprünglichen Problem, das hiermit zu lösen versucht wurde. + +Wir bitten darum, das zu respektieren. + +Wenn ihr trotzdem etwas technisches beitragen möchtet, eröffnet gerne ein Issue und wir arbeiten gemeinsam eine sinnvolle Implementierung aus. +Bitte nicht einfach ungefragt und unabgesprochen Pull-Requests schicken. + +## Dependencies + +- [ASP.NET Core](https://learn.microsoft.com/aspnet/core) Web-Framework für die API. +- [MongoDB.Driver](https://www.mongodb.com/docs/drivers/csharp/) Offizieller C#-Treiber für MongoDB. +- [libphonenumber-csharp](https://github.com/twcclegg/libphonenumber-csharp) Normalisierung von Telefonnummern ins internationale Format. + +## Geocoding +Um Geocoding zu ermöglichen, verwendet Trans\*DB zwei verschiedene Services. +1. **OpenStreetMaps Nominatim** um einmalig am Eintrag eine Adresse in Koordinaten umzuwandeln- +2. **TransDBGeocoding** ist ein eigener, selbst gehosteter Service der es ermöglicht, die Suchanfragen nach Postleitzahlen, Orten oder User-Standortdaten zu verarbeiten. Dies wird benötigt, um Einträge in der Suche entsprechend filtern/sortieren zu können. + +## Einrichtung + +### Voraussetzungen +- .NET 10 SDK +- laufende MongoDB-Instanz +- Directus CMS (cms.transdb.de) +- Cap-Instanz (Optional) +- TransDBGeocoding Service + +1. Repo clonen. +2. `appsettings.json` mit den eigenen Werten befüllen (MongoDB, CMS, Geocoding, Nominatim, Captcha). +3. `dotnet run` ausführen. + +### Konfiguration (`appsettings.json`) + +```json +{ + "MongoDB": { + "ConnectionUri": "mongodb://localhost:27017/transdb" + }, + "Cms": { + "Url": "https://cms.transdb.de", + "AccessToken": "", + "TicketCollection": "transdb_tickets" + }, + "Geocoding": { + "Url": "https://geo.transdb.de", + "ApiKey": "" + }, + "Nominatim": { + "Url": "https://nominatim.openstreetmap.org", + "UserAgent": "transdb.de/2.0.0 (ASP.NET)" + }, + "Captcha": { + "InstanceUrl": "https://cap.transdb.de", + "SiteKey": "", + "Secret": "", + "Enabled": true + } +} +``` + +Für die lokale Entwicklung legt eine `appsettings.Development.json` mit `"Captcha": { "Enabled": false }` die CAPTCHA-Prüfung still. + +## Authentifizierung + +Admins loggen sich über `POST /auth/login` ein. Das Backend validiert die Credentials gegen Directus, prüft den management_users-Eintrag und setzt einen Session-Cookie (nicht persistent, läuft mit dem Browser-Tab ab). + +## CAPTCHA-Schutz + +Die folgenden Endpunkte erfordern einen gültigen [Cap](https://trycap.dev)-Token im `X-Cap-Token`-Header: + +- `GET /entries` +- `POST /entries` +- `POST /auth/login` +- `POST /report` + +### Warum ist der öffentliche Endpunkt zum Abrufen von Einträgen mit Captchas versehen? +Die automatisierte Verarbeitung der Daten ist nicht erwünscht. +Wir wurden schon mal gefragt, ob man die Daten bereitstellen könne, damit Leute ihre Suche nach zb. Therapeuten automatisieren können. +Wenn das jeder tun würde, dann würden wir unzählige Anfragen bekommen, doch bitte aufgrund zu vieler (spam-)Anfragen die Praxen aus unserer Liste zu entfernen. +Damit ist im Endeffekt niemandem geholfen. \ No newline at end of file diff --git a/Schema/EntryEnums.cs b/Schema/EntryEnums.cs new file mode 100644 index 0000000..bfba877 --- /dev/null +++ b/Schema/EntryEnums.cs @@ -0,0 +1,115 @@ +namespace transdb_backend_net.Schema; + +/// Category of a trans-relevant resource. Determines which offers and attributes are valid. +public enum EEntryType +{ + Group, + Therapist, + Endocrinologist, + Surgeon, + Logopedics, + Hairremoval, + Urologist, + Gynecologist, + GP, + Pharmacy, + Cryo, +} + +/// Specific service or procedure an entry offers. Valid values per type are defined in . +public enum EEntryOffer +{ + // Surgeon + Mastectomy, + VaginPI, + VaginCombined, + VaginColon, + PPVagin, + Ffs, + Penoid, + Breast, + Hyst, + Orch, + ClitPI, + Bodyfem, + Glottoplasty, + Fms, + + // Hairremoval + Laser, + Ipl, + Electro, + ElectroAE, + + // Therapist + Indication, + Therapy, + + // Urologist, Gynecologist, GP + Hrt, + Medication, + + // Pharmacy + EInjection, + Cpa, + + // Cryo + FreezesSperm, + FreezesEggs, + + // endo + HormoneGel, + HormoneInjections, + HormonePills, + HormonePatches, + Progesterone, + EDPills +} + +/// Descriptive flag for an entry (accessibility, target group, operating mode, etc.). Valid values per type are defined in . +public enum EEntryAttribute +{ + // Group + Trans, + RegularMeetings, + Consulting, + Activities, + + // Shared + Remote, + TreatsEnby, + SelfPayedOnly, + InsurancePay, + + // Hairremoval + Transfriendly, + HasDoctor, + + // Therapist + YouthOnly, + + // Urologist, Gynecologist + TransFem, + TransMasc, + + // Pharmacy + Shipping, + SingleUseVials, + ReuseVial, + Prefilled, +} + +public enum AcademicTitle +{ + Dr, + Prof, + ProfDr, +} + +public enum TherapistSubject +{ + Therapist, + Psychologist, + Naturopath, + Other, +} diff --git a/Schema/EntrySchema.cs b/Schema/EntrySchema.cs new file mode 100644 index 0000000..ef7d77e --- /dev/null +++ b/Schema/EntrySchema.cs @@ -0,0 +1,52 @@ +using static transdb_backend_net.Schema.EEntryAttribute; +using static transdb_backend_net.Schema.EEntryOffer; +using static transdb_backend_net.Schema.EEntryType; + +namespace transdb_backend_net.Schema; + +/// +/// Defines which attributes and offers are valid for each entry type. +/// Acts as an allowlist: anything not listed here is rejected on submission and edit. +/// Keeping the schema centralised here avoids scattering type-specific rules across validators. +/// +public static class EntrySchema +{ + public static readonly IReadOnlyDictionary OffersByType = + new Dictionary + { + [Surgeon] = [Mastectomy, VaginPI, VaginCombined, VaginColon, PPVagin, Ffs, Penoid, Breast, + Hyst, Orch, ClitPI, Bodyfem, Glottoplasty, Fms], + [Hairremoval] = [Laser, Ipl, Electro, ElectroAE], + [Therapist] = [Indication, Therapy], + [Urologist] = [Hrt, Medication], + [Gynecologist] = [Hrt, Medication], + [Endocrinologist] = [Progesterone, HormoneGel, HormoneInjections, HormonePatches, HormonePills, EDPills], + [GP] = [Hrt, Medication], + [Pharmacy] = [EInjection, Cpa], + [Cryo] = [FreezesSperm, FreezesEggs], + }; + + public static readonly IReadOnlyDictionary AttributesByType = + new Dictionary + { + [Group] = [Trans, RegularMeetings, Consulting, Activities, Remote], + [Surgeon] = [SelfPayedOnly, Remote], + [Endocrinologist] = [TreatsEnby, TransFem, TransMasc, Remote], + [Hairremoval] = [InsurancePay, Transfriendly, HasDoctor], + [Therapist] = [SelfPayedOnly, YouthOnly, TreatsEnby, Remote], + [Urologist] = [TreatsEnby, TransFem, TransMasc, Remote], + [Gynecologist] = [TreatsEnby, TransFem, TransMasc, Remote], + [GP] = [TreatsEnby, Remote], + [Logopedics] = [Remote], + [Pharmacy] = [Shipping, SingleUseVials, ReuseVial, Prefilled], + [Cryo] = [InsurancePay], + }; + + /// Returns the allowed attributes for a given type, or an empty array if the type has no restrictions defined. + public static EEntryAttribute[] GetAllowedAttributes(EEntryType type) => + AttributesByType.TryGetValue(type, out var attrs) ? attrs : []; + + /// Returns the allowed offers for a given type, or an empty array if the type has no restrictions defined. + public static EEntryOffer[] GetAllowedOffers(EEntryType type) => + OffersByType.TryGetValue(type, out var offers) ? offers : []; +} diff --git a/Services/AuthService.cs b/Services/AuthService.cs new file mode 100644 index 0000000..22fbfd5 --- /dev/null +++ b/Services/AuthService.cs @@ -0,0 +1,49 @@ +using Microsoft.AspNetCore.Authentication.Cookies; +using System.Security.Claims; +using transdb_backend_net.Models.Request; +using transdb_backend_net.Utils; + +namespace transdb_backend_net.Services; + +public interface IAuthService +{ + /// + /// Authenticates a user against Directus and returns a claims principal for cookie sign-in. + /// The Directus access token is only held in memory during this call and never persisted. + /// + Task> LoginAsync(LoginRequest credentials); +} + +public record LoginResult(ClaimsPrincipal Principal, string Username, bool IsAdmin); + +public class AuthService(ICmsService cms) : IAuthService +{ + /// + public async Task> LoginAsync(LoginRequest credentials) + { + var loginResult = await cms.DirectusLoginAsync(credentials); + if (loginResult.IsFailed) return Result.Failure(loginResult); + + var userResult = await cms.GetCurrentUserAsync(loginResult.Value!); + if (userResult.IsFailed) return Result.Failure(userResult); + + var managementUser = await cms.GetManagementUserAsync(userResult.Value!.Id); + if (managementUser.IsFailed) return Result.Failure(managementUser); + + var isAdmin = managementUser.Value!.Admin; + var user = userResult.Value!; + + var username = user.FirstName + (user.LastName != null ? " " + user.LastName : ""); + + var claims = new List + { + new(ClaimTypes.NameIdentifier, user.Id), + new(ClaimTypes.Name, username), + new("isAdmin", isAdmin ? "true" : "false") + }; + + var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); + + return Result.Success(new LoginResult(new ClaimsPrincipal(identity), username, isAdmin)); + } +} diff --git a/Services/CaptchaService.cs b/Services/CaptchaService.cs new file mode 100644 index 0000000..fd8a5da --- /dev/null +++ b/Services/CaptchaService.cs @@ -0,0 +1,46 @@ +using System.Text.Json; +using Microsoft.Extensions.Options; +using System.Web; +using transdb_backend_net.Models.Config; + +namespace transdb_backend_net.Services; + +public interface ICaptchaService +{ + /// + /// Verifies a Cap CAPTCHA token against the configured Cap instance. + /// Returns false if the token is invalid, expired, or the request fails. + /// + Task VerifyAsync(string token); +} + +public record CaptchaVerifyResult(bool success); + +public class CaptchaService(HttpClient httpClient, IOptions config) : ICaptchaService +{ + private readonly CaptchaConfig _config = config.Value; + + /// + public async Task VerifyAsync(string token) + { + var body = JsonSerializer.Serialize(new { secret = _config.Secret, response = token }); + var content = new StringContent(body, System.Text.Encoding.UTF8, "application/json"); + + try + { + var uriBuilder = new UriBuilder(httpClient.BaseAddress!) + { + Path = string.Join("/", string.Empty, _config.SiteKey, "siteverify") + }; + var response = await httpClient.PostAsync(uriBuilder.Uri, content); + if (!response.IsSuccessStatusCode) return false; + + var json = await response.Content.ReadFromJsonAsync(); + return json is { success: true }; + } + catch + { + return false; + } + } +} diff --git a/Services/CmsService.cs b/Services/CmsService.cs new file mode 100644 index 0000000..7ec8e10 --- /dev/null +++ b/Services/CmsService.cs @@ -0,0 +1,223 @@ +using System.Net.Http.Headers; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Options; +using transdb_backend_net.Exceptions; +using transdb_backend_net.Models.Config; +using transdb_backend_net.Models.Request; +using transdb_backend_net.Utils; + +namespace transdb_backend_net.Services; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum CmsTicketType +{ + [JsonStringEnumMemberName("new-entry")] + NewEntry, + [JsonStringEnumMemberName("report")] + Report, + [JsonStringEnumMemberName("edit")] + Edit, + [JsonStringEnumMemberName("other")] + Other, +} + +public class DirectusUser +{ + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("first_name")] + public string FirstName { get; set; } = string.Empty; + + [JsonPropertyName("last_name")] + public string? LastName { get; set; } +} + +public class DirectusManagementUser +{ + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("directus_user")] + public string DirectusUserId { get; set; } = string.Empty; + + [JsonPropertyName("admin")] + public bool Admin { get; set; } + + [JsonPropertyName("status")] + public string Status { get; set; } = string.Empty; +} + +public interface ICmsService +{ + /// + /// Authenticates against Directus and returns a short-lived access token. + /// + Task> DirectusLoginAsync(LoginRequest credentials); + + /// + /// Fetches the currently authenticated Directus user using the given access token. + /// + Task> GetCurrentUserAsync(string directusToken); + + /// + /// Looks up the management_users entry for the given Directus user ID using the configured admin access token. + /// Returns null if no entry exists. + /// + Task> GetManagementUserAsync(string userId); + + /// Fetches all users from the management_users collection with their Directus user details. + Task>> GetAllUsersAsync(); + + /// + /// Creates a review ticket in Directus CMS for the given entry. + /// Returns the created ticket's ID. + /// + Task> CreateTicketAsync(string title, string? entryId, CmsTicketType type, string? description); +} + +public class CmsService(HttpClient httpClient, IOptions config) : ICmsService +{ + private readonly CmsConfig _config = config.Value; + + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; + private static readonly JsonSerializerOptions CmsPostOptions = new() { Converters = { new JsonStringEnumConverter() } }; + + /// + public async Task> DirectusLoginAsync(LoginRequest credentials) + { + try + { + var response = await httpClient.PostAsJsonAsync("/auth/login", credentials); + if (!response.IsSuccessStatusCode) return Result.Failure($"cms login failed with status code {response.StatusCode}"); + + var json = await response.Content.ReadAsStringAsync(); + var doc = JsonDocument.Parse(json); + var token = doc.RootElement.GetProperty("data").GetProperty("access_token").GetString(); + + if (token == null) + { + return Result.Failure("token missing from response"); + } + + return new Result(token); + } + catch (Exception e) + { + return Result.Failure(e); + } + } + + /// + public async Task> GetCurrentUserAsync(string directusToken) + { + var meUrl = QueryHelpers.AddQueryString("/users/me", "fields", "id,first_name,last_name"); + var request = new HttpRequestMessage(HttpMethod.Get, meUrl); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", directusToken); + + try + { + var response = await httpClient.SendAsync(request); + if (!response.IsSuccessStatusCode) return Result.Failure("cms user request failed"); + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + var user = doc.RootElement.GetProperty("data").Deserialize(JsonOptions); + return new Result(user); + } + catch(Exception e) + { + return Result.Failure(e); + } + } + + /// + public async Task> GetManagementUserAsync(string userId) + { + var managementUrl = QueryHelpers.AddQueryString("/items/management_users", new Dictionary + { + ["fields"] = "id,user,admin,status", + ["filter[user][_eq]"] = userId, + ["limit"] = "1" + }); + var request = new HttpRequestMessage(HttpMethod.Get, managementUrl); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _config.AccessToken); + + try + { + var response = await httpClient.SendAsync(request); + if (!response.IsSuccessStatusCode) return Result.Failure($"cms management user failed with status code {response.StatusCode}"); + + var json = await response.Content.ReadAsStringAsync(); + var doc = JsonDocument.Parse(json); + var data = doc.RootElement.GetProperty("data"); + + if (data.GetArrayLength() == 0) return Result.Failure("cms management user not found"); + var user = data[0].Deserialize(JsonOptions); + return new Result(user); + } + catch(Exception e) + { + return Result.Failure(e); + } + } + + /// + public async Task>> GetAllUsersAsync() + { + var url = QueryHelpers.AddQueryString("/items/management_users", new Dictionary + { + ["fields"] = "user.id,user.first_name,user.last_name", + ["limit"] = "-1" + }); + var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _config.AccessToken); + + try + { + var response = await httpClient.SendAsync(request); + if (!response.IsSuccessStatusCode) + return Result>.Failure($"cms users request failed with status {response.StatusCode}"); + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + var users = doc.RootElement.GetProperty("data") + .EnumerateArray() + .Select(e => e.GetProperty("user").Deserialize(JsonOptions)) + .Where(u => u != null) + .Select(u => u!) + .ToList(); + + return Result>.Success(users); + } + catch (Exception e) + { + return Result>.Failure(e); + } + } + + /// + public async Task> CreateTicketAsync(string title, string? entryId, CmsTicketType type, string? description) + { + try + { + var url = QueryHelpers.AddQueryString($"/items/{_config.TicketCollection}", "fields", "id"); + var response = await httpClient.PostAsJsonAsync(url, new { title, description, type, entry_id = entryId }, CmsPostOptions); + if (!response.IsSuccessStatusCode) + { + return Result.Failure($"cms ticket creation failed with status {response.StatusCode}", EFailureType.Unexpected); + } + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + var id = doc.RootElement.GetProperty("data").GetProperty("id").ToString(); + return Result.Success(id); + } + catch (Exception e) + { + return Result.Failure(e); + } + } +} diff --git a/Services/DatabaseService.cs b/Services/DatabaseService.cs new file mode 100644 index 0000000..e029d88 --- /dev/null +++ b/Services/DatabaseService.cs @@ -0,0 +1,267 @@ +using Microsoft.Extensions.Options; +using MongoDB.Bson; +using MongoDB.Driver; +using transdb_backend_net.Models.Config; +using transdb_backend_net.Models.Database; +using transdb_backend_net.Utils; + +namespace transdb_backend_net.Services; + +public interface IDatabaseService +{ + /// Pings the database and returns true if it is reachable. + Task Healthcheck(); + + /// Inserts a new entry document and returns it with its generated ID. + Task InsertEntryAsync(Entry entry); + + /// Finds an entry by its ID. Returns null if no document is found. + Task GetEntryByIdAsync(ObjectId id); + + /// Finds a publicly visible entry by its ID. Returns null if not found or not visible. + Task GetPublicEntryByIdAsync(ObjectId id); + + /// Replaces an existing entry document fully. Returns true if a document was modified. + Task ReplaceEntryAsync(ObjectId id, Entry entry); + + /// Applies a partial update to an entry. Returns true if a document was modified. + Task UpdateEntryFieldsAsync(ObjectId id, UpdateDefinition update); + + /// Permanently deletes an entry. Returns true if a document was deleted. + Task DeleteEntryAsync(ObjectId id); + + /// Returns a paginated list of entries matching the given filter. + Task> FindEntriesAsync(FilterDefinition filter, int skip, int limit); + + /// + /// Returns a paginated list of entries sorted by distance from + /// using a $geoNear aggregation pipeline. Distance is stored in kilometres. + /// + Task> FindEntriesWithGeoAsync(FilterDefinition filter, GeoJsonPoint location, int skip, int limit); + + /// Inserts a new activity document. + Task InsertActivityAsync(EntryActivity activity); + + /// Returns a paginated list of all activity documents, sorted by descending timestamp. + Task> FindActivitiesAsync(int skip, int limit); + + /// Returns a paginated list of activity documents for a specific entry, sorted by ascending timestamp. + Task> FindActivitiesByEntryAsync(ObjectId entryId, int skip, int limit); + + /// Finds a single activity document by its ID. Returns null if not found. + Task GetActivityByIdAsync(ObjectId id); + + /// + /// Returns entries of the same type that share at least one of: city, PLZ, email, or telephone. + /// Used as a pre-filter before C# duplicate scoring. + /// + Task> GetDuplicateCandidatesAsync(Entry entry); + + /// Inserts a revocation token document. + Task InsertRevocationTokenAsync(EntryRevocationToken token); + + /// Finds a revocation token by its token string. Returns null if not found. + Task FindRevocationTokenAsync(string token); + + /// Deletes a revocation token by its token string. + Task DeleteRevocationTokenAsync(string token); +} + +public class DatabaseService : IDatabaseService +{ + private readonly IMongoDatabase _db; + private readonly IMongoCollection _entries; + private readonly IMongoCollection _activities; + private readonly IMongoCollection _revocationTokens; + + public DatabaseService(IOptions config, ILogger logger) + { + var client = new MongoClient(config.Value.ConnectionUri); + _db = client.GetDatabase(GetDatabaseName(config.Value.ConnectionUri)); + _entries = _db.GetCollection("entries"); + _activities = _db.GetCollection("activities"); + _revocationTokens = _db.GetCollection("revocation_tokens"); + + CreateIndexes(); + logger.LogInformation("MongoDB successfully initialized"); + } + + private void CreateIndexes() + { + var textIndex = Builders.IndexKeys + .Text(e => e.Name) + .Text("contact.firstName") + .Text("contact.lastName") + .Text(e => e.Telephone) + .Text(e => e.Website) + .Text(e => e.Email) + .Text("address.city") + .Text("address.street"); + _entries.Indexes.CreateOne(new CreateIndexModel(textIndex)); + //_entries.Indexes.CreateOne(new CreateIndexModel(Builders.IndexKeys.Text(e => e.Type))); + + var geoIndex = Builders.IndexKeys.Geo2DSphere("location"); + _entries.Indexes.CreateOne(new CreateIndexModel(geoIndex)); + + _activities.Indexes.CreateOne(new CreateIndexModel( + Builders.IndexKeys.Ascending(a => a.EntryId))); + _activities.Indexes.CreateOne(new CreateIndexModel( + Builders.IndexKeys.Descending(a => a.Timestamp))); + + _revocationTokens.Indexes.CreateOne(new CreateIndexModel( + Builders.IndexKeys.Ascending(t => t.Token), + new CreateIndexOptions { Unique = true })); + + _revocationTokens.Indexes.CreateOne(new CreateIndexModel( + Builders.IndexKeys.Ascending(t => t.ExpiresAt), + new CreateIndexOptions { ExpireAfter = TimeSpan.Zero })); + } + + /// + public async Task Healthcheck() + { + var result = await _db.RunCommandAsync((Command)"{ping:1}"); + return result["ok"].AsDouble == 1; + } + + /// + public async Task InsertEntryAsync(Entry entry) + { + await _entries.InsertOneAsync(entry); + return entry; + } + + /// + public async Task GetEntryByIdAsync(ObjectId id) => + await _entries.Find(e => e.Id == id).FirstOrDefaultAsync(); + + public async Task GetPublicEntryByIdAsync(ObjectId id) => + await _entries + .Find(e => e.Id == id && e.Status.Approved && !e.Status.Blocked && !e.Status.Archived) + .FirstOrDefaultAsync(); + + /// + public async Task ReplaceEntryAsync(ObjectId id, Entry entry) + { + var result = await _entries.ReplaceOneAsync(e => e.Id == id, entry); + return result.ModifiedCount > 0; + } + + /// + public async Task UpdateEntryFieldsAsync(ObjectId id, UpdateDefinition update) + { + var result = await _entries.UpdateOneAsync(e => e.Id == id, update); + return result.ModifiedCount > 0; + } + + /// + public async Task DeleteEntryAsync(ObjectId id) + { + var result = await _entries.DeleteOneAsync(e => e.Id == id); + return result.DeletedCount > 0; + } + + /// + public async Task> FindEntriesAsync(FilterDefinition filter, int skip, int limit) + { + return await _entries.Find(filter).Skip(skip).Limit(limit).ToListAsync(); + } + + /// + public async Task> FindEntriesWithGeoAsync(FilterDefinition filter, GeoJsonPoint location, int skip, int limit) + { + var serializer = MongoDB.Bson.Serialization.BsonSerializer.SerializerRegistry.GetSerializer(); + var registry = MongoDB.Bson.Serialization.BsonSerializer.SerializerRegistry; + var matchDoc = filter.Render(new RenderArgs(serializer, registry)); + + var pipeline = new[] + { + new BsonDocument("$geoNear", new BsonDocument + { + { "near", new BsonDocument + { + { "type", "Point" }, + { "coordinates", new BsonArray { location.Coordinates[0], location.Coordinates[1] } } + } + }, + { "distanceField", "distance" }, + { "distanceMultiplier", 0.001 }, + { "spherical", true }, + { "query", matchDoc } + }), + new BsonDocument("$skip", skip), + new BsonDocument("$limit", limit) + }; + + return await _entries.Aggregate(pipeline).ToListAsync(); + } + + /// + public async Task InsertActivityAsync(EntryActivity activity) => + await _activities.InsertOneAsync(activity); + + /// + public async Task> FindActivitiesAsync(int skip, int limit) => + await _activities.Find(FilterDefinition.Empty) + .SortByDescending(a => a.Timestamp) + .Skip(skip) + .Limit(limit) + .ToListAsync(); + + /// + public async Task> FindActivitiesByEntryAsync(ObjectId entryId, int skip, int limit) => + await _activities.Find(a => a.EntryId == entryId) + .SortByDescending(a => a.Timestamp) + .Skip(skip) + .Limit(limit) + .ToListAsync(); + + /// + public async Task GetActivityByIdAsync(ObjectId id) => + await _activities.Find(a => a.Id == id).FirstOrDefaultAsync(); + + /// + public async Task> GetDuplicateCandidatesAsync(Entry entry) => + await _entries.Find(BuildCandidateFilter(entry)).ToListAsync(); + + private static FilterDefinition BuildCandidateFilter(Entry entry) + { + var orConditions = new List> + { + Builders.Filter.Eq(e => e.Address.City, entry.Address.City) + }; + + if (entry.Address.Plz != null) + orConditions.Add(Builders.Filter.Eq(e => e.Address.Plz, entry.Address.Plz)); + + if (entry.Email != null) + orConditions.Add(Builders.Filter.Eq(e => e.Email, entry.Email)); + + if (entry.Telephone != null) + orConditions.Add(Builders.Filter.Eq(e => e.Telephone, entry.Telephone)); + + return Builders.Filter.And( + Builders.Filter.Eq(e => e.Type, entry.Type), + Builders.Filter.Or(orConditions) + ); + } + + /// + public async Task InsertRevocationTokenAsync(EntryRevocationToken token) => + await _revocationTokens.InsertOneAsync(token); + + /// + public async Task FindRevocationTokenAsync(string token) => + await _revocationTokens.Find(t => t.Token == token).FirstOrDefaultAsync(); + + /// + public async Task DeleteRevocationTokenAsync(string token) => + await _revocationTokens.DeleteOneAsync(t => t.Token == token); + + /// Extracts the database name from a MongoDB connection URI. + private static string GetDatabaseName(string uri) + { + var path = new UriBuilder(uri).Path.TrimStart('/'); + return path.Length > 0 ? path : "transdb"; + } +} diff --git a/Services/EntryActivityService.cs b/Services/EntryActivityService.cs new file mode 100644 index 0000000..e63d1ee --- /dev/null +++ b/Services/EntryActivityService.cs @@ -0,0 +1,124 @@ +using Microsoft.Extensions.Options; +using MongoDB.Bson; +using MongoDB.Driver; +using transdb_backend_net.Models.Config; +using transdb_backend_net.Models.Database; +using transdb_backend_net.Utils; + + +namespace transdb_backend_net.Services; + +public interface IEntryActivityService +{ + /// Persists an activity event for an entry. + Task LogAsync(EntryActivity activity); + + /// Compares the existing entry state with the given status changes and logs the appropriate activities. + Task LogStatusChangesAsync(ObjectId entryId, string userId, Entry existing, EntryStatusChange changes); + + /// Returns a paginated list of all activity events across all entries. + Task> GetAllAsync(int page); + + /// Returns a paginated list of activity events for a specific entry. + Task> GetByEntryAsync(ObjectId entryId, int page); + + /// + /// Reverts the effect of a logged activity and logs the appropriate follow-up activity. + /// Returns a failed result if the type cannot be reverted or a referenced entry is missing. + /// + Task RevertAsync(EntryActivity activity, string userId, string comment); +} + +public class EntryActivityService(IDatabaseService db, IOptions config) : IEntryActivityService +{ + private readonly int _itemsPerPage = config.Value.ActivityItemsPerPage; + + /// + public Task LogAsync(EntryActivity activity) => db.InsertActivityAsync(activity); + + /// + public async Task LogStatusChangesAsync(ObjectId entryId, string userId, Entry existing, EntryStatusChange c) + { + if (!existing.Status.Approved && c.Approved == true) + { + await LogAsync(EntryActivity.Approved(entryId, userId)); + } + + if (!existing.Status.Blocked && c.Blocked == true) + { + await LogAsync(EntryActivity.Blocked(entryId, userId, c.Comment)); + } + else if (existing.Status.Blocked && c.Blocked == false) + { + await LogAsync(EntryActivity.Unblocked(entryId, userId, c.Comment)); + } + + if (!existing.Status.Archived && c.Archived == true) + { + await LogAsync(EntryActivity.Archived(entryId, userId, c.Comment)); + } + } + + /// + public Task> GetAllAsync(int page) => + db.FindActivitiesAsync(Math.Max(0, page) * _itemsPerPage, _itemsPerPage); + + /// + public Task> GetByEntryAsync(ObjectId entryId, int page) => + db.FindActivitiesByEntryAsync(entryId, Math.Max(0, page) * _itemsPerPage, _itemsPerPage); + + /// + public async Task RevertAsync(EntryActivity activity, string userId, string comment) + { + switch (activity.Type) + { + case EntryActivityType.DuplicateDetected: + { + var updated = await db.UpdateEntryFieldsAsync( + activity.EntryId, + Builders.Update.Unset(e => e.PossibleDuplicate)); + if (!updated) return Result.Failure("entry not found"); + break; + } + case EntryActivityType.Deleted: + { + if (!activity.Attachments.TryGetValue(EntryActivityAttachment.OriginalEntryState, out var state)) + return Result.Failure("activity has no original state"); + await db.InsertEntryAsync((Entry)state); + await LogAsync(EntryActivity.Restored(activity.EntryId, userId, comment, activity.Id)); + break; + } + case EntryActivityType.Blocked: + { + var updated = await db.UpdateEntryFieldsAsync( + activity.EntryId, + Builders.Update.Set(e => e.Status.Blocked, false)); + if (!updated) return Result.Failure("entry not found"); + await LogAsync(EntryActivity.Unblocked(activity.EntryId, userId, comment)); + break; + } + case EntryActivityType.Unblocked: + { + var updated = await db.UpdateEntryFieldsAsync( + activity.EntryId, + Builders.Update.Set(e => e.Status.Blocked, true)); + if (!updated) return Result.Failure("entry not found"); + await LogAsync(EntryActivity.Blocked(activity.EntryId, userId, comment)); + break; + } + case EntryActivityType.Archived: + { + var updated = await db.UpdateEntryFieldsAsync( + activity.EntryId, + Builders.Update.Set(e => e.Status.Archived, false)); + if (!updated) return Result.Failure("entry not found"); + await LogAsync(EntryActivity.Restored(activity.EntryId, userId, comment, activity.Id)); + break; + } + default: + return Result.Failure($"activity type {activity.Type} cannot be reverted"); + } + + return Result.Ok(); + } +} diff --git a/Services/EntryRevocationService.cs b/Services/EntryRevocationService.cs new file mode 100644 index 0000000..cc43867 --- /dev/null +++ b/Services/EntryRevocationService.cs @@ -0,0 +1,49 @@ +using Microsoft.Extensions.Options; +using MongoDB.Bson; +using System.Security.Cryptography; +using System.Text; +using transdb_backend_net.Models.Config; +using transdb_backend_net.Models.Database; + +namespace transdb_backend_net.Services; + +public interface IEntryRevocationService +{ + /// Generates a secure single-use token for the given entry and persists it. + Task GenerateTokenAsync(ObjectId entryId, string userAgent); + + /// Returns true if the token exists, maps to the given entry, and the user agent matches. + Task ValidateTokenAsync(string token, ObjectId entryId, string userAgent); + + /// Removes the token from the database. + Task InvalidateTokenAsync(string token); +} + +public class EntryRevocationService(IDatabaseService db, IOptions config) : IEntryRevocationService +{ + private readonly TimeSpan _tokenTtl = config.Value.RevocationTokenLifetime; + + /// + public async Task GenerateTokenAsync(ObjectId entryId, string userAgent) + { + var token = new EntryRevocationToken(entryId, userAgent, + DateTime.UtcNow.Add(_tokenTtl)); + await db.InsertRevocationTokenAsync(token); + return token.Token; + } + + /// + public async Task ValidateTokenAsync(string token, ObjectId entryId, string userAgent) + { + var doc = await db.FindRevocationTokenAsync(token); + return doc != null + && doc.EntryId == entryId + && doc.UserAgentHash == HashUserAgent(userAgent); + } + + /// + public Task InvalidateTokenAsync(string token) => db.DeleteRevocationTokenAsync(token); + + public static string HashUserAgent(string userAgent) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(userAgent))).ToLowerInvariant(); +} diff --git a/Services/EntryService.cs b/Services/EntryService.cs new file mode 100644 index 0000000..e25f450 --- /dev/null +++ b/Services/EntryService.cs @@ -0,0 +1,282 @@ +using Microsoft.Extensions.Options; +using MongoDB.Bson; +using MongoDB.Driver; +using transdb_backend_net.Models.Config; +using transdb_backend_net.Models.Database; +using transdb_backend_net.Models.Request; +using transdb_backend_net.Models.Response; +using transdb_backend_net.Utils; + +namespace transdb_backend_net.Services; + +public interface IEntryService +{ + /// Creates a new entry and opens a CMS review ticket. + Task> CreateEntryAsync(CreateEntryRequest request); + + /// + /// Returns a paginated, publicly visible list of approved entries matching the given filters. + /// Supports full-text search, type/offer/attribute filtering, and optional geospatial sorting. + /// + Task> FilterEntriesForPublicUsageAsync(EntriesFilterRequest filter); + + /// + /// Returns a paginated list of entries for admin review. + /// Supports optional filtering by approved/blocked/archived status and full-text search. + /// + Task> GetFullEntriesForElevatedUsageAsync(AdminEntriesFilterRequest filter); + + /// Returns a single entry by its ID. Returns a failed result if not found. + Task> GetEntryByIdAsync(ObjectId id); + + /// Returns a publicly visible entry by its ID. Returns a failed result if not found or not visible. + Task> GetPublicEntryByIdAsync(ObjectId id); + + /// + /// Partially updates an entry's status flags (Approved, Blocked, Archived). + /// On approval, triggers a fire-and-forget geo location update. + /// Returns a failed result if the entry does not exist. + /// + Task> PatchEntryAsync(ObjectId id, PatchEntryStatusRequest request); + + /// + /// Fully replaces an entry with the given data. Preserves timestamps and approval metadata. + /// Triggers a fire-and-forget geo location update only if the address changed. + /// Returns a failed result if the entry does not exist. + /// + Task> EditEntryAsync(ObjectId id, EditEntryRequest request); + + /// + /// Fetches fresh coordinates for an entry from Nominatim and saves them. + /// Returns a failed result if the entry does not exist or geocoding fails. + /// + Task UpdateGeoLocationAsync(ObjectId id); + + /// Permanently deletes an entry. Returns a failed result if the entry does not exist. + Task DeleteEntryAsync(ObjectId id); + +} + +public class EntryService( + IDatabaseService db, + ICmsService cms, + IGeocodingService geocoding, + INominatimService nominatim, + IEntryActivityService activityService, + ILogger logger, + IOptions config) : IEntryService +{ + private readonly int _itemsPerPage = config.Value.ItemsPerPage; + private readonly int _adminItemsPerPage = config.Value.AdminItemsPerPage; + private readonly double _duplicateThreshold = config.Value.DuplicateProbabilityThreshold; + + private static readonly IReadOnlyList DuplicateHints = + [ + new NameHint(), new EmailHint(), new TelephoneHint(), + new WebsiteHint(), new AddressHint(), new ContactHint(), + ]; + + private static double CalculateScore(Entry a, Entry b) => + DuplicateHints.Sum(h => h.GetScore(a, b)); + + private static double CalculateMaxScore(Entry entry) => + DuplicateHints.Sum(h => h.GetMaxWeight(entry)); + + private async Task FindPossibleDuplicateAsync(Entry entry) + { + var candidates = await db.GetDuplicateCandidatesAsync(entry); + var maxScore = CalculateMaxScore(entry); + + DuplicateMatch? best = null; + var bestProbability = _duplicateThreshold; + + foreach (var candidate in candidates) + { + var score = CalculateScore(entry, candidate); + var probability = maxScore > 0 ? score / maxScore : 0; + if (!(probability > bestProbability)) continue; + bestProbability = probability; + best = new DuplicateMatch + { + EntryId = candidate.Id, + Probability = Math.Round(probability, 2) + }; + } + + return best; + } + + /// + public async Task> CreateEntryAsync(CreateEntryRequest request) + { + try + { + var entry = new Entry(request); + + var duplicate = await FindPossibleDuplicateAsync(entry); + if (duplicate != null) + entry.PossibleDuplicate = duplicate; + + var created = await db.InsertEntryAsync(entry); + + //_ = CreateCmsTicketAsync(entry.Name, created.Id.ToString()); + + return Result.Success(created); + } + catch (Exception e) + { + return Result.Failure(e); + } + } + + /// + public async Task> FilterEntriesForPublicUsageAsync(EntriesFilterRequest filter) + { + filter.DatabaseConditions.Add(Builders.Filter.Eq(e => e.Status.Approved, true)); + filter.DatabaseConditions.Add(Builders.Filter.Ne(e => e.Status.Blocked, true)); + filter.DatabaseConditions.Add(Builders.Filter.Eq(e => e.Status.Archived, false)); + + var (entries, locationName) = await FetchFilteredEntriesAsync(filter, _itemsPerPage); + return new PaginatedEntryResponse( + entries.Select(e => new PublicEntryResponse(e)).ToList(), + locationName, + _itemsPerPage); + } + + /// + public async Task> GetFullEntriesForElevatedUsageAsync(AdminEntriesFilterRequest filter) + { + var (entries, locationName) = await FetchFilteredEntriesAsync(filter, _adminItemsPerPage); + return new PaginatedEntryResponse(entries.ToList(), locationName, _adminItemsPerPage); + } + + private async Task<(IEnumerable Entries, string? LocationName)> FetchFilteredEntriesAsync(EntriesFilterRequest query, int limit) + { + var skip = Math.Max(0, query.Page) * limit; + string? locationName = null; + GeoJsonPoint? geoLocation = null; + + if (query.GeoLocation != null) + { + var nameResult = await geocoding.GetLocationNameAsync(query.GeoLocation); + if (nameResult.IsOk) locationName = nameResult.Value; + } + else if (!string.IsNullOrWhiteSpace(query.Location)) + { + var geoResult = await geocoding.SearchByNameAsync(query.Location); + if (geoResult.IsOk && geoResult.Value != null) + { + geoLocation = geoResult.Value.Location; + locationName = geoResult.Value.Name; + } + } + + var combinedFilters = query.DatabaseConditions.Count > 0 + ? Builders.Filter.And(query.DatabaseConditions) + : Builders.Filter.Empty; + + IEnumerable entries; + if (geoLocation != null) + { + entries = await db.FindEntriesWithGeoAsync(combinedFilters, geoLocation, skip, limit); + } + else + { + entries = await db.FindEntriesAsync(combinedFilters, skip, limit); + } + + return (entries, locationName); + } + + /// + public async Task> GetEntryByIdAsync(ObjectId id) + { + var entry = await db.GetEntryByIdAsync(id); + return entry != null + ? Result.Success(entry) + : Result.Failure("entry not found"); + } + + /// + public async Task> GetPublicEntryByIdAsync(ObjectId id) + { + var entry = await db.GetPublicEntryByIdAsync(id); + return entry != null + ? Result.Success(entry) + : Result.Failure("entry not found"); + } + + /// + public async Task> PatchEntryAsync(ObjectId id, PatchEntryStatusRequest request) + { + var existing = await db.GetEntryByIdAsync(id); + if (existing == null) return Result.Failure("entry not found"); + + // do this to copy by value + var wasApproved = (existing.Status is { Approved: true }); + request.ApplyTo(existing); + + var replaced = await db.ReplaceEntryAsync(id, existing); + if (!replaced) return Result.Failure("entry not found"); + + if (!wasApproved && existing.Status is { Approved: true }) + { + _ = UpdateGeoLocationAndLogAsync(id); + } + + return Result.Success(existing); + } + + /// + public async Task> EditEntryAsync(ObjectId id, EditEntryRequest request) + { + var existing = await db.GetEntryByIdAsync(id); + if (existing == null) return Result.Failure("entry not found"); + + var applyResult = request.ApplyTo(existing); + var replaced = await db.ReplaceEntryAsync(id, existing); + if (!replaced) return Result.Failure("entry not found"); + + if (applyResult.IsAddressChanged) _ = UpdateGeoLocationAndLogAsync(id); + + return Result.Success(existing); + } + + /// + public async Task UpdateGeoLocationAsync(ObjectId id) + { + var entry = await db.GetEntryByIdAsync(id); + if (entry == null) return Result.Failure("entry not found"); + + var locationResult = await nominatim.GetCoordinatesAsync(entry.Address); + if (locationResult.IsFailed) return Result.Failure(locationResult); + + var updated = await db.UpdateEntryFieldsAsync(id, Builders.Update.Set(e => e.Location, locationResult.Value)); + return updated ? Result.Ok() : Result.Failure("entry not found"); + } + + /// + public async Task DeleteEntryAsync(ObjectId id) + { + var deleted = await db.DeleteEntryAsync(id); + return deleted ? Result.Ok() : Result.Failure("entry not found"); + } + + private async Task UpdateGeoLocationAndLogAsync(ObjectId id) + { + try + { + var result = await UpdateGeoLocationAsync(id); + if (result.IsFailed) + { + logger.LogWarning("Geocoding failed for entry {Id}: {Details}", id, result.FailureDetails); + await activityService.LogAsync(EntryActivity.GeoLocationFailed(id)); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Unexpected error during geocoding for entry {Id}", id); + await activityService.LogAsync(EntryActivity.GeoLocationFailed(id)); + } + } +} diff --git a/Services/GeocodingService.cs b/Services/GeocodingService.cs new file mode 100644 index 0000000..a156b60 --- /dev/null +++ b/Services/GeocodingService.cs @@ -0,0 +1,77 @@ +using System.Text.Json; +using Microsoft.AspNetCore.WebUtilities; +using transdb_backend_net.Models.Database; +using transdb_backend_net.Utils; + +namespace transdb_backend_net.Services; + +public record GeocodingResult +{ + public string Name { get; set; } = string.Empty; + public GeoJsonPoint Location { get; set; } = new(); +} + +public interface IGeocodingService +{ + /// + /// Searches for a location by name and returns the best match with its coordinates. + /// + Task> SearchByNameAsync(string query); + + /// + /// Reverse-geocodes a coordinate pair to a human-readable location name. + /// + Task> GetLocationNameAsync(GeoJsonPoint geoLocation); +} + +public class GeocodingService(HttpClient httpClient) : IGeocodingService +{ + /// + public async Task> SearchByNameAsync(string query) + { + var url = QueryHelpers.AddQueryString("/geocode", "q", query); + try + { + var results = await httpClient.GetFromJsonAsync(url); + var result = results?.FirstOrDefault(); + + if (result == null) + { + return Result.Failure("geocoding failed"); + } + + return Result.Success(result); + } + catch(Exception e) + { + return Result.Failure(e); + } + } + + /// + public async Task> GetLocationNameAsync(GeoJsonPoint geoLocation) + { + var url = QueryHelpers.AddQueryString("/geocode", new Dictionary + { + ["lat"] = geoLocation.Lat.ToString(System.Globalization.CultureInfo.InvariantCulture), + ["long"] = geoLocation.Lng.ToString(System.Globalization.CultureInfo.InvariantCulture) + }); + + try + { + var results = await httpClient.GetFromJsonAsync(url); + var name = results?.FirstOrDefault()?.Name; + + if (name == null) + { + return Result.Failure("geocoding failed"); + } + + return Result.Success(name); + } + catch(Exception e) + { + return Result.Failure(e); + } + } +} diff --git a/Services/NominatimService.cs b/Services/NominatimService.cs new file mode 100644 index 0000000..d50900e --- /dev/null +++ b/Services/NominatimService.cs @@ -0,0 +1,109 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.WebUtilities; +using transdb_backend_net.Models.Database; +using transdb_backend_net.Utils; + +namespace transdb_backend_net.Services; + +public interface INominatimService +{ + /// + /// Geocodes a postal address to a GeoJSON point using the Nominatim API. + /// Respects the Nominatim fair-use policy of at most one request per second. + /// Returns a failed result if no match is found or the request fails. + /// + Task> GetCoordinatesAsync(Address address); +} + +public class NominatimService(HttpClient httpClient) : INominatimService +{ + // Static so the queue and last-request timestamp are shared across all transient instances + private static readonly SemaphoreSlim _queue = new(1, 1); + private static DateTime _lastRequestAt = DateTime.MinValue; + + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; + + /// + public async Task> GetCoordinatesAsync(Address address) + { + await _queue.WaitAsync(); + try + { + var elapsed = DateTime.UtcNow - _lastRequestAt; + var remaining = TimeSpan.FromMilliseconds(1100) - elapsed; + if (remaining > TimeSpan.Zero) + { + await Task.Delay(remaining); + } + + _lastRequestAt = DateTime.UtcNow; + + var url = BuildSearchUrl(address); + var response = await httpClient.GetAsync(url); + + if (!response.IsSuccessStatusCode) + { + return Result.Failure($"nominatim request failed with status {response.StatusCode}", EFailureType.Unexpected); + } + + var json = await response.Content.ReadAsStringAsync(); + var results = JsonSerializer.Deserialize(json, JsonOptions); + if (results == null || results.Length == 0) + { + return Result.Failure("no coordinates found for address"); + } + + return Result.Success(new GeoJsonPoint + { + Coordinates = + [ + double.Parse(results[0].Lon, System.Globalization.CultureInfo.InvariantCulture), + double.Parse(results[0].Lat, System.Globalization.CultureInfo.InvariantCulture) + ] + }); + } + catch (Exception e) + { + return Result.Failure(e); + } + finally + { + _queue.Release(); + } + } + + /// + /// Builds the Nominatim search query URL from an . + /// + private static string BuildSearchUrl(Address address) + { + var query = new Dictionary + { + ["city"] = address.City, + ["format"] = "json", + ["limit"] = "1" + }; + + if (!string.IsNullOrWhiteSpace(address.Plz)) + { + query["postalcode"] = address.Plz; + } + + if (!string.IsNullOrWhiteSpace(address.Street)) + query["street"] = address.House != null + ? address.Street + " " + address.House + : address.Street; + + return QueryHelpers.AddQueryString("/search", query); + } + + private class NominatimResult + { + [JsonPropertyName("lat")] + public string Lat { get; set; } = string.Empty; + + [JsonPropertyName("lon")] + public string Lon { get; set; } = string.Empty; + } +} diff --git a/Setup/CookieSetup.cs b/Setup/CookieSetup.cs new file mode 100644 index 0000000..3b4da34 --- /dev/null +++ b/Setup/CookieSetup.cs @@ -0,0 +1,33 @@ +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.Extensions.Options; + +namespace transdb_backend_net.Setup; + +/// +/// Configures cookie authentication options via DI so Program.cs can call +/// .AddCookie() without an inline lambda. +/// +public class ConfigureCookieOptions : IConfigureNamedOptions +{ + public void Configure(CookieAuthenticationOptions options) => + Configure(CookieAuthenticationDefaults.AuthenticationScheme, options); + + public void Configure(string? name, CookieAuthenticationOptions options) + { + options.Cookie.Name = "transdb-auth"; + options.Cookie.HttpOnly = true; + options.Cookie.SameSite = SameSiteMode.Strict; + options.Cookie.SecurePolicy = CookieSecurePolicy.Always; + // Return 401/403 JSON responses instead of redirecting to a login page + options.Events.OnRedirectToLogin = ctx => + { + ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; + return Task.CompletedTask; + }; + options.Events.OnRedirectToAccessDenied = ctx => + { + ctx.Response.StatusCode = StatusCodes.Status403Forbidden; + return Task.CompletedTask; + }; + } +} diff --git a/Setup/HttpClientsSetup.cs b/Setup/HttpClientsSetup.cs new file mode 100644 index 0000000..73069c4 --- /dev/null +++ b/Setup/HttpClientsSetup.cs @@ -0,0 +1,43 @@ +using System.Net.Http.Headers; +using transdb_backend_net.Models.Config; +using transdb_backend_net.Services; + +namespace transdb_backend_net.Setup; + +public static class HttpClientsSetup +{ + public static IServiceCollection AddApplicationHttpClients(this IServiceCollection services, IConfiguration configuration) + { + services.Configure(configuration.GetSection(CmsConfig.ConfigKey)); + var cmsConfig = configuration.GetSection(CmsConfig.ConfigKey).Get()!; + services.AddHttpClient(client => + { + client.BaseAddress = new Uri(cmsConfig.Url); + }); + + services.Configure(configuration.GetSection(GeocodingConfig.ConfigKey)); + var geocodingConfig = configuration.GetSection(GeocodingConfig.ConfigKey).Get()!; + services.AddHttpClient(client => + { + client.BaseAddress = new Uri(geocodingConfig.Url); + client.DefaultRequestHeaders.Add("X-API-Key", geocodingConfig.ApiKey); + }); + + services.Configure(configuration.GetSection(NominatimConfig.ConfigKey)); + var nominatimConfig = configuration.GetSection(NominatimConfig.ConfigKey).Get()!; + services.AddHttpClient(client => + { + client.BaseAddress = new Uri(nominatimConfig.Url); + client.DefaultRequestHeaders.Add("User-Agent", nominatimConfig.UserAgent); + }); + + services.Configure(configuration.GetSection(CaptchaConfig.ConfigKey)); + var captchaConfig = configuration.GetSection(CaptchaConfig.ConfigKey).Get()!; + services.AddHttpClient(client => + { + client.BaseAddress = new Uri(captchaConfig.InstanceUrl); + }); + + return services; + } +} diff --git a/Setup/MongoDbSetup.cs b/Setup/MongoDbSetup.cs new file mode 100644 index 0000000..32ec896 --- /dev/null +++ b/Setup/MongoDbSetup.cs @@ -0,0 +1,42 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.Conventions; +using MongoDB.Bson.Serialization.Serializers; +using transdb_backend_net.Models.Config; +using transdb_backend_net.Schema; +using transdb_backend_net.Services; + +namespace transdb_backend_net.Setup; + +public static class MongoDbSetup +{ + public static IServiceCollection AddMongoDb(this IServiceCollection services, IConfiguration configuration) + { + services.Configure(configuration.GetSection(MongoDbConfig.ConfigKey)); + + // camelCase field names, enums as strings (C# member name) + var conventionPack = new ConventionPack + { + new CamelCaseElementNameConvention(), + new EnumRepresentationConvention(BsonType.String) + }; + ConventionRegistry.Register("Conventions", conventionPack, _ => true); + + // EnumRepresentationConvention does not apply to enum elements inside lists + // explicit serialiser registration is required so List etc. store as strings + BsonSerializer.RegisterSerializer(new EnumSerializer(BsonType.String)); + BsonSerializer.RegisterSerializer(new EnumSerializer(BsonType.String)); + + // ObjectSerializer only allows primitives by default, open it up for our own types + // so Dictionary<..., object> attachments in EntryActivity can be round-tripped + BsonSerializer.RegisterSerializer(new ObjectSerializer(type => + ObjectSerializer.DefaultAllowedTypes(type) || + type.FullName!.StartsWith("transdb_backend_net."))); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + return services; + } +} diff --git a/Setup/ValidationSetup.cs b/Setup/ValidationSetup.cs new file mode 100644 index 0000000..347bcd5 --- /dev/null +++ b/Setup/ValidationSetup.cs @@ -0,0 +1,74 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; +using Microsoft.Extensions.Options; +using transdb_backend_net.Exceptions; + +namespace transdb_backend_net.Setup; + +/// +/// Configures MVC model binding to use camelCase JSON property names in validation errors +/// and maps all binding-level error messages to short codes for frontend i18n. +/// +public class ConfigureApiValidationMessages : IConfigureOptions +{ + public void Configure(MvcOptions options) + { + options.ModelMetadataDetailsProviders.Add(new SystemTextJsonValidationMetadataProvider()); + + // Map all model-binding-level errors to short codes + options.ModelBindingMessageProvider.SetValueMustNotBeNullAccessor(_ => "required"); + options.ModelBindingMessageProvider.SetMissingBindRequiredValueAccessor(_ => "required"); + options.ModelBindingMessageProvider.SetValueMustBeANumberAccessor(_ => "format"); + options.ModelBindingMessageProvider.SetAttemptedValueIsInvalidAccessor((_, _) => "format"); + options.ModelBindingMessageProvider.SetNonPropertyValueMustBeANumberAccessor(() => "format"); + options.ModelBindingMessageProvider.SetNonPropertyAttemptedValueIsInvalidAccessor(_ => "format"); + options.ModelBindingMessageProvider.SetUnknownValueIsInvalidAccessor(_ => "format"); + options.ModelBindingMessageProvider.SetNonPropertyUnknownValueIsInvalidAccessor(() => "format"); + options.ModelBindingMessageProvider.SetMissingKeyOrValueAccessor(() => "required"); + options.ModelBindingMessageProvider.SetMissingRequestBodyRequiredValueAccessor(() => "required"); + } +} + +/// +/// Replaces the default 400 ProblemDetails validation response with a structured +/// 422 response using short codes. +/// +public class ConfigureApiValidationBehaviour : IConfigureOptions +{ + public void Configure(ApiBehaviorOptions options) + { + options.InvalidModelStateResponseFactory = BuildValidationResponse; + } + + private static IActionResult BuildValidationResponse(ActionContext ctx) + { + // $. keys = JSON deserialiser errors (invalid type, unknown enum value, etc.) + // non-$. keys with a known code = our DataAnnotations/IValidatableObject errors + // If both are empty the body itself was missing or completely unparseable → 400 + var jsonProblems = GetJsonPathProblems(ctx); + var validationProblems = GetValidationCodeProblems(ctx); + + if (jsonProblems.Count == 0 && validationProblems.Count == 0) + { + return new InvalidRequestApiError("request data could not be parsed"); + } + + return new ValidationApiError([.. jsonProblems, .. validationProblems]); + } + + // Errors on $. keys come from the JSON deserialiser — always "format" + private static List GetJsonPathProblems(ActionContext ctx) => + ctx.ModelState + .Where(kv => kv.Key.StartsWith("$.") && kv.Value?.Errors.Count > 0) + .SelectMany(kv => kv.Value!.Errors.Select(_ => new ValidationProblem(kv.Key, "format"))) + .ToList(); + + // Errors on non-$. keys with a recognised code come from DataAnnotations/IValidatableObject + private static List GetValidationCodeProblems(ActionContext ctx) => + ctx.ModelState + .Where(kv => !kv.Key.StartsWith("$.") && kv.Value?.Errors.Count > 0) + .SelectMany(kv => kv.Value!.Errors + .Select(e => new ValidationProblem(kv.Key, e.ErrorMessage))) + .ToList(); +} diff --git a/Utils/DuplicateHints.cs b/Utils/DuplicateHints.cs new file mode 100644 index 0000000..2baa9a3 --- /dev/null +++ b/Utils/DuplicateHints.cs @@ -0,0 +1,129 @@ +using System.Text.RegularExpressions; +using F23.StringSimilarity; +using transdb_backend_net.Models.Database; + +namespace transdb_backend_net.Utils; + +/// +/// Base class for a single scoring dimension in duplicate entry detection. +/// Each hint contributes a partial score and a maximum possible weight. +/// +/// +/// takes the entry being checked rather than returning a constant +/// because optional fields (email, phone, etc.) must not inflate the denominator when they +/// are absent, otherwise the resulting probability would be artificially deflated. +/// +public abstract class EntryDuplicateHint +{ + /// + /// Returns the maximum score this hint can contribute for the given entry. + /// Returns 0 if the required field is absent and the hint cannot apply. + /// + public abstract double GetMaxWeight(Entry entry); + + /// + /// Returns the actual score contribution when comparing two entries. + /// is the new entry being checked; is the candidate. + /// + public abstract double GetScore(Entry a, Entry b); +} + +/// +/// Scores name similarity using Jaro-Winkler, scaled to a maximum weight of 2.0. +/// Uses a continuous similarity value instead of a binary threshold so that near-matches +/// like abbreviations or slight typos still contribute partial score. +/// +public partial class NameHint : EntryDuplicateHint +{ + public override double GetMaxWeight(Entry _) => 2.0; + + public override double GetScore(Entry a, Entry b) => new JaroWinkler().Similarity(Sanitize(a.Name), Sanitize(b.Name)) * 2.0; + + [GeneratedRegex(@"[^a-z0-9öäüß]+")] + private static partial Regex SanitizeRegex(); + + private string Sanitize(string s) => SanitizeRegex().Replace(s.ToLowerInvariant().Trim(), ""); +} + +/// Scores exact email match. Only contributes if the new entry has an email address. +public class EmailHint : EntryDuplicateHint +{ + public override double GetMaxWeight(Entry a) => a.Email != null ? 1.5 : 0; + + public override double GetScore(Entry a, Entry b) => + a.Email != null && a.Email == b.Email ? 1.5 : 0; +} + +/// Scores exact telephone match. Only contributes if the new entry has a telephone number. +public class TelephoneHint : EntryDuplicateHint +{ + public override double GetMaxWeight(Entry a) => a.Telephone != null ? 1.5 : 0; + + public override double GetScore(Entry a, Entry b) => + a.Telephone != null && a.Telephone == b.Telephone ? 1.5 : 0; +} + +/// +/// Scores website match by comparing origins (scheme + host) rather than full URLs, +/// so that paths and query strings do not cause false negatives. +/// Only contributes if the new entry has a website. +/// +public class WebsiteHint : EntryDuplicateHint +{ + public override double GetMaxWeight(Entry a) => a.Website != null ? 0.5 : 0; + + public override double GetScore(Entry a, Entry b) => + a.Website != null && b.Website != null && GetOrigin(a.Website) == GetOrigin(b.Website) ? 0.5 : 0; + + private static string? GetOrigin(string url) => + Uri.TryCreate(url, UriKind.Absolute, out var uri) ? uri.GetLeftPart(UriPartial.Authority) : null; +} + +/// +/// Scores address similarity across city, postal code, street, and house number. +/// City is always required; the other fields only contribute weight when present on the new entry. +/// +public class AddressHint : EntryDuplicateHint +{ + public override double GetMaxWeight(Entry a) + { + double max = 0.5; // city is always present + if (a.Address.Plz != null) max += 0.5; + if (a.Address.Street != null) max += 0.5; + if (a.Address.House != null) max += 0.25; + return max; + } + + public override double GetScore(Entry a, Entry b) + { + double score = 0; + if (a.Address.City == b.Address.City) score += 0.5; + if (a.Address.Plz != null && a.Address.Plz == b.Address.Plz) score += 0.5; + if (a.Address.Street != null && a.Address.Street == b.Address.Street) score += 0.5; + if (a.Address.House != null && a.Address.House == b.Address.House) score += 0.25; + return score; + } +} + +/// +/// Scores contact person similarity by last and first name. +/// Fields only contribute weight when present on the new entry. +/// +public class ContactHint : EntryDuplicateHint +{ + public override double GetMaxWeight(Entry a) + { + double max = 0; + if (a.Contact?.LastName != null) max += 0.5; + if (a.Contact?.FirstName != null) max += 0.25; + return max; + } + + public override double GetScore(Entry a, Entry b) + { + double score = 0; + if (a.Contact?.LastName != null && a.Contact.LastName == b.Contact?.LastName) score += 0.5; + if (a.Contact?.FirstName != null && a.Contact.FirstName == b.Contact?.FirstName) score += 0.25; + return score; + } +} diff --git a/Utils/ObjectIdJsonConverter.cs b/Utils/ObjectIdJsonConverter.cs new file mode 100644 index 0000000..5b5e28f --- /dev/null +++ b/Utils/ObjectIdJsonConverter.cs @@ -0,0 +1,14 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using MongoDB.Bson; + +namespace transdb_backend_net.Utils; + +public class ObjectIdJsonConverter : JsonConverter +{ + public override ObjectId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + ObjectId.TryParse(reader.GetString(), out var id) ? id : throw new JsonException("Invalid ObjectId"); + + public override void Write(Utf8JsonWriter writer, ObjectId value, JsonSerializerOptions options) => + writer.WriteStringValue(value.ToString()); +} diff --git a/Utils/Result.cs b/Utils/Result.cs new file mode 100644 index 0000000..5f765e7 --- /dev/null +++ b/Utils/Result.cs @@ -0,0 +1,75 @@ +using transdb_backend_net.Exceptions; + +namespace transdb_backend_net.Utils; + +/// +/// Distinguishes between expected business-logic failures (e.g. "entry not found") and +/// unexpected technical failures (e.g. an exception from an external service). +/// Controllers use this to decide which HTTP error to return. +/// +public enum EFailureType +{ + None, + Expected, + Unexpected +} + +/// +/// Represents the outcome of an operation that can either succeed or fail with a reason. +/// Use / as factory methods. +/// +public class Result +{ + public bool IsOk => FailureType == EFailureType.None; + public bool IsFailed => !IsOk; + public string? FailureDetails { get; } + public EFailureType FailureType { get; } + + protected Result(string? details = null, EFailureType type = EFailureType.None) + { + FailureDetails = details; + FailureType = type; + } + + public static Result Ok() => new(); + public static Result Failure(string error, EFailureType type = EFailureType.Expected) => new(error, type); + public static Result Failure(Exception e) => new(e.Message, EFailureType.Unexpected); + + /// Promotes a typed failure into a non-generic result, preserving the failure details. + public static Result Failure(Result r) where T : class => new(r.FailureDetails, r.FailureType); + + /// + /// Returns either or based on the failure type, + /// allowing callers to map business errors to 4xx and technical errors to 5xx responses. + /// + public ApiError SelectApiError(ApiError expected, ApiError unexpected) + { + if (FailureType == EFailureType.Expected) + { + return expected; + } + + return unexpected; + } +} + +/// +/// A that carries a value on success. +/// is constrained to reference types because value types can be +/// returned as-is without wrapping, and nullability then serves as the failure signal. +/// +public class Result : Result where T : class +{ + public T? Value { get; } + + public Result(T? value, string? details = null, EFailureType type = EFailureType.None) + : base(details, type) + { + Value = value; + } + + public static Result Success(T value) => new(value); + public new static Result Failure(string error, EFailureType type = EFailureType.Expected) => new(null, error, type); + public new static Result Failure(Exception e) => new(null, e.Message, EFailureType.Unexpected); + public new static Result Failure(Result r) where TB : class => new(null, r.FailureDetails, r.FailureType); +} diff --git a/appsettings.Development.json b/appsettings.Development.json new file mode 100644 index 0000000..5bcfd6f --- /dev/null +++ b/appsettings.Development.json @@ -0,0 +1,19 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "Captcha": { + "Enabled": false + }, + "Cors": { + "DevOrigins": ["http://localhost:5173"] + }, + "RateLimiter": { + "NewEntry": { "Window": "00:05:00", "PermitLimit": 500 }, + "Report": { "Window": "00:05:00", "PermitLimit": 500 }, + "Login": { "Window": "00:05:00", "PermitLimit": 500 } + } +} diff --git a/appsettings.json b/appsettings.json new file mode 100644 index 0000000..e6f8089 --- /dev/null +++ b/appsettings.json @@ -0,0 +1,36 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "MongoDB": { + "ConnectionUri": "FROM_SECRETS" + }, + "Cms": { + "Url": "https://cms.transdb.de", + "AccessToken": "FROM_SECRETS", + "TicketCollection": "transdb_tickets" + }, + "Geocoding": { + "Url": "https://geo.transdb.de", + "ApiKey": "FROM_SECRETS" + }, + "Nominatim": { + "Url": "https://nominatim.openstreetmap.org", + "UserAgent": "transdb.de/3.0.0 (ASP.NET)" + }, + "Captcha": { + "InstanceUrl": "https://cap.transdb.de", + "SiteKey": "FROM_SECRETS", + "Secret": "FROM_SECRETS", + "Enabled": true + }, + "RateLimiter": { + "NewEntry": { "Window": "00:05:00", "PermitLimit": 5 }, + "Report": { "Window": "00:05:00", "PermitLimit": 5 }, + "Login": { "Window": "00:05:00", "PermitLimit": 5 } + } +} diff --git a/transdb-backend-net.csproj b/transdb-backend-net.csproj new file mode 100644 index 0000000..d60863c --- /dev/null +++ b/transdb-backend-net.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + transdb_backend_net + Linux + ebdb8ffe-c2ad-452e-ae59-6daa76fc03d1 + + + + + + + + + + + + .dockerignore + + + + diff --git a/transdb-backend-net.sln b/transdb-backend-net.sln new file mode 100644 index 0000000..29ae955 --- /dev/null +++ b/transdb-backend-net.sln @@ -0,0 +1,31 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "transdb-backend-net", "transdb-backend-net.csproj", "{762FA409-E3D4-4F04-859C-91A9B9909D5F}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {762FA409-E3D4-4F04-859C-91A9B9909D5F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {762FA409-E3D4-4F04-859C-91A9B9909D5F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {762FA409-E3D4-4F04-859C-91A9B9909D5F}.Debug|x64.ActiveCfg = Debug|Any CPU + {762FA409-E3D4-4F04-859C-91A9B9909D5F}.Debug|x64.Build.0 = Debug|Any CPU + {762FA409-E3D4-4F04-859C-91A9B9909D5F}.Debug|x86.ActiveCfg = Debug|Any CPU + {762FA409-E3D4-4F04-859C-91A9B9909D5F}.Debug|x86.Build.0 = Debug|Any CPU + {762FA409-E3D4-4F04-859C-91A9B9909D5F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {762FA409-E3D4-4F04-859C-91A9B9909D5F}.Release|Any CPU.Build.0 = Release|Any CPU + {762FA409-E3D4-4F04-859C-91A9B9909D5F}.Release|x64.ActiveCfg = Release|Any CPU + {762FA409-E3D4-4F04-859C-91A9B9909D5F}.Release|x64.Build.0 = Release|Any CPU + {762FA409-E3D4-4F04-859C-91A9B9909D5F}.Release|x86.ActiveCfg = Release|Any CPU + {762FA409-E3D4-4F04-859C-91A9B9909D5F}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/transdb-migration/migrate.js b/transdb-migration/migrate.js new file mode 100644 index 0000000..6762d28 --- /dev/null +++ b/transdb-migration/migrate.js @@ -0,0 +1,336 @@ +#!/usr/bin/env node +// Usage: node migrate.js [--output-dir ] [--tickets ] + +const fs = require("fs"); +const path = require("path"); + +// ---------- Helpers ---------- + +function oid(hex) { + return { $oid: hex }; +} + +function date(isoString) { + return { $date: isoString }; +} + +function parseTimestamp(value) { + if (value === null || value === undefined) { + return null; + } + // mongoexport Legacy/Canonical: { "$numberLong": "1700000000000" } + if (typeof value === "object" && value.$numberLong !== undefined) { + return new Date(parseFloat(value.$numberLong)).toISOString(); + } + // mongoexport Relaxed (v2): plain number in Millisekunden + if (typeof value === "number") { + return new Date(value).toISOString(); + } + // ISO-String Fallback + if (typeof value === "string") { + return new Date(value).toISOString(); + } + return null; +} + +function activity(entryId, type, timestamp, userId = null, comment = null) { + const doc = { + entryId: oid(entryId), + type, + timestamp: date(timestamp), + comment + }; + + if (userId !== null) { + doc.userId = userId; + } + + return doc; +} + +// ---------- Mapping tables ---------- + +const typeMap = { + group: "Group", + therapist: "Therapist", + endocrinologist: "Endocrinologist", + surgeon: "Surgeon", + logopedics: "Logopedics", + hairremoval: "Hairremoval", + urologist: "Urologist", + gynecologist: "Gynecologist", + GP: "GP", + pharmacy: "Pharmacy", + cryo: "Cryo", + // surveyor (Gutachter) intentionally omitted — obsolete since SBGG replaced TSG +}; + +const titleMap = { + dr: "Dr", + prof: "Prof", + prof_dr: "ProfDr", +}; + +const attributeMap = { + trans: "Trans", + regularMeetings: "RegularMeetings", + consulting: "Consulting", + activities: "Activities", + remote: "Remote", + enby: "TreatsEnby", + treatsNB: "TreatsEnby", + selfPayedOnly: "SelfPayedOnly", + youthOnly: "YouthOnly", + insurancePay: "InsurancePay", + transfriendly: "Transfriendly", + hasDoctor: "HasDoctor", + transFem: "TransFem", + transMasc: "TransMasc", + shipping: "Shipping", + singleUseVials: "SingleUseVials", + reuseVial: "ReuseVial", + prefilled: "Prefilled", +}; + +const offerMap = { + mastectomy: "Mastectomy", + vaginPI: "VaginPI", + vaginCombined: "VaginCombined", + ffs: "Ffs", + penoid: "Penoid", + breast: "Breast", + hyst: "Hyst", + orch: "Orch", + clitPI: "ClitPI", + bodyfem: "Bodyfem", + glottoplasty: "Glottoplasty", + fms: "Fms", + laser: "Laser", + ipl: "Ipl", + electro: "Electro", + electroAE: "ElectroAE", + indication: "Indication", + therapy: "Therapy", + hrt: "Hrt", + medication: "Medication", + eInjection: "EInjection", + cpa: "Cpa", + freezesSperm: "FreezesSperm", + freezesEggs: "FreezesEggs", +}; + +const subjectMap = { + therapist: "Therapist", + psychologist: "Psychologist", + naturopath: "Naturopath", + other: "Other", +}; + +// ---------- Arguments ---------- + +if (process.argv.length < 3) { + console.error("Usage: node migrate.js [--output-dir ]"); + process.exit(1); +} + +const inputFile = process.argv[2]; +let outputDir = path.dirname(path.resolve(inputFile)); +let ticketsFile = null; + +for (let i = 3; i < process.argv.length - 1; i++) { + if (process.argv[i] === "--output-dir") { + outputDir = process.argv[i + 1]; + } + if (process.argv[i] === "--tickets") { + ticketsFile = process.argv[i + 1]; + } +} + +// ---------- Convert ---------- + +const docs = JSON.parse(fs.readFileSync(inputFile, "utf8")); +const entries = []; +const activities = []; +let warnings = 0; +let skipped = 0; + +for (const doc of docs) { + const entryId = doc._id.$oid; + + // Type + const mappedType = typeMap[doc.type]; + if (!mappedType) { + console.error(`[ERROR] Unknown type "${doc.type}" on entry "${doc.name}", skipping`); + skipped++; + continue; + } + + // Timestamps + const submittedAt = parseTimestamp(doc.submittedTimestamp); + + let approvedAt = null; + let approvedBy = null; + if (doc.approvedTimestamp != null) { + approvedAt = parseTimestamp(doc.approvedTimestamp); + + if (doc.approvedBy?.$oid) { + approvedBy = doc.approvedBy.$oid ?? null; + } else { + approvedBy = doc.approvedBy ?? null; + } + } + + // Accessible: "yes"/"no"/"unknown" -> bool? + const accessibleMap = { yes: true, no: false }; + const accessible = doc.accessible in accessibleMap ? accessibleMap[doc.accessible] : null; + + // Meta + const meta = doc.meta ?? {}; + + const attributes = (meta.attributes ?? []).flatMap((key) => { + if (attributeMap[key]) { + return [attributeMap[key]]; + } + console.error(`[WARN] Unknown attribute '${key}' on entry ${entryId}`); + warnings++; + return []; + }); + + const offers = (meta.offers ?? []).flatMap((key) => { + if (offerMap[key]) { + return [offerMap[key]]; + } + console.error(`[WARN] Unknown offer '${key}' on entry ${entryId}`); + warnings++; + return []; + }); + + // Address + const address = { + city: doc.address.city, + plz: doc.address.plz ?? null, + street: doc.address.street ?? null, + house: doc.address.house ?? null, + }; + + // Location (GeoJSON pass-through) + const location = doc.location + ? { type: "Point", coordinates: doc.location.coordinates } + : null; + + // Contact person (only set if at least one field is present) + const hasContact = doc.academicTitle || doc.firstName || doc.lastName; + const contact = hasContact + ? { + academicTitle: titleMap[doc.academicTitle] ?? null, + firstName: doc.firstName ?? null, + lastName: doc.lastName ?? null, + } + : null; + + // Build entry document + const entry = { + _id: oid(entryId), + status: { + approved: doc.approved ?? false, + blocked: doc.blocked ?? false, + archived: false, + }, + type: mappedType, + name: doc.name, + contact, + email: doc.email ?? null, + telephone: doc.telephone ?? null, + website: doc.website ?? null, + accessible, + address, + location, + attributes, + offers, + specials: meta.specials ?? null, + subject: subjectMap[meta.subject] ?? null, + }; + + if (doc.possibleDuplicate != null) { + const duplicateMatch = { + entryId: oid(doc.possibleDuplicate.$oid), + probability: 0, + }; + entry.possibleDuplicate = duplicateMatch; + activities.push({ + ...activity(entryId, "DuplicateDetected", submittedAt), + attachments: { PossibleDuplicate: duplicateMatch }, + }); + } + + entries.push(entry); + + // Activities + activities.push(activity(entryId, "Submitted", submittedAt)); + + if (approvedAt !== null) { + activities.push(activity(entryId, "Approved", approvedAt, approvedBy)); + } + + if (doc.blocked) { + activities.push(activity(entryId, "Blocked", null, null)); + } + +} + +// ---------- Tickets ---------- + +const reportTypeMap = { + "report": "Report", + "edit": "Edit", + "other": "Other", +}; + +if (ticketsFile) { + const tickets = JSON.parse(fs.readFileSync(ticketsFile, "utf8")); + let ticketActivities = 0; + + for (const ticket of tickets) { + if (ticket.type === "new-entry") { + continue; + } + + if (!ticket.entry_id) { + console.error(`[WARN] Ticket #${ticket.id} ("${ticket.title}") has no entry_id, skipping`); + warnings++; + continue; + } + + const reportType = reportTypeMap[ticket.type]; + if (!reportType) { + console.error(`[WARN] Ticket #${ticket.id} has unknown type "${ticket.type}", skipping`); + warnings++; + continue; + } + + const act = activity(ticket.entry_id, "Reported", ticket.date_created, null, ticket.description); + act.attachments = { + ReportType: reportType, + CmsTicketId: String(ticket.id), + }; + activities.push(act); + ticketActivities++; + } + + console.log(` + ${ticketActivities} ticket activities from ${ticketsFile}`); +} + +// ---------- Write output ---------- + +fs.mkdirSync(outputDir, { recursive: true }); + +const entriesPath = path.join(outputDir, "entries.json"); +const activitiesPath = path.join(outputDir, "activities.json"); +fs.writeFileSync(entriesPath, JSON.stringify(entries, null, 2)); +fs.writeFileSync(activitiesPath, JSON.stringify(activities, null, 2)); + +const warningNote = warnings > 0 ? `, ${warnings} warnings` : ""; +const skippedNote = skipped > 0 ? `, ${skipped} skipped` : ""; +console.log(`Done: ${entries.length} entries, ${activities.length} activities${warningNote}${skippedNote}`); +console.log(` → ${entriesPath}`); +console.log(` → ${activitiesPath}`); From d3c2214bcff28910b39d721806015640bcc803f5 Mon Sep 17 00:00:00 2001 From: Lena Emme <38376566+Feuerhamster@users.noreply.github.com> Date: Sat, 20 Jun 2026 17:21:41 +0200 Subject: [PATCH 03/12] Add github pipeline --- .github/workflows/docker-publish.yml | 51 ++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 .github/workflows/docker-publish.yml diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..91665e7 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,51 @@ +name: Build & Publish Docker Image + +on: + push: + branches: [main] + tags: ["v*"] + +env: + REGISTRY: ghcr.io + IMAGE_NAME: backend-net + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + id-token: write + attestations: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=semver,pattern={{version}} + type=raw,value=latest,enable=${{ github.event_name == 'release' }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + provenance: mode=max \ No newline at end of file From 782f3df691d0bf9b7c75f42b82f1994c2c2f5f7b Mon Sep 17 00:00:00 2001 From: Lena Emme <38376566+Feuerhamster@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:09:33 +0200 Subject: [PATCH 04/12] fix small business logic problems --- Errors/ApiErrors.cs | 2 +- Models/Database/GeoJsonPoint.cs | 6 +++--- Models/Request/EntriesFilterRequest.cs | 4 ++-- Services/EntryService.cs | 7 ++++++- Services/GeocodingService.cs | 2 +- Services/NominatimService.cs | 4 ++-- 6 files changed, 15 insertions(+), 10 deletions(-) diff --git a/Errors/ApiErrors.cs b/Errors/ApiErrors.cs index 6b52267..b84d119 100644 --- a/Errors/ApiErrors.cs +++ b/Errors/ApiErrors.cs @@ -44,7 +44,7 @@ public class CaptchaVerificationError(string? details = null) /// Machine-readable constraint code for frontend i18n (e.g. "required", "length", "email"). public partial class ValidationProblem(string property, string code) { - public string Property { get; set; } + public string Property { get; set; } = property; public string Code { get; set; } = code; } diff --git a/Models/Database/GeoJsonPoint.cs b/Models/Database/GeoJsonPoint.cs index 38c67e6..772d66a 100644 --- a/Models/Database/GeoJsonPoint.cs +++ b/Models/Database/GeoJsonPoint.cs @@ -8,13 +8,13 @@ public class GeoJsonPoint public string Type { get; set; } = "Point"; // [longitude, latitude] - public double[] Coordinates { get; set; } = []; + public decimal[] Coordinates { get; set; } = []; [BsonIgnore] [JsonIgnore] - public double Lat => Coordinates[1]; + public decimal Lat => Coordinates[1]; [BsonIgnore] [JsonIgnore] - public double Lng => Coordinates[0]; + public decimal Lng => Coordinates[0]; } diff --git a/Models/Request/EntriesFilterRequest.cs b/Models/Request/EntriesFilterRequest.cs index ecbb74f..030a3a2 100644 --- a/Models/Request/EntriesFilterRequest.cs +++ b/Models/Request/EntriesFilterRequest.cs @@ -16,8 +16,8 @@ public class EntriesFilterRequest public string? Text { get; set; } public string? Location { get; set; } - public double? Lat { get; set; } - public double? Long { get; set; } + public decimal? Lat { get; set; } + public decimal? Long { get; set; } /// /// Constructs a from and when both are provided. diff --git a/Services/EntryService.cs b/Services/EntryService.cs index e25f450..3049576 100644 --- a/Services/EntryService.cs +++ b/Services/EntryService.cs @@ -159,7 +159,12 @@ public async Task> GetFullEntriesForElevatedUsageA if (query.GeoLocation != null) { var nameResult = await geocoding.GetLocationNameAsync(query.GeoLocation); - if (nameResult.IsOk) locationName = nameResult.Value; + if (nameResult.IsOk) + { + locationName = nameResult.Value; + } + + geoLocation = query.GeoLocation; } else if (!string.IsNullOrWhiteSpace(query.Location)) { diff --git a/Services/GeocodingService.cs b/Services/GeocodingService.cs index a156b60..d6869ca 100644 --- a/Services/GeocodingService.cs +++ b/Services/GeocodingService.cs @@ -54,7 +54,7 @@ public async Task> GetLocationNameAsync(GeoJsonPoint geoLocation) var url = QueryHelpers.AddQueryString("/geocode", new Dictionary { ["lat"] = geoLocation.Lat.ToString(System.Globalization.CultureInfo.InvariantCulture), - ["long"] = geoLocation.Lng.ToString(System.Globalization.CultureInfo.InvariantCulture) + ["lon"] = geoLocation.Lng.ToString(System.Globalization.CultureInfo.InvariantCulture) }); try diff --git a/Services/NominatimService.cs b/Services/NominatimService.cs index d50900e..a4e5125 100644 --- a/Services/NominatimService.cs +++ b/Services/NominatimService.cs @@ -58,8 +58,8 @@ public async Task> GetCoordinatesAsync(Address address) { Coordinates = [ - double.Parse(results[0].Lon, System.Globalization.CultureInfo.InvariantCulture), - double.Parse(results[0].Lat, System.Globalization.CultureInfo.InvariantCulture) + decimal.Parse(results[0].Lon, System.Globalization.CultureInfo.InvariantCulture), + decimal.Parse(results[0].Lat, System.Globalization.CultureInfo.InvariantCulture) ] }); } From b48f4b4e2b0d559a46dfc0191f69c0ed70d05f23 Mon Sep 17 00:00:00 2001 From: Lena Emme <38376566+Feuerhamster@users.noreply.github.com> Date: Tue, 23 Jun 2026 18:47:30 +0200 Subject: [PATCH 05/12] fix migration script to include comments & attatchments properly --- transdb-migration/migrate.js | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/transdb-migration/migrate.js b/transdb-migration/migrate.js index 6762d28..29c2f09 100644 --- a/transdb-migration/migrate.js +++ b/transdb-migration/migrate.js @@ -37,17 +37,25 @@ function activity(entryId, type, timestamp, userId = null, comment = null) { const doc = { entryId: oid(entryId), type, - timestamp: date(timestamp), - comment + timestamp: timestamp !== null ? date(timestamp) : null, }; if (userId !== null) { doc.userId = userId; } + if (comment !== null) { + doc.comment = comment; + } return doc; } +// Converts a plain object to MongoDB ArrayOfArrays format as required by +// BsonDictionaryOptions(DictionaryRepresentation.ArrayOfArrays) in EntryActivity. +function attachments(obj) { + return Object.entries(obj); +} + // ---------- Mapping tables ---------- const typeMap = { @@ -259,7 +267,7 @@ for (const doc of docs) { entry.possibleDuplicate = duplicateMatch; activities.push({ ...activity(entryId, "DuplicateDetected", submittedAt), - attachments: { PossibleDuplicate: duplicateMatch }, + attachments: attachments({ PossibleDuplicate: duplicateMatch }), }); } @@ -309,10 +317,10 @@ if (ticketsFile) { } const act = activity(ticket.entry_id, "Reported", ticket.date_created, null, ticket.description); - act.attachments = { + act.attachments = attachments({ ReportType: reportType, CmsTicketId: String(ticket.id), - }; + }); activities.push(act); ticketActivities++; } From 820db641fffc2047b40eca27bdf0b362bb5fadd0 Mon Sep 17 00:00:00 2001 From: Lena Emme <38376566+Feuerhamster@users.noreply.github.com> Date: Tue, 23 Jun 2026 19:12:03 +0200 Subject: [PATCH 06/12] add launch settings --- .gitignore | 1 - Properties/launchSettings.json | 14 ++++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 Properties/launchSettings.json diff --git a/.gitignore b/.gitignore index 4d387a3..145a90a 100644 --- a/.gitignore +++ b/.gitignore @@ -6,5 +6,4 @@ riderModule.iml /transdb-migration/* !/transdb-migration/migrate.js .idea -Properties *.DotSettings.user \ No newline at end of file diff --git a/Properties/launchSettings.json b/Properties/launchSettings.json new file mode 100644 index 0000000..2205386 --- /dev/null +++ b/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5018", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} From 342352c791cb40e704aa8675a69d74f98dc25a37 Mon Sep 17 00:00:00 2001 From: Lena Emme <38376566+feuerhamster@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:34:05 +0200 Subject: [PATCH 07/12] rename admin controller and fix comment on patch --- ...{AdminEntriesController.cs => ManageEntriesController.cs} | 4 ++-- Models/Request/PatchEntryStatusRequest.cs | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) rename Controllers/{AdminEntriesController.cs => ManageEntriesController.cs} (98%) diff --git a/Controllers/AdminEntriesController.cs b/Controllers/ManageEntriesController.cs similarity index 98% rename from Controllers/AdminEntriesController.cs rename to Controllers/ManageEntriesController.cs index 443fc43..57b6bcc 100644 --- a/Controllers/AdminEntriesController.cs +++ b/Controllers/ManageEntriesController.cs @@ -13,9 +13,9 @@ namespace transdb_backend_net.Controllers; [ApiController] -[Route("admin/entries")] +[Route("manage/entries")] [Authorize] -public class AdminEntriesController( +public class ManageEntriesController( IEntryService entryService, IEntryActivityService activityService) : ControllerBase { diff --git a/Models/Request/PatchEntryStatusRequest.cs b/Models/Request/PatchEntryStatusRequest.cs index b61ac8a..80bf143 100644 --- a/Models/Request/PatchEntryStatusRequest.cs +++ b/Models/Request/PatchEntryStatusRequest.cs @@ -3,12 +3,15 @@ namespace transdb_backend_net.Models.Request; -public class PatchEntryStatusRequest : CommentedRequest, IValidatableObject +public class PatchEntryStatusRequest : IValidatableObject { public bool? Approved { get; set; } public bool? Blocked { get; set; } public bool? Archived { get; set; } + [StringLength(2000, ErrorMessage = "length")] + public string Comment { get; set; } = string.Empty; + public bool? RemoveDuplication { get; set; } public void ApplyTo(Entry entry) From fc175fe83eb87bdc8a62c16a2c9cc1d889267d48 Mon Sep 17 00:00:00 2001 From: Lena Emme <38376566+feuerhamster@users.noreply.github.com> Date: Thu, 25 Jun 2026 00:28:17 +0200 Subject: [PATCH 08/12] activity log & user fixes --- Controllers/ActivityController.cs | 3 --- Controllers/UsersController.cs | 23 +++++++++++++---------- Models/Database/EntryActivity.cs | 8 ++++---- Models/Request/PatchEntryStatusRequest.cs | 2 +- Models/Response/UserResponse.cs | 18 +++++++++++++++--- Services/EntryActivityService.cs | 4 ++++ Services/EntryService.cs | 6 +++--- Setup/MongoDbSetup.cs | 9 +++++++++ 8 files changed, 49 insertions(+), 24 deletions(-) diff --git a/Controllers/ActivityController.cs b/Controllers/ActivityController.cs index 6fb477d..c5f0ad1 100644 --- a/Controllers/ActivityController.cs +++ b/Controllers/ActivityController.cs @@ -27,9 +27,6 @@ public async Task>> GetAll([FromQuery] int page [HttpGet("entry/{id}")] public async Task>> GetByEntry(ObjectId id, [FromQuery] int page = 0) { - var entryResult = await entryService.GetEntryByIdAsync(id); - if (entryResult.IsFailed) return NotFound(); - var activities = await activityService.GetByEntryAsync(id, page); return Ok(activities); } diff --git a/Controllers/UsersController.cs b/Controllers/UsersController.cs index 7aba402..cf1a40f 100644 --- a/Controllers/UsersController.cs +++ b/Controllers/UsersController.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; using transdb_backend_net.Exceptions; using transdb_backend_net.Models.Response; using transdb_backend_net.Services; @@ -10,27 +11,29 @@ namespace transdb_backend_net.Controllers; [ApiController] [Route("users")] [Authorize] -public class UsersController(ICmsService cms, IMemoryCache cache) : ControllerBase +public class UsersController(ICmsService cms, IMemoryCache cache, IConfiguration config) : ControllerBase { private const string CacheKey = "cms_users"; - [HttpGet("{id}")] - public async Task> GetUser(string id) + [HttpGet()] + public async Task>> GetUsers() { - var users = await GetCachedUsersAsync(); - if (users == null) + var directusUsers = await GetCachedUsersAsync(); + if (directusUsers == null) { return new OperationFailedApiError(); } + + var users = directusUsers.Select(user => new UserResponse(user)).ToList(); - var user = users.FirstOrDefault(u => u.Id == id); + var legacyUsers = config.GetSection("LegacyUsers").Get>(); - if (user == null) + if (legacyUsers != null) { - return new NotFoundApiError("user not found"); + users.AddRange(legacyUsers.Select(u => new UserResponse(u.Key, u.Value)).ToList()); } - - return Ok(new UserResponse(user)); + + return users; } private async Task?> GetCachedUsersAsync() diff --git a/Models/Database/EntryActivity.cs b/Models/Database/EntryActivity.cs index 8fd1627..8e7c75c 100644 --- a/Models/Database/EntryActivity.cs +++ b/Models/Database/EntryActivity.cs @@ -168,17 +168,17 @@ private EntryActivity() { } } }; - public static EntryActivity Restored(ObjectId entryId, string userId, string? comment, ObjectId revertedActivityId) => new() + public static EntryActivity Restored(ObjectId entryId, string userId, string? comment, ObjectId? revertedActivityId = null) => new() { EntryId = entryId, UserId = userId, Comment = comment, Type = EntryActivityType.Restored, Timestamp = DateTime.UtcNow, - Attachments = new Dictionary + Attachments = revertedActivityId.HasValue ? new Dictionary { - [EntryActivityAttachment.RevertedActivityId] = revertedActivityId.ToString() - } + [EntryActivityAttachment.RevertedActivityId] = revertedActivityId + } : new Dictionary() }; /// Logged when an automatic geocoding update fails after an entry is approved or edited. diff --git a/Models/Request/PatchEntryStatusRequest.cs b/Models/Request/PatchEntryStatusRequest.cs index 80bf143..953262e 100644 --- a/Models/Request/PatchEntryStatusRequest.cs +++ b/Models/Request/PatchEntryStatusRequest.cs @@ -10,7 +10,7 @@ public class PatchEntryStatusRequest : IValidatableObject public bool? Archived { get; set; } [StringLength(2000, ErrorMessage = "length")] - public string Comment { get; set; } = string.Empty; + public string? Comment { get; set; } = string.Empty; public bool? RemoveDuplication { get; set; } diff --git a/Models/Response/UserResponse.cs b/Models/Response/UserResponse.cs index 8efc649..c8a30ab 100644 --- a/Models/Response/UserResponse.cs +++ b/Models/Response/UserResponse.cs @@ -2,8 +2,20 @@ namespace transdb_backend_net.Models.Response; -public class UserResponse(DirectusUser user) +public class UserResponse { - public string Id { get; set; } = user.Id; - public string Name => user.FirstName + (user.LastName != null ? " " + user.LastName : ""); + public UserResponse(DirectusUser user) + { + Id = user.Id; + Name = user.FirstName + (user.LastName != null ? " " + user.LastName : ""); + } + + public UserResponse(string id, string name) + { + Id = id; + Name = name; + } + + public string Id { get; set; } + public string Name { get; set; } } \ No newline at end of file diff --git a/Services/EntryActivityService.cs b/Services/EntryActivityService.cs index e63d1ee..5aaa68a 100644 --- a/Services/EntryActivityService.cs +++ b/Services/EntryActivityService.cs @@ -57,6 +57,10 @@ public async Task LogStatusChangesAsync(ObjectId entryId, string userId, Entry e { await LogAsync(EntryActivity.Archived(entryId, userId, c.Comment)); } + else if (existing.Status.Archived && c.Archived == false) + { + await LogAsync(EntryActivity.Restored(entryId, userId, c.Comment, null)); + } } /// diff --git a/Services/EntryService.cs b/Services/EntryService.cs index 3049576..7c97077 100644 --- a/Services/EntryService.cs +++ b/Services/EntryService.cs @@ -224,7 +224,7 @@ public async Task> PatchEntryAsync(ObjectId id, PatchEntryStatusRe var replaced = await db.ReplaceEntryAsync(id, existing); if (!replaced) return Result.Failure("entry not found"); - if (!wasApproved && existing.Status is { Approved: true }) + if (!wasApproved && existing is { Status: { Approved: true }, Location: null }) { _ = UpdateGeoLocationAndLogAsync(id); } @@ -257,7 +257,7 @@ public async Task UpdateGeoLocationAsync(ObjectId id) if (locationResult.IsFailed) return Result.Failure(locationResult); var updated = await db.UpdateEntryFieldsAsync(id, Builders.Update.Set(e => e.Location, locationResult.Value)); - return updated ? Result.Ok() : Result.Failure("entry not found"); + return updated ? Result.Ok() : Result.Failure("entry update failed"); } /// @@ -272,7 +272,7 @@ private async Task UpdateGeoLocationAndLogAsync(ObjectId id) try { var result = await UpdateGeoLocationAsync(id); - if (result.IsFailed) + if (result.IsFailed && result.FailureType == EFailureType.Unexpected) { logger.LogWarning("Geocoding failed for entry {Id}: {Details}", id, result.FailureDetails); await activityService.LogAsync(EntryActivity.GeoLocationFailed(id)); diff --git a/Setup/MongoDbSetup.cs b/Setup/MongoDbSetup.cs index 32ec896..97d2481 100644 --- a/Setup/MongoDbSetup.cs +++ b/Setup/MongoDbSetup.cs @@ -3,6 +3,8 @@ using MongoDB.Bson.Serialization.Conventions; using MongoDB.Bson.Serialization.Serializers; using transdb_backend_net.Models.Config; +using transdb_backend_net.Models.Database; +using transdb_backend_net.Models.Request; using transdb_backend_net.Schema; using transdb_backend_net.Services; @@ -33,6 +35,13 @@ public static IServiceCollection AddMongoDb(this IServiceCollection services, IC ObjectSerializer.DefaultAllowedTypes(type) || type.FullName!.StartsWith("transdb_backend_net."))); + // Explicitly register types that are stored as object values in EntryActivity.Attachments + // so their _t discriminator can be resolved on deserialization even before any write in the process + BsonClassMap.RegisterClassMap(cm => cm.AutoMap()); + BsonClassMap.RegisterClassMap(cm => cm.AutoMap()); + BsonClassMap.RegisterClassMap(cm => cm.AutoMap()); + BsonClassMap.RegisterClassMap(cm => cm.AutoMap()); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); From 057db1e0d9ec191f07428a411400cb20c888fecb Mon Sep 17 00:00:00 2001 From: Lena Emme <38376566+feuerhamster@users.noreply.github.com> Date: Sat, 27 Jun 2026 14:26:58 +0200 Subject: [PATCH 09/12] paging for activities --- Controllers/ActivityController.cs | 5 +++-- Controllers/EntriesController.cs | 2 +- Controllers/ManageEntriesController.cs | 2 +- Models/Response/EntryResponse.cs | 15 ++++++++------- Services/EntryActivityService.cs | 19 +++++++++++++------ Services/EntryService.cs | 16 ++++++++-------- 6 files changed, 34 insertions(+), 25 deletions(-) diff --git a/Controllers/ActivityController.cs b/Controllers/ActivityController.cs index c5f0ad1..4aa2bc4 100644 --- a/Controllers/ActivityController.cs +++ b/Controllers/ActivityController.cs @@ -6,6 +6,7 @@ using transdb_backend_net.Exceptions; using transdb_backend_net.Models.Database; using transdb_backend_net.Models.Request; +using transdb_backend_net.Models.Response; using transdb_backend_net.Services; namespace transdb_backend_net.Controllers; @@ -17,7 +18,7 @@ public class ActivityController(IEntryActivityService activityService, IEntrySer { /// Returns a paginated list of all activity events across all entries. [HttpGet] - public async Task>> GetAll([FromQuery] int page = 0) + public async Task>> GetAll([FromQuery] int page = 0) { var activities = await activityService.GetAllAsync(page); return Ok(activities); @@ -25,7 +26,7 @@ public async Task>> GetAll([FromQuery] int page /// Returns a paginated list of activity events for a specific entry. [HttpGet("entry/{id}")] - public async Task>> GetByEntry(ObjectId id, [FromQuery] int page = 0) + public async Task>> GetByEntry(ObjectId id, [FromQuery] int page = 0) { var activities = await activityService.GetByEntryAsync(id, page); return Ok(activities); diff --git a/Controllers/EntriesController.cs b/Controllers/EntriesController.cs index accfa92..8df7f67 100644 --- a/Controllers/EntriesController.cs +++ b/Controllers/EntriesController.cs @@ -25,7 +25,7 @@ public class EntriesController( /// [HttpGet] [ValidateCaptcha] - public async Task>> Filter([FromQuery] EntriesFilterRequest filter) + public async Task>> Filter([FromQuery] EntriesFilterRequest filter) { var result = await entryService.FilterEntriesForPublicUsageAsync(filter); return Ok(result); diff --git a/Controllers/ManageEntriesController.cs b/Controllers/ManageEntriesController.cs index 57b6bcc..ea4500a 100644 --- a/Controllers/ManageEntriesController.cs +++ b/Controllers/ManageEntriesController.cs @@ -25,7 +25,7 @@ public class ManageEntriesController( /// Returns all entries when no filters are applied. /// [HttpGet] - public async Task>> GetEntries([FromQuery] AdminEntriesFilterRequest filter) + public async Task>> GetEntries([FromQuery] AdminEntriesFilterRequest filter) { var result = await entryService.GetFullEntriesForElevatedUsageAsync(filter); return Ok(result); diff --git a/Models/Response/EntryResponse.cs b/Models/Response/EntryResponse.cs index 2e55add..1237f09 100644 --- a/Models/Response/EntryResponse.cs +++ b/Models/Response/EntryResponse.cs @@ -62,21 +62,22 @@ public CreateEntryResponse(Entry entry, string revocationToken, DuplicateMatch? } /// -/// Generic paginated response wrapper for entry list endpoints. +/// Generic paginated response wrapper. /// is true when the returned page is full, signalling that another page likely exists. /// -public class PaginatedEntryResponse +public class PaginatedResponse { - public List Entries { get; set; } = []; + public List Items { get; set; } = []; /// Indicates that at least one more page of results may be available. public bool More { get; set; } public string? LocationName { get; set; } - public PaginatedEntryResponse(List entries, string? locationName, int itemsPerPage) + public PaginatedResponse(List items, int itemsPerPage, string? locationName = null) { - this.Entries = entries; + Items = items; // If the page is exactly full there may be more results, if its short, this was the last page. - this.More = entries.Count >= itemsPerPage; - this.LocationName = locationName; + More = items.Count >= itemsPerPage; + LocationName = locationName; } } + diff --git a/Services/EntryActivityService.cs b/Services/EntryActivityService.cs index 5aaa68a..a163561 100644 --- a/Services/EntryActivityService.cs +++ b/Services/EntryActivityService.cs @@ -3,6 +3,7 @@ using MongoDB.Driver; using transdb_backend_net.Models.Config; using transdb_backend_net.Models.Database; +using transdb_backend_net.Models.Response; using transdb_backend_net.Utils; @@ -17,10 +18,10 @@ public interface IEntryActivityService Task LogStatusChangesAsync(ObjectId entryId, string userId, Entry existing, EntryStatusChange changes); /// Returns a paginated list of all activity events across all entries. - Task> GetAllAsync(int page); + Task> GetAllAsync(int page); /// Returns a paginated list of activity events for a specific entry. - Task> GetByEntryAsync(ObjectId entryId, int page); + Task> GetByEntryAsync(ObjectId entryId, int page); /// /// Reverts the effect of a logged activity and logs the appropriate follow-up activity. @@ -64,12 +65,18 @@ public async Task LogStatusChangesAsync(ObjectId entryId, string userId, Entry e } /// - public Task> GetAllAsync(int page) => - db.FindActivitiesAsync(Math.Max(0, page) * _itemsPerPage, _itemsPerPage); + public async Task> GetAllAsync(int page) + { + var items = await db.FindActivitiesAsync(Math.Max(0, page) * _itemsPerPage, _itemsPerPage); + return new PaginatedResponse(items, _itemsPerPage); + } /// - public Task> GetByEntryAsync(ObjectId entryId, int page) => - db.FindActivitiesByEntryAsync(entryId, Math.Max(0, page) * _itemsPerPage, _itemsPerPage); + public async Task> GetByEntryAsync(ObjectId entryId, int page) + { + var items = await db.FindActivitiesByEntryAsync(entryId, Math.Max(0, page) * _itemsPerPage, _itemsPerPage); + return new PaginatedResponse(items, _itemsPerPage); + } /// public async Task RevertAsync(EntryActivity activity, string userId, string comment) diff --git a/Services/EntryService.cs b/Services/EntryService.cs index 7c97077..b050952 100644 --- a/Services/EntryService.cs +++ b/Services/EntryService.cs @@ -18,13 +18,13 @@ public interface IEntryService /// Returns a paginated, publicly visible list of approved entries matching the given filters. /// Supports full-text search, type/offer/attribute filtering, and optional geospatial sorting. /// - Task> FilterEntriesForPublicUsageAsync(EntriesFilterRequest filter); + Task> FilterEntriesForPublicUsageAsync(EntriesFilterRequest filter); /// /// Returns a paginated list of entries for admin review. /// Supports optional filtering by approved/blocked/archived status and full-text search. /// - Task> GetFullEntriesForElevatedUsageAsync(AdminEntriesFilterRequest filter); + Task> GetFullEntriesForElevatedUsageAsync(AdminEntriesFilterRequest filter); /// Returns a single entry by its ID. Returns a failed result if not found. Task> GetEntryByIdAsync(ObjectId id); @@ -130,24 +130,24 @@ public async Task> CreateEntryAsync(CreateEntryRequest request) } /// - public async Task> FilterEntriesForPublicUsageAsync(EntriesFilterRequest filter) + public async Task> FilterEntriesForPublicUsageAsync(EntriesFilterRequest filter) { filter.DatabaseConditions.Add(Builders.Filter.Eq(e => e.Status.Approved, true)); filter.DatabaseConditions.Add(Builders.Filter.Ne(e => e.Status.Blocked, true)); filter.DatabaseConditions.Add(Builders.Filter.Eq(e => e.Status.Archived, false)); var (entries, locationName) = await FetchFilteredEntriesAsync(filter, _itemsPerPage); - return new PaginatedEntryResponse( + return new PaginatedResponse( entries.Select(e => new PublicEntryResponse(e)).ToList(), - locationName, - _itemsPerPage); + _itemsPerPage, + locationName); } /// - public async Task> GetFullEntriesForElevatedUsageAsync(AdminEntriesFilterRequest filter) + public async Task> GetFullEntriesForElevatedUsageAsync(AdminEntriesFilterRequest filter) { var (entries, locationName) = await FetchFilteredEntriesAsync(filter, _adminItemsPerPage); - return new PaginatedEntryResponse(entries.ToList(), locationName, _adminItemsPerPage); + return new PaginatedResponse(entries.ToList(), _adminItemsPerPage, locationName); } private async Task<(IEnumerable Entries, string? LocationName)> FetchFilteredEntriesAsync(EntriesFilterRequest query, int limit) From 8ae3c3a18233c877ea64f2ec432d6dfe0dff591b Mon Sep 17 00:00:00 2001 From: Lena Emme <38376566+feuerhamster@users.noreply.github.com> Date: Sat, 27 Jun 2026 17:10:46 +0200 Subject: [PATCH 10/12] add entry names to activities --- Controllers/ActivityController.cs | 5 +++-- Models/Database/EntryActivity.cs | 10 +++++++--- Services/DatabaseService.cs | 24 +++++++++++++++++++++--- Services/EntryActivityService.cs | 13 +++++++++---- 4 files changed, 40 insertions(+), 12 deletions(-) diff --git a/Controllers/ActivityController.cs b/Controllers/ActivityController.cs index 4aa2bc4..2dcf86c 100644 --- a/Controllers/ActivityController.cs +++ b/Controllers/ActivityController.cs @@ -16,9 +16,10 @@ namespace transdb_backend_net.Controllers; [Authorize] public class ActivityController(IEntryActivityService activityService, IEntryService entryService, IDatabaseService databaseService) : ControllerBase { - /// Returns a paginated list of all activity events across all entries. + /// Returns a paginated list of all activity events across all entries, enriched with entry names. [HttpGet] - public async Task>> GetAll([FromQuery] int page = 0) + [Authorize(Policy = "AdminOnly")] + public async Task>> GetAll([FromQuery] int page = 0) { var activities = await activityService.GetAllAsync(page); return Ok(activities); diff --git a/Models/Database/EntryActivity.cs b/Models/Database/EntryActivity.cs index 8e7c75c..24793c6 100644 --- a/Models/Database/EntryActivity.cs +++ b/Models/Database/EntryActivity.cs @@ -73,13 +73,17 @@ public class EntryActivity .ToDictionary(kv => kv.Key, kv => kv.Value); [BsonConstructor] - private EntryActivity() { } + protected EntryActivity() { } - public static EntryActivity Submitted(ObjectId entryId) => new() + public static EntryActivity Submitted(ObjectId entryId, string? cmsTicketId = null) => new() { EntryId = entryId, Type = EntryActivityType.Submitted, - Timestamp = DateTime.UtcNow + Timestamp = DateTime.UtcNow, + Attachments = cmsTicketId != null ? new Dictionary + { + [EntryActivityAttachment.CmsTicketId] = cmsTicketId + } : new Dictionary() }; public static EntryActivity DuplicateDetected(ObjectId entryId, DuplicateMatch duplicate) => new() diff --git a/Services/DatabaseService.cs b/Services/DatabaseService.cs index e029d88..1a8b0af 100644 --- a/Services/DatabaseService.cs +++ b/Services/DatabaseService.cs @@ -3,6 +3,7 @@ using MongoDB.Driver; using transdb_backend_net.Models.Config; using transdb_backend_net.Models.Database; +using transdb_backend_net.Models.Response; using transdb_backend_net.Utils; namespace transdb_backend_net.Services; @@ -42,8 +43,11 @@ public interface IDatabaseService /// Inserts a new activity document. Task InsertActivityAsync(EntryActivity activity); - /// Returns a paginated list of all activity documents, sorted by descending timestamp. - Task> FindActivitiesAsync(int skip, int limit); + /// Returns a paginated list of all activity documents as enriched responses, sorted by descending timestamp. + Task> FindActivitiesAsync(int skip, int limit); + + /// Returns a name lookup for the given entry IDs, keyed by ID. Entries not found are omitted. + Task> FindEntryNamesByIdsAsync(IEnumerable ids); /// Returns a paginated list of activity documents for a specific entry, sorted by ascending timestamp. Task> FindActivitiesByEntryAsync(ObjectId entryId, int skip, int limit); @@ -201,13 +205,27 @@ public async Task InsertActivityAsync(EntryActivity activity) => await _activities.InsertOneAsync(activity); /// - public async Task> FindActivitiesAsync(int skip, int limit) => + public async Task> FindActivitiesAsync(int skip, int limit) => await _activities.Find(FilterDefinition.Empty) .SortByDescending(a => a.Timestamp) .Skip(skip) .Limit(limit) + .As() .ToListAsync(); + /// + public async Task> FindEntryNamesByIdsAsync(IEnumerable ids) + { + var filter = Builders.Filter.In(e => e.Id, ids); + var projection = Builders.Projection.Include(e => e.Id).Include(e => e.Name); + return await _entries.Find(filter) + .Project(projection) + .ToListAsync() + .ContinueWith(t => t.Result.ToDictionary( + d => d["_id"].AsObjectId, + d => d["name"].AsString)); + } + /// public async Task> FindActivitiesByEntryAsync(ObjectId entryId, int skip, int limit) => await _activities.Find(a => a.EntryId == entryId) diff --git a/Services/EntryActivityService.cs b/Services/EntryActivityService.cs index a163561..a5f68d0 100644 --- a/Services/EntryActivityService.cs +++ b/Services/EntryActivityService.cs @@ -17,8 +17,8 @@ public interface IEntryActivityService /// Compares the existing entry state with the given status changes and logs the appropriate activities. Task LogStatusChangesAsync(ObjectId entryId, string userId, Entry existing, EntryStatusChange changes); - /// Returns a paginated list of all activity events across all entries. - Task> GetAllAsync(int page); + /// Returns a paginated list of all activity events across all entries, enriched with entry names. + Task> GetAllAsync(int page); /// Returns a paginated list of activity events for a specific entry. Task> GetByEntryAsync(ObjectId entryId, int page); @@ -65,10 +65,15 @@ public async Task LogStatusChangesAsync(ObjectId entryId, string userId, Entry e } /// - public async Task> GetAllAsync(int page) + public async Task> GetAllAsync(int page) { var items = await db.FindActivitiesAsync(Math.Max(0, page) * _itemsPerPage, _itemsPerPage); - return new PaginatedResponse(items, _itemsPerPage); + var names = await db.FindEntryNamesByIdsAsync(items.Select(a => a.EntryId).Distinct()); + foreach (var item in items) + { + item.EntryName = names.GetValueOrDefault(item.EntryId); + } + return new PaginatedResponse(items, _itemsPerPage); } /// From 58c7f6788b1059176baf0479b70caad46746c79c Mon Sep 17 00:00:00 2001 From: Lena Emme <38376566+feuerhamster@users.noreply.github.com> Date: Sat, 27 Jun 2026 17:12:12 +0200 Subject: [PATCH 11/12] fix cms ticketing --- Controllers/EntriesController.cs | 16 ++++++++++++-- Controllers/ManageEntriesController.cs | 1 - Services/CmsService.cs | 10 +++------ Services/EntryService.cs | 3 --- Setup/HttpClientsSetup.cs | 1 + appsettings.json | 2 +- transdb-migration/migrate.js | 29 ++++++++++++++++++++------ 7 files changed, 42 insertions(+), 20 deletions(-) diff --git a/Controllers/EntriesController.cs b/Controllers/EntriesController.cs index 8df7f67..ca9ca72 100644 --- a/Controllers/EntriesController.cs +++ b/Controllers/EntriesController.cs @@ -17,6 +17,8 @@ namespace transdb_backend_net.Controllers; public class EntriesController( IEntryService entryService, IEntryRevocationService revocationService, + ICmsService cmsService, + ILogger logger, IEntryActivityService activityService) : ControllerBase { /// @@ -42,11 +44,21 @@ public async Task> CreateEntry([FromBody] Crea { var result = await entryService.CreateEntryAsync(request); if (result.IsFailed) return new OperationFailedApiError(result.FailureDetails); - + var entry = result.Value!; - await activityService.LogAsync(EntryActivity.Submitted(entry.Id)); + + var cmsResult = await cmsService.CreateTicketAsync(entry.Name, entry.Id.ToString(), CmsTicketType.NewEntry, null); + + if (cmsResult.IsFailed) + { + logger.LogCritical(cmsResult.FailureDetails); + } + + await activityService.LogAsync(EntryActivity.Submitted(entry.Id, cmsResult.Value)); if (entry.PossibleDuplicate != null) + { await activityService.LogAsync(EntryActivity.DuplicateDetected(entry.Id, entry.PossibleDuplicate)); + } var userAgent = Request.Headers.UserAgent.ToString(); var revocationToken = await revocationService.GenerateTokenAsync(entry.Id, userAgent); diff --git a/Controllers/ManageEntriesController.cs b/Controllers/ManageEntriesController.cs index ea4500a..1cac3da 100644 --- a/Controllers/ManageEntriesController.cs +++ b/Controllers/ManageEntriesController.cs @@ -71,7 +71,6 @@ await activityService.LogStatusChangesAsync(id, userId, existing, /// A geo location update is triggered in the background if the address changed. /// [HttpPut("{id}")] - [Authorize(Policy = "AdminOnly")] public async Task EditEntry(ObjectId id, [FromBody] EditEntryRequest request) { var userId = User.FindFirstValue(ClaimTypes.NameIdentifier)!; diff --git a/Services/CmsService.cs b/Services/CmsService.cs index 7ec8e10..195f165 100644 --- a/Services/CmsService.cs +++ b/Services/CmsService.cs @@ -142,12 +142,10 @@ public async Task> GetManagementUserAsync(string ["filter[user][_eq]"] = userId, ["limit"] = "1" }); - var request = new HttpRequestMessage(HttpMethod.Get, managementUrl); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _config.AccessToken); try { - var response = await httpClient.SendAsync(request); + var response = await httpClient.GetAsync(managementUrl); if (!response.IsSuccessStatusCode) return Result.Failure($"cms management user failed with status code {response.StatusCode}"); var json = await response.Content.ReadAsStringAsync(); @@ -172,12 +170,10 @@ public async Task>> GetAllUsersAsync() ["fields"] = "user.id,user.first_name,user.last_name", ["limit"] = "-1" }); - var request = new HttpRequestMessage(HttpMethod.Get, url); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _config.AccessToken); - + try { - var response = await httpClient.SendAsync(request); + var response = await httpClient.GetAsync(url); if (!response.IsSuccessStatusCode) return Result>.Failure($"cms users request failed with status {response.StatusCode}"); diff --git a/Services/EntryService.cs b/Services/EntryService.cs index b050952..b745398 100644 --- a/Services/EntryService.cs +++ b/Services/EntryService.cs @@ -118,9 +118,6 @@ public async Task> CreateEntryAsync(CreateEntryRequest request) entry.PossibleDuplicate = duplicate; var created = await db.InsertEntryAsync(entry); - - //_ = CreateCmsTicketAsync(entry.Name, created.Id.ToString()); - return Result.Success(created); } catch (Exception e) diff --git a/Setup/HttpClientsSetup.cs b/Setup/HttpClientsSetup.cs index 73069c4..6d44a27 100644 --- a/Setup/HttpClientsSetup.cs +++ b/Setup/HttpClientsSetup.cs @@ -13,6 +13,7 @@ public static IServiceCollection AddApplicationHttpClients(this IServiceCollecti services.AddHttpClient(client => { client.BaseAddress = new Uri(cmsConfig.Url); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", cmsConfig.AccessToken); }); services.Configure(configuration.GetSection(GeocodingConfig.ConfigKey)); diff --git a/appsettings.json b/appsettings.json index e6f8089..4d9444e 100644 --- a/appsettings.json +++ b/appsettings.json @@ -12,7 +12,7 @@ "Cms": { "Url": "https://cms.transdb.de", "AccessToken": "FROM_SECRETS", - "TicketCollection": "transdb_tickets" + "TicketCollection": "transdb_tickets_testing" }, "Geocoding": { "Url": "https://geo.transdb.de", diff --git a/transdb-migration/migrate.js b/transdb-migration/migrate.js index 29c2f09..da923b0 100644 --- a/transdb-migration/migrate.js +++ b/transdb-migration/migrate.js @@ -159,6 +159,8 @@ for (let i = 3; i < process.argv.length - 1; i++) { const docs = JSON.parse(fs.readFileSync(inputFile, "utf8")); const entries = []; const activities = []; +const submittedActivityByEntryId = {}; +const approvedActivityByEntryId = {}; let warnings = 0; let skipped = 0; @@ -274,10 +276,14 @@ for (const doc of docs) { entries.push(entry); // Activities - activities.push(activity(entryId, "Submitted", submittedAt)); + const submittedActivity = activity(entryId, "Submitted", submittedAt); + submittedActivityByEntryId[entryId] = submittedActivity; + activities.push(submittedActivity); if (approvedAt !== null) { - activities.push(activity(entryId, "Approved", approvedAt, approvedBy)); + const approvedActivity = activity(entryId, "Approved", approvedAt, approvedBy); + approvedActivityByEntryId[entryId] = approvedActivity; + activities.push(approvedActivity); } if (doc.blocked) { @@ -299,15 +305,26 @@ if (ticketsFile) { let ticketActivities = 0; for (const ticket of tickets) { - if (ticket.type === "new-entry") { - continue; - } - if (!ticket.entry_id) { console.error(`[WARN] Ticket #${ticket.id} ("${ticket.title}") has no entry_id, skipping`); warnings++; continue; } + + if (ticket.type === "new-entry") { + const submitted = submittedActivityByEntryId[ticket.entry_id]; + if (!submitted) { + console.error(`[WARN] new-entry ticket #${ticket.id} references unknown entry ${ticket.entry_id}, skipping`); + warnings++; + continue; + } + const cms = attachments({ CmsTicketId: String(ticket.id) }); + submitted.attachments = cms; + const approved = approvedActivityByEntryId[ticket.entry_id]; + if (approved) approved.attachments = cms; + ticketActivities++; + continue; + } const reportType = reportTypeMap[ticket.type]; if (!reportType) { From 54716703167adb61ae1c6a061996847113e15d30 Mon Sep 17 00:00:00 2001 From: Lena Emme <38376566+feuerhamster@users.noreply.github.com> Date: Sat, 27 Jun 2026 17:24:36 +0200 Subject: [PATCH 12/12] add missing file --- Models/Response/ActivityResponse.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 Models/Response/ActivityResponse.cs diff --git a/Models/Response/ActivityResponse.cs b/Models/Response/ActivityResponse.cs new file mode 100644 index 0000000..65f7da5 --- /dev/null +++ b/Models/Response/ActivityResponse.cs @@ -0,0 +1,12 @@ +using transdb_backend_net.Models.Database; + +namespace transdb_backend_net.Models.Response; + +/// +/// Extends with the name of the referenced entry. +/// Used in the global activity list where callers cannot derive the entry name from context. +/// +public class EntryActivityResponse : EntryActivity +{ + public string? EntryName { get; set; } +}