├── .dockerignore ├── .env.example ├── .eslintrc.json ├── .github └── workflows │ ├── build-chart.yaml │ ├── build.yaml │ └── codeql-analysis.yml ├── .gitignore ├── .prettierrc ├── .prettierrc.json ├── .vscode └── tasks.json ├── CODEOWNERS ├── Dockerfile ├── LICENSE ├── README.md ├── chart ├── .helmignore ├── Chart.yaml ├── templates │ ├── NOTES.txt │ ├── _helpers.tpl │ ├── deployment.yaml │ ├── hpa.yaml │ ├── ingress.yaml │ ├── service.yaml │ ├── serviceaccount.yaml │ └── tests │ │ └── test-connection.yaml └── values.yaml ├── cloudbuild.yaml ├── doc ├── content-response.md ├── db-metadata.md └── forum-vs-user.md ├── docker-compose-mastodon-dev.yaml ├── docker-compose.yml ├── nest-cli.json ├── nginx-mastodon.conf ├── nginx.conf ├── package-lock.json ├── package.json ├── sonar-project.properties ├── src ├── app.controller.spec.ts ├── app.controller.ts ├── app.module.ts ├── app.service.ts ├── common │ ├── decorators │ │ ├── duplicate-record-filter.decorator.ts │ │ ├── service-domain.decorator.spec.ts │ │ ├── service-domain.decorator.ts │ │ └── user.decorator.ts │ ├── exceptions │ │ └── duplicate-record.exception.ts │ ├── pipes │ │ ├── activity-streams.pipe.spec.ts │ │ └── activity-streams.pipe.ts │ ├── schema │ │ └── base.schema.ts │ ├── transformer │ │ ├── object-create.transformer.ts │ │ └── object.transformer.ts │ └── util │ │ └── validate-url.ts ├── config │ ├── auth.ts │ ├── database.ts │ ├── index.ts │ └── service.ts ├── main.ts ├── modules │ ├── activity │ │ ├── README.md │ │ ├── activity.module.ts │ │ ├── dto │ │ │ ├── activity.dto.ts │ │ │ ├── external-actor.dto.ts │ │ │ ├── follow-create.dto.ts │ │ │ └── http-signature.dto.ts │ │ ├── interceptor │ │ │ └── raw-activity.interceptor.ts │ │ ├── interface │ │ │ └── accept-options.interface.ts │ │ ├── schema │ │ │ ├── activity.schema.ts │ │ │ └── base-activity.schema.ts │ │ ├── service │ │ │ ├── activity-pub.service.spec.ts │ │ │ ├── activity-pub.service.ts │ │ │ ├── activity.service.spec.ts │ │ │ ├── activity.service.ts │ │ │ ├── inbox-processor.service.spec.ts │ │ │ ├── inbox-processor.service.ts │ │ │ ├── inbox.service.spec.ts │ │ │ ├── inbox.service.ts │ │ │ ├── outbox.service.spec.ts │ │ │ ├── outbox.service.ts │ │ │ ├── sync-dispatch.service.spec.ts │ │ │ └── sync-dispatch.service.ts │ │ └── validator │ │ │ └── context.validator.ts │ ├── auth │ │ ├── README.md │ │ ├── anonymous.strategy.ts │ │ ├── auth.controller.spec.ts │ │ ├── auth.controller.ts │ │ ├── auth.module.ts │ │ ├── auth.service.spec.ts │ │ ├── auth.service.ts │ │ ├── decorators │ │ │ └── roles.decorator.ts │ │ ├── dto │ │ │ └── login.dto.ts │ │ ├── enums │ │ │ └── role.enum.ts │ │ ├── jwt-auth.guard.ts │ │ ├── jwt.strategy.ts │ │ ├── local-auth.guard.ts │ │ └── local.strategy.ts │ ├── forum │ │ ├── controller │ │ │ ├── forum-content.controller.spec.ts │ │ │ ├── forum-content.controller.ts │ │ │ ├── forum-inbox.controller.spec.ts │ │ │ ├── forum-inbox.controller.ts │ │ │ ├── forum-outbox.controller.spec.ts │ │ │ ├── forum-outbox.controller.ts │ │ │ ├── forum.controller.spec.ts │ │ │ └── forum.controller.ts │ │ ├── dto │ │ │ ├── forum-collection.dto.ts │ │ │ ├── forum-params.dto.ts │ │ │ └── forum.dto.ts │ │ ├── forum.module.ts │ │ ├── forum.service.spec.ts │ │ └── forum.service.ts │ ├── object │ │ ├── dto │ │ │ ├── actor │ │ │ │ └── actor.dto.ts │ │ │ ├── base-object.dto.ts │ │ │ ├── collection │ │ │ │ └── ordered-collection-page.dto.ts │ │ │ ├── content-query-options.dto.ts │ │ │ ├── forum-create.dto.ts │ │ │ ├── link.dto.ts │ │ │ ├── object-create │ │ │ │ ├── article-create.dto.ts │ │ │ │ ├── note-create.dto.ts │ │ │ │ └── object-create.dto.ts │ │ │ ├── object.dto.ts │ │ │ └── object │ │ │ │ ├── article.dto.ts │ │ │ │ ├── note.dto.ts │ │ │ │ ├── person.dto.ts │ │ │ │ └── relationship.dto.ts │ │ ├── object.controller.spec.ts │ │ ├── object.controller.ts │ │ ├── object.module.ts │ │ ├── object.service.spec.ts │ │ ├── object.service.ts │ │ ├── resolver │ │ │ └── stored-object.resolver.ts │ │ ├── schema │ │ │ ├── README.md │ │ │ ├── actor.schema.ts │ │ │ ├── base-object.schema.ts │ │ │ ├── content.schema.ts │ │ │ ├── object.schema.ts │ │ │ ├── relationship.schema.ts │ │ │ └── user-actor.schema.ts │ │ └── type │ │ │ ├── README.md │ │ │ ├── actor.type.ts │ │ │ ├── attribution.type.ts │ │ │ ├── base-object.type.ts │ │ │ ├── dto.type.ts │ │ │ ├── object.type.ts │ │ │ ├── relationship.type.ts │ │ │ ├── replies.type.ts │ │ │ └── user-actor.type.ts │ ├── user │ │ ├── controller │ │ │ ├── user-content.controller.spec.ts │ │ │ ├── user-content.controller.ts │ │ │ ├── user-inbox.controller.spec.ts │ │ │ ├── user-inbox.controller.ts │ │ │ ├── user-outbox.controller.spec.ts │ │ │ ├── user-outbox.controller.ts │ │ │ └── user.controller.ts │ │ ├── dto │ │ │ ├── user-actor.dto.ts │ │ │ ├── user-content-query-options.dto.ts │ │ │ ├── user-create.dto.ts │ │ │ ├── user-params.dto.ts │ │ │ ├── user-query-options.dto.ts │ │ │ └── user.dto.ts │ │ ├── schemas │ │ │ └── user.schema.ts │ │ ├── user.module.ts │ │ ├── user.service.spec.ts │ │ └── user.service.ts │ └── well-known │ │ ├── README.md │ │ ├── dto │ │ ├── webfinger-link.dto.ts │ │ └── webfinger.dto.ts │ │ ├── host-meta.controller.spec.ts │ │ ├── host-meta.controller.ts │ │ ├── webfinger.controller.spec.ts │ │ ├── webfinger.controller.ts │ │ ├── webfinger.service.spec.ts │ │ ├── webfinger.service.ts │ │ └── well-known.module.ts └── repl.ts ├── test ├── app.e2e-spec.ts └── jest-e2e.json ├── tsconfig.build.json ├── tsconfig.json ├── tsfmt.json └── tslint.json /.dockerignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | test/ 4 | .env* 5 | .gitignore 6 | .prettierrc 7 | cloudbuild.yaml 8 | nest-cli.json 9 | tslint.json -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | MONGODB_URI=mongodb://127.0.0.1/yuforia 2 | DEFAULT_DOMAIN=yuforium.dev 3 | JWT_SECRET="CHANGE THIS" 4 | SUPERUSER_PASSWORD="CHANGE THIS" 5 | NODE_ENV=development -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"], 7 | "ignorePatterns": [ 8 | "node_modules/", 9 | "dist/" 10 | // @todo ignore test files for now, remove later 11 | // "src/**/*.spec.ts" 12 | ], 13 | "parser": "@typescript-eslint/parser", 14 | "parserOptions": { 15 | "ecmaVersion": "latest", 16 | "sourceType": "module" 17 | }, 18 | "plugins": ["@typescript-eslint"], 19 | "rules": { 20 | "indent": [ 21 | "error", 22 | 2, 23 | { 24 | "MemberExpression": 1, 25 | // not having these rules means properties need to be indented 26 | // from their decorators, which at first glance doesn't seem 27 | // completely horrible 28 | "ignoredNodes": [ 29 | // "FunctionExpression > .params[decorators.length > 0]", 30 | // "FunctionExpression > .params > :matches(Decorator, :not(:first-child))", 31 | // "ClassBody.body > PropertyDefinition[decorators.length > 0] > .key" 32 | ] 33 | } 34 | ], 35 | "linebreak-style": ["error", "unix"], 36 | "quotes": ["error", "single"], 37 | "semi": ["error", "always"], 38 | // @todo consider removing this rule at some point 39 | "@typescript-eslint/no-explicit-any": ["off"], 40 | "no-unused-vars": "off", 41 | "@typescript-eslint/no-unused-vars": [ 42 | "warn", // or "error" 43 | { 44 | "argsIgnorePattern": "^_", 45 | "varsIgnorePattern": "^_", 46 | "caughtErrorsIgnorePattern": "^_" 47 | } 48 | ] 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /.github/workflows/build-chart.yaml: -------------------------------------------------------------------------------- 1 | name: Build Helm Chart 2 | on: 3 | push: 4 | branches: 5 | - main 6 | paths: 7 | - chart/** 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - id: auth 14 | name: Auth to GCloud 15 | uses: google-github-actions/auth@v1 16 | with: 17 | credentials_json: '${{ secrets.GCLOUD_AUTH }}' 18 | token_format: access_token 19 | - uses: docker/login-action@v1 20 | with: 21 | registry: us-central1-docker.pkg.dev 22 | username: oauth2accesstoken 23 | password: ${{ steps.auth.outputs.access_token }} 24 | - run: | 25 | export CHART_VERSION=$(helm show chart chart | yq e '.version') 26 | helm package chart 27 | helm push api-$CHART_VERSION.tgz oci://us-central1-docker.pkg.dev/yuforium/api -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Build Docker Image 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: 6 | - main 7 | - arm-builds 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Setup Docker Buildx 13 | uses: docker/setup-buildx-action@v3 14 | with: 15 | driver: docker-container 16 | - name: Login to DockerHub 17 | uses: docker/login-action@v3 18 | with: 19 | username: ${{ secrets.DOCKERHUB_USERNAME }} 20 | password: ${{ secrets.DOCKERHUB_TOKEN }} 21 | - name: Build and Push 22 | uses: docker/build-push-action@v5 23 | with: 24 | platforms: linux/arm64/v8,linux/amd64 25 | push: true 26 | tags: yuforium/api:latest,yuforium/api:${{ github.sha }} 27 | sonarcloud: 28 | name: SonarCloud 29 | runs-on: ubuntu-latest 30 | steps: 31 | - uses: actions/checkout@v3 32 | with: 33 | fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis 34 | - name: Install Deps 35 | run: npm ci 36 | - name: Run Tests 37 | run: npx jest --coverage 38 | continue-on-error: true 39 | - name: SonarCloud Scan 40 | uses: SonarSource/sonarcloud-github-action@master 41 | env: 42 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any 43 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 44 | - name: Upload coverage reports to Codecov 45 | uses: codecov/codecov-action@v4.0.1 46 | with: 47 | token: ${{ secrets.CODECOV_TOKEN }} 48 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "main" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "main" ] 20 | schedule: 21 | - cron: '40 3 * * 4' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v3 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v2 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | 52 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 53 | # queries: security-extended,security-and-quality 54 | 55 | 56 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 57 | # If this step fails, then you should remove it and run the build manually (see below) 58 | - name: Autobuild 59 | uses: github/codeql-action/autobuild@v2 60 | 61 | # ℹ️ Command-line programs to run using the OS shell. 62 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 63 | 64 | # If the Autobuild fails above, remove it and uncomment the following three lines. 65 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 66 | 67 | # - run: | 68 | # echo "Run, Build Application using script" 69 | # ./location_of_script_within_repo/buildscript.sh 70 | 71 | - name: Perform CodeQL Analysis 72 | uses: github/codeql-action/analyze@v2 73 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # docs 6 | /documentation 7 | 8 | # Logs 9 | logs 10 | *.log 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | lerna-debug.log* 15 | 16 | # OS 17 | .DS_Store 18 | 19 | # Tests 20 | /coverage 21 | /.nyc_output 22 | 23 | # IDEs and editors 24 | /.idea 25 | .project 26 | .classpath 27 | .c9/ 28 | *.launch 29 | .settings/ 30 | *.sublime-workspace 31 | 32 | # IDE - VSCode 33 | .vscode/* 34 | !.vscode/settings.json 35 | !.vscode/tasks.json 36 | !.vscode/launch.json 37 | !.vscode/extensions.json 38 | 39 | # Yuforium Project Specific 40 | /.local/ 41 | .env* -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "none", 4 | "tabWidth": 2, 5 | "useTabs": false, 6 | "semi": true, 7 | "arrowParens": "avoid", 8 | "endOfLine": "lf" 9 | } -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "typescript", 6 | "tsconfig": "tsconfig.json", 7 | "option": "watch", 8 | "problemMatcher": [ 9 | "$tsc-watch" 10 | ], 11 | "group": "build", 12 | "label": "tsc: watch - tsconfig.json" 13 | } 14 | ] 15 | } -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @cpmoser 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine AS development 2 | WORKDIR /usr/src/app 3 | COPY . . 4 | RUN npm ci --ignore-scripts && npm run build 5 | 6 | FROM node:20-alpine AS production 7 | ARG NODE_ENV=production 8 | ENV NODE_ENV=${NODE_ENV} 9 | WORKDIR /usr/src/app 10 | COPY package*.json ./ 11 | RUN npm ci --ignore-scripts --only=production 12 | COPY --from=development /usr/src/app/dist ./dist 13 | EXPOSE 3000 14 | CMD ["dist/main"] 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Yuforium API 2 | _Yuforium is a federated community platform based on Activity Pub_ 3 | 4 | The Yuforium API and [UI](https://github.com/yuforium/ui) are currently deployed to [Yuforia.com](https://www.yuforia.com). 5 | 6 | Yuforium federates communities so they are not constrained to a single entity, and attempts to federate them in the same way that the Internet is distributed, making communities operate across public exchange points. 7 | 8 | ## App Structure 9 | The Yuforium API is built using the [NestJS framework](https://nestjs.com), and all Yuforium API code has been organized into modules in `src/modules`. 10 | 11 | ### Data Modules 12 | Data in Yuforium is stored as ActivityPub objects. Any additional fields that are not part of the ActivityPub specification start with an underscore character. These fields are generally reserved for data operations (such as pointers to other documents). 13 | 14 | Yuforium stores data in three collections, with a corresponding modules: 15 | 16 | * **Activities** contain all activities sent or received 17 | * **Objects** stores the current state of all tracked objects and can be considered a summation of all Activities 18 | * **Users** contains all user login information. In addition to providing services related to the Users collection, the `UserModule` provides functionality for managing ActivityPub related to a user, such as the Inbox, Outbox, and user related content endpoints. 19 | 20 | ### Other Modules 21 | 22 | * **ActivityPub** handles server-to-server communication 23 | * **Auth** handles user authentication 24 | * **WellKnown** handles all `/.well-known` such as webfinger which are required for ActivityPub 25 | 26 | 27 | ## About 28 | This API is designed to implement a [proposed community standard for Activity Pub](https://github.com/yuforium/activitypub-docs/blob/main/federation.md). This project also uses the [Activity Streams](https://github.com/yuforium/activity-streams) package and has a [separate UI project](https://github.com/yuforium/ui) written in Angular. 29 | -------------------------------------------------------------------------------- /chart/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *.orig 18 | *~ 19 | # Various IDEs 20 | .project 21 | .idea/ 22 | *.tmproj 23 | .vscode/ 24 | -------------------------------------------------------------------------------- /chart/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: api 3 | description: Yuforium API 4 | 5 | # A chart can be either an 'application' or a 'library' chart. 6 | # 7 | # Application charts are a collection of templates that can be packaged into versioned archives 8 | # to be deployed. 9 | # 10 | # Library charts provide useful utilities or functions for the chart developer. They're included as 11 | # a dependency of application charts to inject those utilities and functions into the rendering 12 | # pipeline. Library charts do not define any templates and therefore cannot be deployed. 13 | type: application 14 | 15 | # This is the chart version. This version number should be incremented each time you make changes 16 | # to the chart and its templates, including the app version. 17 | # Versions are expected to follow Semantic Versioning (https://semver.org/) 18 | version: 0.1.1 19 | 20 | # This is the version number of the application being deployed. This version number should be 21 | # incremented each time you make changes to the application. Versions are not expected to 22 | # follow Semantic Versioning. They should reflect the version the application is using. 23 | # It is recommended to use it with quotes. 24 | appVersion: "0.0.1" 25 | -------------------------------------------------------------------------------- /chart/templates/NOTES.txt: -------------------------------------------------------------------------------- 1 | 1. Get the application URL by running these commands: 2 | {{- if .Values.ingress.enabled }} 3 | {{- range $host := .Values.ingress.hosts }} 4 | {{- range .paths }} 5 | http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }} 6 | {{- end }} 7 | {{- end }} 8 | {{- else if contains "NodePort" .Values.service.type }} 9 | export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "api.fullname" . }}) 10 | export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") 11 | echo http://$NODE_IP:$NODE_PORT 12 | {{- else if contains "LoadBalancer" .Values.service.type }} 13 | NOTE: It may take a few minutes for the LoadBalancer IP to be available. 14 | You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "api.fullname" . }}' 15 | export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "api.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") 16 | echo http://$SERVICE_IP:{{ .Values.service.port }} 17 | {{- else if contains "ClusterIP" .Values.service.type }} 18 | export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "api.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") 19 | export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}") 20 | echo "Visit http://127.0.0.1:8080 to use your application" 21 | kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT 22 | {{- end }} 23 | -------------------------------------------------------------------------------- /chart/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* 2 | Expand the name of the chart. 3 | */}} 4 | {{- define "api.name" -}} 5 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} 6 | {{- end }} 7 | 8 | {{/* 9 | Create a default fully qualified app name. 10 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 11 | If release name contains chart name it will be used as a full name. 12 | */}} 13 | {{- define "api.fullname" -}} 14 | {{- if .Values.fullnameOverride }} 15 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} 16 | {{- else }} 17 | {{- $name := default .Chart.Name .Values.nameOverride }} 18 | {{- if contains $name .Release.Name }} 19 | {{- .Release.Name | trunc 63 | trimSuffix "-" }} 20 | {{- else }} 21 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} 22 | {{- end }} 23 | {{- end }} 24 | {{- end }} 25 | 26 | {{/* 27 | Create chart name and version as used by the chart label. 28 | */}} 29 | {{- define "api.chart" -}} 30 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} 31 | {{- end }} 32 | 33 | {{/* 34 | Common labels 35 | */}} 36 | {{- define "api.labels" -}} 37 | helm.sh/chart: {{ include "api.chart" . }} 38 | {{ include "api.selectorLabels" . }} 39 | {{- if .Chart.AppVersion }} 40 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 41 | {{- end }} 42 | app.kubernetes.io/managed-by: {{ .Release.Service }} 43 | {{- end }} 44 | 45 | {{/* 46 | Selector labels 47 | */}} 48 | {{- define "api.selectorLabels" -}} 49 | app.kubernetes.io/name: {{ include "api.name" . }} 50 | app.kubernetes.io/instance: {{ .Release.Name }} 51 | {{- end }} 52 | 53 | {{/* 54 | Create the name of the service account to use 55 | */}} 56 | {{- define "api.serviceAccountName" -}} 57 | {{- if .Values.serviceAccount.create }} 58 | {{- default (include "api.fullname" .) .Values.serviceAccount.name }} 59 | {{- else }} 60 | {{- default "default" .Values.serviceAccount.name }} 61 | {{- end }} 62 | {{- end }} 63 | -------------------------------------------------------------------------------- /chart/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ include "api.fullname" . }} 5 | labels: 6 | {{- include "api.labels" . | nindent 4 }} 7 | spec: 8 | {{- if not .Values.autoscaling.enabled }} 9 | replicas: {{ .Values.replicaCount }} 10 | {{- end }} 11 | selector: 12 | matchLabels: 13 | {{- include "api.selectorLabels" . | nindent 6 }} 14 | template: 15 | metadata: 16 | {{- with .Values.podAnnotations }} 17 | annotations: 18 | {{- toYaml . | nindent 8 }} 19 | {{- end }} 20 | labels: 21 | {{- include "api.labels" . | nindent 8 }} 22 | {{- with .Values.podLabels }} 23 | {{- toYaml . | nindent 8 }} 24 | {{- end }} 25 | spec: 26 | {{- with .Values.imagePullSecrets }} 27 | imagePullSecrets: 28 | {{- toYaml . | nindent 8 }} 29 | {{- end }} 30 | serviceAccountName: {{ include "api.serviceAccountName" . }} 31 | securityContext: 32 | {{- toYaml .Values.podSecurityContext | nindent 8 }} 33 | automountServiceAccountToken: {{ .Values.serviceAccount.automount }} 34 | containers: 35 | - name: {{ .Chart.Name }} 36 | securityContext: 37 | {{- toYaml .Values.securityContext | nindent 12 }} 38 | image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" 39 | imagePullPolicy: {{ .Values.image.pullPolicy }} 40 | ports: 41 | - name: http 42 | containerPort: {{ .Values.service.port }} 43 | protocol: TCP 44 | livenessProbe: 45 | httpGet: 46 | path: /healthz 47 | port: http 48 | readinessProbe: 49 | httpGet: 50 | path: /healthz 51 | port: http 52 | resources: 53 | {{- toYaml .Values.resources | nindent 12 }} 54 | {{- with .Values.volumeMounts }} 55 | volumeMounts: 56 | {{- toYaml . | nindent 12 }} 57 | {{- end }} 58 | {{- with .Values.envFrom }} 59 | envFrom: 60 | {{- toYaml . | nindent 12 }} 61 | {{- end }} 62 | {{- with .Values.env }} 63 | env: 64 | {{- toYaml . | nindent 12 }} 65 | {{- end }} 66 | {{- with .Values.volumes }} 67 | volumes: 68 | {{- toYaml . | nindent 8 }} 69 | {{- end }} 70 | {{- with .Values.nodeSelector }} 71 | nodeSelector: 72 | {{- toYaml . | nindent 8 }} 73 | {{- end }} 74 | {{- with .Values.affinity }} 75 | affinity: 76 | {{- toYaml . | nindent 8 }} 77 | {{- end }} 78 | {{- with .Values.tolerations }} 79 | tolerations: 80 | {{- toYaml . | nindent 8 }} 81 | {{- end }} 82 | -------------------------------------------------------------------------------- /chart/templates/hpa.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.autoscaling.enabled }} 2 | apiVersion: autoscaling/v2 3 | kind: HorizontalPodAutoscaler 4 | metadata: 5 | name: {{ include "api.fullname" . }} 6 | labels: 7 | {{- include "api.labels" . | nindent 4 }} 8 | spec: 9 | scaleTargetRef: 10 | apiVersion: apps/v1 11 | kind: Deployment 12 | name: {{ include "api.fullname" . }} 13 | minReplicas: {{ .Values.autoscaling.minReplicas }} 14 | maxReplicas: {{ .Values.autoscaling.maxReplicas }} 15 | metrics: 16 | {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} 17 | - type: Resource 18 | resource: 19 | name: cpu 20 | target: 21 | type: Utilization 22 | averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} 23 | {{- end }} 24 | {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} 25 | - type: Resource 26 | resource: 27 | name: memory 28 | target: 29 | type: Utilization 30 | averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} 31 | {{- end }} 32 | {{- end }} 33 | -------------------------------------------------------------------------------- /chart/templates/ingress.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.ingress.enabled -}} 2 | {{- $fullName := include "api.fullname" . -}} 3 | {{- $svcPort := .Values.service.port -}} 4 | {{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }} 5 | {{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }} 6 | {{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}} 7 | {{- end }} 8 | {{- end }} 9 | {{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}} 10 | apiVersion: networking.k8s.io/v1 11 | {{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} 12 | apiVersion: networking.k8s.io/v1beta1 13 | {{- else -}} 14 | apiVersion: extensions/v1beta1 15 | {{- end }} 16 | kind: Ingress 17 | metadata: 18 | name: {{ $fullName }} 19 | labels: 20 | {{- include "api.labels" . | nindent 4 }} 21 | {{- with .Values.ingress.annotations }} 22 | annotations: 23 | {{- toYaml . | nindent 4 }} 24 | {{- end }} 25 | spec: 26 | {{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }} 27 | ingressClassName: {{ .Values.ingress.className }} 28 | {{- end }} 29 | {{- if .Values.ingress.tls }} 30 | tls: 31 | {{- range .Values.ingress.tls }} 32 | - hosts: 33 | {{- range .hosts }} 34 | - {{ . | quote }} 35 | {{- end }} 36 | secretName: {{ .secretName }} 37 | {{- end }} 38 | {{- end }} 39 | rules: 40 | {{- range .Values.ingress.hosts }} 41 | - host: {{ .host | quote }} 42 | http: 43 | paths: 44 | {{- range .paths }} 45 | - path: {{ .path }} 46 | {{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }} 47 | pathType: {{ .pathType }} 48 | {{- end }} 49 | backend: 50 | {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} 51 | service: 52 | name: {{ $fullName }} 53 | port: 54 | number: {{ $svcPort }} 55 | {{- else }} 56 | serviceName: {{ $fullName }} 57 | servicePort: {{ $svcPort }} 58 | {{- end }} 59 | {{- end }} 60 | {{- end }} 61 | {{- end }} 62 | -------------------------------------------------------------------------------- /chart/templates/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ include "api.fullname" . }} 5 | labels: 6 | {{- include "api.labels" . | nindent 4 }} 7 | spec: 8 | type: {{ .Values.service.type }} 9 | ports: 10 | - port: {{ .Values.service.port }} 11 | targetPort: http 12 | protocol: TCP 13 | name: http 14 | selector: 15 | {{- include "api.selectorLabels" . | nindent 4 }} 16 | -------------------------------------------------------------------------------- /chart/templates/serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.serviceAccount.create -}} 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: {{ include "api.serviceAccountName" . }} 6 | labels: 7 | {{- include "api.labels" . | nindent 4 }} 8 | {{- with .Values.serviceAccount.annotations }} 9 | annotations: 10 | {{- toYaml . | nindent 4 }} 11 | {{- end }} 12 | automountServiceAccountToken: {{ .Values.serviceAccount.automount }} 13 | {{- end }} 14 | -------------------------------------------------------------------------------- /chart/templates/tests/test-connection.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Pod 3 | metadata: 4 | name: "{{ include "api.fullname" . }}-test-connection" 5 | labels: 6 | {{- include "api.labels" . | nindent 4 }} 7 | annotations: 8 | "helm.sh/hook": test 9 | spec: 10 | automountServiceAccountToken: false 11 | containers: 12 | - name: wget 13 | image: busybox 14 | command: ['wget'] 15 | args: ['{{ include "api.fullname" . }}:{{ .Values.service.port }}'] 16 | resources: 17 | limits: 18 | memory: 128Mi 19 | cpu: 500m 20 | restartPolicy: Never 21 | -------------------------------------------------------------------------------- /chart/values.yaml: -------------------------------------------------------------------------------- 1 | # Default values for api. 2 | # This is a YAML-formatted file. 3 | # Declare variables to be passed into your templates. 4 | 5 | replicaCount: 1 6 | 7 | image: 8 | repository: yuforium/api 9 | pullPolicy: IfNotPresent 10 | # Overrides the image tag whose default is the chart appVersion. 11 | tag: "latest" 12 | 13 | imagePullSecrets: [] 14 | nameOverride: "" 15 | fullnameOverride: "" 16 | 17 | serviceAccount: 18 | # Specifies whether a service account should be created 19 | create: true 20 | # Automatically mount a ServiceAccount's API credentials? 21 | automount: false 22 | # Annotations to add to the service account 23 | annotations: {} 24 | # The name of the service account to use. 25 | # If not set and create is true, a name is generated using the fullname template 26 | name: "" 27 | 28 | podAnnotations: {} 29 | podLabels: {} 30 | 31 | podSecurityContext: {} 32 | # fsGroup: 2000 33 | 34 | securityContext: {} 35 | # capabilities: 36 | # drop: 37 | # - ALL 38 | # readOnlyRootFilesystem: true 39 | # runAsNonRoot: true 40 | # runAsUser: 1000 41 | 42 | service: 43 | type: ClusterIP 44 | port: 80 45 | 46 | ingress: 47 | enabled: false 48 | className: "" 49 | annotations: {} 50 | # kubernetes.io/ingress.class: nginx 51 | # kubernetes.io/tls-acme: "true" 52 | hosts: 53 | - host: chart-example.local 54 | paths: 55 | - path: / 56 | pathType: ImplementationSpecific 57 | tls: [] 58 | # - secretName: chart-example-tls 59 | # hosts: 60 | # - chart-example.local 61 | 62 | resources: 63 | # We usually recommend not to specify default resources and to leave this as a conscious 64 | # choice for the user. This also increases chances charts run on environments with little 65 | # resources, such as Minikube. If you do want to specify resources, uncomment the following 66 | # lines, adjust them as necessary, and remove the curly braces after 'resources:'. 67 | limits: 68 | cpu: "1" 69 | memory: 512Mi 70 | requests: 71 | cpu: 100m 72 | memory: 128Mi 73 | 74 | autoscaling: 75 | enabled: false 76 | minReplicas: 1 77 | maxReplicas: 100 78 | targetCPUUtilizationPercentage: 80 79 | # targetMemoryUtilizationPercentage: 80 80 | 81 | # Additional volumes on the output Deployment definition. 82 | volumes: [] 83 | # - name: foo 84 | # secret: 85 | # secretName: mysecret 86 | # optional: false 87 | 88 | # Additional volumeMounts on the output Deployment definition. 89 | volumeMounts: [] 90 | # - name: foo 91 | # mountPath: "/etc/foo" 92 | # readOnly: true 93 | 94 | nodeSelector: {} 95 | 96 | tolerations: [] 97 | 98 | affinity: {} 99 | -------------------------------------------------------------------------------- /cloudbuild.yaml: -------------------------------------------------------------------------------- 1 | steps: 2 | - name: "gcr.io/cloud-builders/docker" 3 | args: ["build", "-t", "gcr.io/yuforium/api", "."] 4 | images: ["gcr.io/yuforium/api"] -------------------------------------------------------------------------------- /doc/content-response.md: -------------------------------------------------------------------------------- 1 | ## Sample for a content query response 2 | ```json 3 | { 4 | "type": "OrderedCollectionPage", 5 | "items": [ 6 | { 7 | "type": "Collection", 8 | "name": "Post metadata", 9 | "items": [ 10 | { 11 | "name": "cached", 12 | "url": "https://yuforium.com/posts/chris/note/1" 13 | }, 14 | { 15 | "name": "original", 16 | "url": "https://original/link/to/post" 17 | }, 18 | { 19 | "name": "thread", 20 | "url": "https://yuforium.com/thread/chris/note/1" 21 | } 22 | ] 23 | } 24 | ] 25 | } 26 | ``` -------------------------------------------------------------------------------- /doc/db-metadata.md: -------------------------------------------------------------------------------- 1 | # MongoDB Database Structure 2 | Records stored in Yuforium's MongoDB driver are stored as their Activity Streams representation with the addition of metadata fields (prefixed with the underscore character) which should be ignored in responses and are only used for query operations. 3 | 4 | ## Metadata and Cached Fields 5 | ### `_actor` Field 6 | The `_actor` field identifies any actors associated with the object. This includes objects that would appear in a feed, so specifically a user's internal database id for objects that they sent, or the user's internal database id for activities directed to them and sent to their inbox. 7 | 8 | A top level `Service` object representing a domain is a root object and would not have an `_actor` set. follows: 9 | ```json 10 | { 11 | "type": "Service", 12 | "_id": ObjectID("64d72245a66ee071653a3dbb"), 13 | "id": "https://yuforia.com", 14 | "_actor": null, 15 | "_path": null, 16 | "_pathId": "yuforia.com" 17 | } 18 | ``` 19 | -------------------------------------------------------------------------------- /doc/forum-vs-user.md: -------------------------------------------------------------------------------- 1 | # How is a Forum different than a User? 2 | Taking into account the ActivityPub specification, how is a forum different than a user? Let's use the [recap from the ActivityPub Overview](https://www.w3.org/TR/activitypub/#Overview) on the W3 site: 3 | 4 | ## Inbox POST 5 | 6 | ### The User Way 7 | You can POST to someone's inbox to send them a message (server-to-server / federation only... this is federation!) 8 | 9 | ### The Forum Way 10 | A forum can POST to another forum's inbox to relay messages from one forum to another (server-to-server) 11 | 12 | ## Inbox GET 13 | 14 | ### The User Way 15 | You can GET from your inbox to read your latest messages (client-to-server; this is like reading your social network stream) 16 | 17 | ### The Forum Way 18 | You can GET from your forum's inbox to read the latest messages (client-to-server; this is like reading your social network stream) 19 | 20 | ## Outbox POST 21 | 22 | ### The User Way 23 | You can POST to your outbox to send messages to the world (client-to-server) 24 | 25 | ### The Forum Way 26 | You can POST to your forum's outbox to send messages to the world (client-to-server) 27 | 28 | *A Note On This:* Anything generally posted to a forum should be posted to the public group, although it may be possible to specify targets in the future. 29 | 30 | A forum represents all activity (user messages) that center around a topic - so when posting to a forum actor, its ID represents the topic to which it belongs. Topics exist on yuforium.com, but may also exist in other services as well. 31 | 32 | Topics should be the form https://yuforium.com/topic/**topicId** 33 | 34 | ### Outbox GET 35 | 36 | ### The User Way 37 | You can GET from someone's outbox to see what messages they've posted (or at least the ones you're authorized to see). (client-to-server and/or server-to-server) 38 | 39 | ### The Forum Way 40 | Your forum can GET from another forum's outbox to see what messages they've posted (or at least the ones you're authorized to see) and add them into their own collection. 41 | (client-to-server and/or server-to-server) -------------------------------------------------------------------------------- /docker-compose-mastodon-dev.yaml: -------------------------------------------------------------------------------- 1 | version: "3.3" 2 | services: 3 | nginx: 4 | image: nginx 5 | volumes: 6 | - ./nginx-mastodon.conf:/etc/nginx/conf.d/yuforium.conf 7 | ports: 8 | - "80:80" 9 | - "443:443" -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.3" 2 | services: 3 | yuforia: 4 | build: 5 | context: . 6 | dockerfile: Dockerfile.developer 7 | image: yuforium/api:developer 8 | entrypoint: 9 | - npm 10 | command: 11 | - run 12 | - start:dev 13 | volumes: 14 | - ./src:/usr/src/app/src 15 | environment: 16 | MONGODB_URI: mongodb://mongodev/yuforia 17 | SERVICE_ID: http://yuforia.com 18 | SERVICE_NAME: Yuforia 19 | ports: 20 | - 3000:3000 21 | networks: 22 | default: 23 | aliases: 24 | - yuforia.com 25 | depends_on: 26 | - mongodev 27 | yuforium: 28 | build: 29 | context: . 30 | dockerfile: Dockerfile.developer 31 | image: yuforium/api:developer 32 | entrypoint: 33 | - npm 34 | command: 35 | - run 36 | - start:dev 37 | volumes: 38 | - ./src:/usr/src/app/src 39 | environment: 40 | MONGODB_URI: mongodb://mongodev/yuforium 41 | SERVICE_ID: http://yuforium.com 42 | SERVICE_NAME: Yuforium 43 | ports: 44 | - 3001:3000 45 | networks: 46 | default: 47 | aliases: 48 | - yuforium.com 49 | depends_on: 50 | - mongodev 51 | nginx: 52 | image: nginx 53 | ports: 54 | - 80:80 55 | volumes: 56 | - ./nginx.conf:/etc/nginx/conf.d/yuforium.conf 57 | depends_on: 58 | - yuforia 59 | - yuforium 60 | mongodev: 61 | image: mongo 62 | volumes: 63 | - .local/mongodev:/data/db -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src" 4 | } 5 | -------------------------------------------------------------------------------- /nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | server_name yuforium.dev; 4 | server_name yuforia.dev; 5 | server_name dev.yuforium.com; 6 | 7 | location / { 8 | proxy_pass http://localhost:3001; 9 | } 10 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "api", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "license": "MIT", 7 | "repository": "https://github.com/yuforium/api.git", 8 | "scripts": { 9 | "prebuild": "rimraf dist", 10 | "build": "nest build", 11 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 12 | "start": "nest start", 13 | "start:dev": "nest start --watch", 14 | "start:debug": "nest start --debug --watch", 15 | "start:prod": "node dist/main", 16 | "start:repl": "nest start --entryFile repl", 17 | "start:mirrord": "source .env.mirrord && mirrord exec --steal --target $MIRRORD_TARGET --target-namespace $MIRRORD_TARGET_NAMESPACE npm run start:dev", 18 | "lint": "tslint -p tsconfig.json -c tslint.json", 19 | "test": "jest", 20 | "test:watch": "jest --watch", 21 | "test:cov": "jest --coverage", 22 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 23 | "test:e2e": "jest --config ./test/jest-e2e.json", 24 | "doc": "compodoc -p tsconfig.json -s -w" 25 | }, 26 | "engine": { 27 | "node": ">=18.0.0" 28 | }, 29 | "dependencies": { 30 | "@nestjs/axios": "^3.0.0", 31 | "@nestjs/common": "^10.0.5", 32 | "@nestjs/config": "^3.0.0", 33 | "@nestjs/core": "^10.0.5", 34 | "@nestjs/jwt": "^10.1.0", 35 | "@nestjs/mongoose": "^10.0.0", 36 | "@nestjs/passport": "^10.0.0", 37 | "@nestjs/platform-express": "^10.0.5", 38 | "@nestjs/swagger": "^7.1.1", 39 | "@yuforium/activity-streams": "^0.2.0-alpha", 40 | "@yuforium/http-signature": "^0.0.1", 41 | "bcrypt": "^5.0.1", 42 | "bcryptjs": "^2.4.3", 43 | "class-transformer": "^0.5.1", 44 | "class-validator": "^0.14.1", 45 | "mongoose": "^6.0.12", 46 | "passport": "^0.6.0", 47 | "passport-jwt": "^4.0.0", 48 | "passport-local": "^1.0.0", 49 | "reflect-metadata": "^0.1.13", 50 | "rimraf": "^3.0.0", 51 | "rxjs": "^7.4.0", 52 | "sanitize-html": "^2.13.0", 53 | "swagger-ui-express": "^4.1.2", 54 | "tldts": "^6.0.22" 55 | }, 56 | "devDependencies": { 57 | "@compodoc/compodoc": "^1.1.24", 58 | "@eslint/create-config": "^0.4.6", 59 | "@nestjs/cli": "^10.1.8", 60 | "@nestjs/schematics": "^10.0.1", 61 | "@nestjs/testing": "^10.0.5", 62 | "@swc/cli": "^0.1.62", 63 | "@swc/core": "^1.3.67", 64 | "@types/bcrypt": "^5.0.0", 65 | "@types/bcryptjs": "^2.4.6", 66 | "@types/express": "^4.17.1", 67 | "@types/jest": "^29.5.2", 68 | "@types/node": "^16.11.27", 69 | "@types/passport-jwt": "^3.0.3", 70 | "@types/passport-local": "^1.0.33", 71 | "@types/psl": "^1.1.0", 72 | "@types/sanitize-html": "^2.11.0", 73 | "@types/supertest": "^2.0.8", 74 | "@typescript-eslint/eslint-plugin": "^6.16.0", 75 | "@typescript-eslint/parser": "^6.16.0", 76 | "eslint": "^8.56.0", 77 | "jest": "^29.6.1", 78 | "multicast-service-discovery": "^4.0.3", 79 | "prettier": "1.19.1", 80 | "supertest": "^4.0.2", 81 | "ts-jest": "^29.1.1", 82 | "ts-loader": "^9.4.2", 83 | "ts-node": "^8.4.1", 84 | "tsconfig-paths": "^3.9.0", 85 | "tslint": "^5.20.1", 86 | "typedoc": "^0.24.8", 87 | "typescript": "^4.4.4" 88 | }, 89 | "jest": { 90 | "moduleFileExtensions": [ 91 | "js", 92 | "json", 93 | "ts" 94 | ], 95 | "rootDir": "src", 96 | "testRegex": ".spec.ts$", 97 | "transform": { 98 | "^.+\\.(t|j)s$": "ts-jest" 99 | }, 100 | "coverageDirectory": "../coverage", 101 | "testEnvironment": "node" 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.projectKey=yuforium_api 2 | sonar.organization=yuforium 3 | 4 | # This is the name and version displayed in the SonarCloud UI. 5 | #sonar.projectName=api 6 | #sonar.projectVersion=1.0 7 | 8 | sonar.javascript.lcov.reportPaths=./coverage/lcov.info 9 | 10 | # Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows. 11 | #sonar.sources=. 12 | 13 | # Encoding of the source code. Default is default system encoding 14 | #sonar.sourceEncoding=UTF-8 15 | -------------------------------------------------------------------------------- /src/app.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AppController } from './app.controller'; 3 | import { AppService } from './app.service'; 4 | 5 | describe('AppController', () => { 6 | let appController: AppController; 7 | 8 | beforeEach(async () => { 9 | const app: TestingModule = await Test.createTestingModule({ 10 | controllers: [AppController], 11 | providers: [AppService], 12 | }).compile(); 13 | 14 | appController = app.get(AppController); 15 | }); 16 | 17 | describe('root', () => { 18 | it('should return "ok" status in healthcheck', async () => { 19 | const result = await appController.getHealthCheck(); 20 | expect(result).toHaveProperty('status', 'ok'); 21 | expect(typeof result).toBe('object'); 22 | }); 23 | 24 | it('should return a valid response for root application', async () => { 25 | const result = await appController.getService('localhost'); 26 | expect(result).toHaveProperty('id', 'https://localhost'); 27 | expect(result).toHaveProperty('type', 'Application'); 28 | }); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { AppService } from './app.service'; 3 | import { ApiTags, ApiProduces } from '@nestjs/swagger'; 4 | import { ServiceDomain } from './common/decorators/service-domain.decorator'; 5 | 6 | @ApiProduces('application/ld+json; profile="https://www.w3.org/ns/activitystreams"', 'application/activity+json') 7 | @ApiTags('app') 8 | @Controller() 9 | export class AppController { 10 | constructor( 11 | protected readonly appService: AppService 12 | ) { } 13 | 14 | @Get() 15 | public async getService(@ServiceDomain() serviceId: string) { 16 | return this.appService.get(serviceId); 17 | } 18 | 19 | @Get('healthz') 20 | public async getHealthCheck(): Promise<{status: string, uptime: number, timestamp: number}> { 21 | return { 22 | status: 'ok', 23 | uptime: process.uptime(), 24 | timestamp: Date.now() 25 | }; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AppController } from './app.controller'; 3 | import { AppService } from './app.service'; 4 | import { ConfigModule, ConfigService } from '@nestjs/config'; 5 | import database from './config/database'; 6 | import service from './config/service'; 7 | import auth from './config/auth'; 8 | import { MongooseModule, MongooseModuleFactoryOptions } from '@nestjs/mongoose'; 9 | import { AuthModule } from './modules/auth/auth.module'; 10 | import { UserModule } from './modules/user/user.module'; 11 | import { WellKnownModule } from './modules/well-known/well-known.module'; 12 | import { ActivityModule } from './modules/activity/activity.module'; 13 | import { ForumModule } from './modules/forum/forum.module'; 14 | 15 | @Module({ 16 | imports: [ 17 | ConfigModule.forRoot({load: [database, service, auth], isGlobal: true}), 18 | MongooseModule.forRootAsync({ 19 | imports: [ConfigModule], 20 | inject: [ConfigService], 21 | useFactory: async (config: ConfigService) => config.get('database') as MongooseModuleFactoryOptions 22 | }), 23 | AuthModule, 24 | UserModule, 25 | WellKnownModule, 26 | ActivityModule, 27 | ForumModule 28 | ], 29 | controllers: [AppController], 30 | providers: [AppService, ConfigService], 31 | exports: [ConfigService] 32 | }) 33 | export class AppModule { } 34 | -------------------------------------------------------------------------------- /src/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { resolveDomain } from './common/decorators/service-domain.decorator'; 3 | 4 | @Injectable() 5 | export class AppService { 6 | public async get(domain: string) { 7 | const id = `https://${domain}`; 8 | 9 | return { 10 | type: 'Application', 11 | id, 12 | inbox: `${id}/inbox`, 13 | outbox: `${id}/outbox`, 14 | following: `${id}/following`, 15 | followers: `${id}/followers`, 16 | liked: `${id}/liked`, 17 | sharedInbox: `${id}/sharedInbox`, 18 | 19 | streams: [ 20 | `${id}/top/forums`, 21 | `${id}/top/users`, 22 | `${id}/trending/forums`, 23 | `${id}/trending/users` 24 | ] 25 | }; 26 | } 27 | 28 | public async getDomain(hostname: string) { 29 | return resolveDomain(hostname); 30 | } 31 | 32 | public async getHealthCheck() { 33 | return { 34 | status: 'ok', 35 | uptime: process.uptime(), 36 | timestamp: Date.now() 37 | }; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/common/decorators/duplicate-record-filter.decorator.ts: -------------------------------------------------------------------------------- 1 | import { ArgumentsHost, Catch, ExceptionFilter } from '@nestjs/common'; 2 | import { DuplicateRecordException } from '../exceptions/duplicate-record.exception'; 3 | 4 | @Catch(DuplicateRecordException) 5 | export class DuplicateRecordFilter implements ExceptionFilter { 6 | catch(exception: DuplicateRecordException, host: ArgumentsHost) { 7 | const context = host.switchToHttp(); 8 | const response = context.getResponse(); 9 | 10 | response.status(409).json(exception); 11 | } 12 | } -------------------------------------------------------------------------------- /src/common/decorators/service-domain.decorator.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { resolveDomain, ServiceDomainDecorator } from './service-domain.decorator'; 3 | 4 | describe('ServiceDomain', () => { 5 | const nodeEnv = process.env.NODE_ENV; 6 | let decorator: ServiceDomainDecorator; 7 | 8 | beforeEach(async () => { 9 | const module: TestingModule = await Test.createTestingModule({ 10 | providers: [ 11 | ServiceDomainDecorator 12 | ] 13 | }).compile(); 14 | 15 | decorator = module.get(ServiceDomainDecorator); 16 | }); 17 | 18 | afterEach(() => { 19 | process.env.NODE_ENV = nodeEnv; 20 | }); 21 | 22 | it('should be defined', () => { 23 | expect(decorator).toBeDefined(); 24 | }); 25 | 26 | it('should resolve a domain', () => { 27 | const result = resolveDomain('www.yuforium.com'); 28 | expect(result).toBe('yuforium.com'); 29 | }); 30 | 31 | it('should resolve localhost in development', () => { 32 | process.env.NODE_ENV = 'development'; 33 | const result = resolveDomain('localhost'); 34 | expect(result).toBe('localhost'); 35 | }); 36 | 37 | it('should throw an error for localhost in production', () => { 38 | expect(() => { 39 | resolveDomain('localhost'); 40 | }).toThrow('not a valid name'); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/common/decorators/service-domain.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext, Injectable, Logger } from '@nestjs/common'; 2 | import { parse } from 'tldts'; 3 | 4 | const logger = new Logger('ServiceDomainDecorator'); 5 | 6 | @Injectable() 7 | export class ServiceDomainDecorator { 8 | static resolveDomain(hostname: string) { 9 | logger.debug(`Processing hostname ${hostname}`); 10 | const domain = parse(hostname); 11 | 12 | if (domain.hostname === 'localhost' && process.env.NODE_ENV === 'development') { 13 | return 'localhost'; 14 | } 15 | 16 | if (domain.domain === null) { 17 | throw new Error('not a valid name'); 18 | } 19 | 20 | logger.debug(`resolveDomain: Using domain ${domain.domain} as serviceDomain`); 21 | return domain.domain; 22 | } 23 | } 24 | 25 | export const ServiceDomain = createParamDecorator( 26 | (_, ctx: ExecutionContext): string => ServiceDomainDecorator.resolveDomain(ctx.switchToHttp().getRequest().hostname) 27 | ); 28 | 29 | export const resolveDomain = ServiceDomainDecorator.resolveDomain; 30 | -------------------------------------------------------------------------------- /src/common/decorators/user.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common'; 2 | 3 | type allowedUserFields = 'preferredUsername'; 4 | 5 | export const User = createParamDecorator( 6 | (data: allowedUserFields, ctx: ExecutionContext) => { 7 | const request = ctx.switchToHttp().getRequest(); 8 | 9 | if (!request.user) { 10 | return null; 11 | } 12 | 13 | const user = request.user; 14 | 15 | if (data) { 16 | return user[data]; 17 | } 18 | 19 | return user; 20 | } 21 | ); 22 | -------------------------------------------------------------------------------- /src/common/exceptions/duplicate-record.exception.ts: -------------------------------------------------------------------------------- 1 | import { HttpException } from '@nestjs/common'; 2 | 3 | export class DuplicateRecordException extends HttpException { 4 | constructor(response?: string | Record, status = 409) { 5 | super({error: 'duplicate record', message: 'Record already exists'}, status); 6 | } 7 | } -------------------------------------------------------------------------------- /src/common/pipes/activity-streams.pipe.spec.ts: -------------------------------------------------------------------------------- 1 | import { ActivityStreams } from '@yuforium/activity-streams'; 2 | import { ActivityStreamsPipe } from './activity-streams.pipe'; 3 | 4 | describe('ActivityStreamsPipe', () => { 5 | it('should be defined', () => { 6 | expect(new ActivityStreamsPipe(new ActivityStreams.Transformer())).toBeDefined(); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /src/common/pipes/activity-streams.pipe.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Injectable, Optional, PipeTransform } from '@nestjs/common'; 2 | import { ActivityStreams } from '@yuforium/activity-streams'; 3 | import { ClassTransformOptions, TransformationType } from 'class-transformer'; 4 | import { validate, ValidationError } from 'class-validator'; 5 | 6 | @Injectable() 7 | export class ActivityStreamsPipe implements PipeTransform { 8 | protected allowedTypes: string[] = []; 9 | // protected transformer: Function; 10 | 11 | constructor(@Optional() protected transformer: ActivityStreams.Transformer, protected classTransformOptions: ClassTransformOptions = {}) { 12 | this.classTransformOptions = { 13 | excludeExtraneousValues: true, 14 | ...classTransformOptions 15 | }; 16 | 17 | if (this.transformer === undefined) { 18 | this.transformer = new ActivityStreams.Transformer(undefined, {convertTextToLinks: false}); 19 | } 20 | } 21 | 22 | async transform(value: any): Promise { 23 | const obj = this.transformer.transform({value, type: TransformationType.PLAIN_TO_CLASS, key: '', obj: null, options: this.classTransformOptions}); 24 | 25 | if (!obj) { 26 | throw new BadRequestException(`The type "${value.type}" is not supported.`); 27 | } 28 | 29 | const errors = await validate(obj); 30 | 31 | if (errors.length) { 32 | throw new BadRequestException(errors.reduce((prev: string[], error: ValidationError) => { 33 | return prev.concat(error.constraints ? Object.values(error.constraints) : []); 34 | }, []).join(', ')); 35 | } 36 | 37 | return obj; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/common/schema/base.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop } from '@nestjs/mongoose'; 2 | import { Exclude } from 'class-transformer'; 3 | import { Types } from 'mongoose'; 4 | 5 | export type GConstructor = new (...args: any[]) => T; 6 | 7 | /** 8 | * Minimum type requirements to store a record. 9 | */ 10 | export type BaseDto = { 11 | id: string; 12 | } 13 | 14 | /** 15 | * Mixin type that defines common fields that are used for all stored records. 16 | */ 17 | export type BaseRecord = { 18 | _id?: string | Types.ObjectId; 19 | _domain: string; 20 | _public: boolean; 21 | _local: boolean; 22 | _deleted?: boolean; 23 | } 24 | 25 | export function BaseSchema = GConstructor>(SourceDto: SourceDtoType): SourceDtoType & GConstructor { 26 | class BaseSchema extends SourceDto implements BaseRecord { 27 | /** 28 | * The ID of the object, override @Prop to force requirements + uniqueness. 29 | */ 30 | @Prop({type: String, required: true, unique: true}) 31 | public id!: string; 32 | 33 | /** 34 | * The database ID of the object. 35 | */ 36 | @Exclude() 37 | public _id!: Types.ObjectId; 38 | 39 | /** 40 | * The domain of the object. This is used for querying content. Although 41 | * there is no support for multiple domains, this is included for future support. 42 | */ 43 | @Prop({type: String, required: true}) 44 | @Exclude() 45 | public _domain!: string; 46 | 47 | /** 48 | * Specifies if this is a public object. Used for querying content. 49 | */ 50 | @Prop({type: Boolean, default: false}) 51 | @Exclude() 52 | public _public!: boolean; 53 | 54 | /** 55 | * Specifies if this is a local object. 56 | */ 57 | @Prop({type: Boolean, required: true}) 58 | @Exclude() 59 | public _local!: boolean; 60 | 61 | /** 62 | * Specifies if this is a deleted object. This is used for querying content. 63 | */ 64 | @Prop({type: Boolean, required: false, default: false}) 65 | public _deleted?: boolean; 66 | } 67 | 68 | return BaseSchema; 69 | } 70 | -------------------------------------------------------------------------------- /src/common/transformer/object-create.transformer.ts: -------------------------------------------------------------------------------- 1 | import { ActivityStreams } from '@yuforium/activity-streams'; 2 | import { ArticleCreateDto } from '../../modules/object/dto/object-create/article-create.dto'; 3 | import { NoteCreateDto } from '../../modules/object/dto/object-create/note-create.dto'; 4 | 5 | export const ObjectCreateTransformer = new ActivityStreams.Transformer(undefined, {convertTextToLinks: false}); 6 | 7 | ObjectCreateTransformer.add(ArticleCreateDto, NoteCreateDto); 8 | -------------------------------------------------------------------------------- /src/common/transformer/object.transformer.ts: -------------------------------------------------------------------------------- 1 | import { ActivityStreams } from '@yuforium/activity-streams'; 2 | import { NoteDto } from '../../modules/object/dto/object/note.dto'; 3 | 4 | const ObjectTransformer = new ActivityStreams.Transformer(undefined, {convertTextToLinks: false}); 5 | 6 | ObjectTransformer.add(NoteDto); 7 | 8 | export {ObjectTransformer}; 9 | -------------------------------------------------------------------------------- /src/common/util/validate-url.ts: -------------------------------------------------------------------------------- 1 | import { parse } from 'tldts'; 2 | 3 | export class InvalidURLException extends Error { } 4 | 5 | /** 6 | * URL validation function 7 | * @returns a validated URL 8 | * @throws InvalidURLException if the URL doesn't meet specified criteria 9 | */ 10 | export function validateURL(rawURL: string): string { 11 | const url = new URL(rawURL); 12 | 13 | if (url.protocol !== 'https:') { 14 | throw new InvalidURLException('URL must be avaiable via https protocol'); 15 | } 16 | 17 | if (url.port !== '') { 18 | throw new InvalidURLException('URL must not specify on default port'); 19 | } 20 | 21 | const domain = parse(url.hostname); 22 | 23 | if (!domain.domain || !domain.isIcann || domain.isPrivate) { 24 | throw new InvalidURLException('URL contains an invalid domain or is a private domain'); 25 | } 26 | 27 | return `https://${domain.hostname}${url.pathname}${url.search}${url.hash}`; 28 | } 29 | -------------------------------------------------------------------------------- /src/config/auth.ts: -------------------------------------------------------------------------------- 1 | export default () => ({ 2 | auth: { 3 | jwtSecret: process.env.JWT_SECRET 4 | } 5 | }); -------------------------------------------------------------------------------- /src/config/database.ts: -------------------------------------------------------------------------------- 1 | export default () => ({ 2 | database: { 3 | uri: process.env.MONGODB_URI 4 | } 5 | }); -------------------------------------------------------------------------------- /src/config/index.ts: -------------------------------------------------------------------------------- 1 | export * as auth from './auth'; 2 | export * as database from './database'; 3 | export * as service from './service'; -------------------------------------------------------------------------------- /src/config/service.ts: -------------------------------------------------------------------------------- 1 | export default () => ({ 2 | service: { 3 | id: process.env.SERVICE_ID, 4 | name: process.env.SERVICE_NAME, 5 | defaultDomain: process.env.DEFAULT_DOMAIN, 6 | resourcePaths: { 7 | user: 'users', 8 | userActivity: 'users/:username/activities', 9 | userObject: 'users/:username/posts' 10 | } 11 | } 12 | }); -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory, Reflector } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; 4 | import { ClassSerializerInterceptor, ValidationPipe } from '@nestjs/common'; 5 | import { useContainer } from 'class-validator'; 6 | import { NestExpressApplication } from '@nestjs/platform-express'; 7 | 8 | async function bootstrap() { 9 | const app = await NestFactory.create(AppModule, { 10 | rawBody: true, 11 | logger: ['log', 'error', 'warn', 'debug', 'verbose'] 12 | }); 13 | 14 | app.useBodyParser('json', {type: ['application/activity+json', 'application/json']}); 15 | 16 | const options = new DocumentBuilder() 17 | .setTitle('Yuforium') 18 | .setDescription('Yuforium API specification') 19 | .setVersion('1.0') 20 | .addBearerAuth() 21 | .build(); 22 | const document = SwaggerModule.createDocument(app, options); 23 | 24 | SwaggerModule.setup('api', app, document); 25 | 26 | // see https://github.com/nestjs/nest/issues/528 - enables DI in class-validator dto objects 27 | useContainer(app.select(AppModule), { fallbackOnErrors: true }); 28 | 29 | if (process.env.NODE_ENV === 'development') { 30 | app.enableCors({ origin: '*' }); 31 | } else { 32 | app.enableCors({ 33 | origin: [ 34 | `https://${process.env.DEFAULT_DOMAIN}`, 35 | `https://www.${process.env.DEFAULT_DOMAIN}` 36 | ] 37 | }); 38 | } 39 | 40 | app.useGlobalPipes(new ValidationPipe({transform: false, whitelist: true})); 41 | app.useGlobalInterceptors( 42 | new ClassSerializerInterceptor(app.get(Reflector), {excludeExtraneousValues: true, exposeUnsetFields: false}) 43 | ); 44 | 45 | await app.listen(parseInt(process.env.PORT ?? '3000', 10)); 46 | } 47 | bootstrap(); 48 | -------------------------------------------------------------------------------- /src/modules/activity/README.md: -------------------------------------------------------------------------------- 1 | # Activity Module 2 | * Handles interaction with external services 3 | * Manages inbox and outbox resources, including inbox and outbox controller classes that can and should be extended by other modules -------------------------------------------------------------------------------- /src/modules/activity/activity.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { MongooseModule } from '@nestjs/mongoose'; 3 | import { ActivityService } from './service/activity.service'; 4 | import { ActivitySchema } from './schema/activity.schema'; 5 | import { HttpModule } from '@nestjs/axios'; 6 | import { OutboxService } from './service/outbox.service'; 7 | import { ObjectModule } from '../object/object.module'; 8 | import { InboxService } from './service/inbox.service'; 9 | import { InboxProcessorService } from './service/inbox-processor.service'; 10 | 11 | @Module({ 12 | providers: [ 13 | ActivityService, 14 | InboxService, 15 | InboxProcessorService, 16 | OutboxService 17 | ], 18 | imports: [ 19 | MongooseModule.forFeature([ 20 | {name: 'Activity', schema: ActivitySchema} 21 | ]), 22 | HttpModule, 23 | ObjectModule 24 | ], 25 | exports: [ 26 | ActivityService, 27 | InboxService, 28 | InboxProcessorService, 29 | OutboxService 30 | ], 31 | controllers: [] 32 | }) 33 | export class ActivityModule { } 34 | -------------------------------------------------------------------------------- /src/modules/activity/dto/activity.dto.ts: -------------------------------------------------------------------------------- 1 | import { Prop } from '@nestjs/mongoose'; 2 | import { Activity, ActivityStreams, ASContext, IsRequired } from '@yuforium/activity-streams'; 3 | import { Expose, Transform, Type } from 'class-transformer'; 4 | import { Validate, ValidateNested } from 'class-validator'; 5 | import * as mongoose from 'mongoose'; 6 | import { ArticleDto } from '../../object/dto/object/article.dto'; 7 | import { NoteDto } from '../../object/dto/object/note.dto'; 8 | import { ContextValidator } from '../validator/context.validator'; 9 | import { HttpSignatureDto } from './http-signature.dto'; 10 | import { LinkDto } from '../../../modules/object/dto/link.dto'; 11 | import { ActorDto } from 'src/modules/object/dto/actor/actor.dto'; 12 | 13 | const { Mixed } = mongoose.Schema.Types; 14 | 15 | export type ActivityDtoObjectTypes = NoteDto | LinkDto; 16 | 17 | const transformer = new ActivityStreams.Transformer(); 18 | transformer.add(NoteDto, ArticleDto); 19 | 20 | export class ActivityDto extends Activity { 21 | @Prop({type: Mixed, required: true}) 22 | @IsRequired() 23 | @Validate(ContextValidator, {each: true}) 24 | @Expose() 25 | public '@context': string | ASContext | (string | ASContext)[] = 'https://www.w3.org/ns/activitystreams'; 26 | 27 | @Prop({type: String, required: true}) 28 | @IsRequired() 29 | @Expose() 30 | public id!: string; 31 | 32 | @Prop({type: String, required: true}) 33 | @IsRequired() 34 | @Expose() 35 | public type!: string; 36 | 37 | /** 38 | * @todo this could come in as an object, e.g. {id: 'https://example.com/actor', type: 'Person'} 39 | */ 40 | @Prop({type: String, required: true}) 41 | @IsRequired() 42 | @Expose() 43 | public actor!: string | ActorDto; 44 | 45 | @Prop({type: Mixed, required: true}) 46 | @IsRequired() 47 | @Expose() 48 | @ValidateNested({each: true}) 49 | @Transform(params => { 50 | if (typeof params.value === 'string') { 51 | return params.value; 52 | } 53 | return transformer.transform(params); 54 | }) 55 | public object!: any; 56 | 57 | @Type(() => HttpSignatureDto) 58 | @ValidateNested() 59 | public signature?: HttpSignatureDto; 60 | 61 | // @Prop({type: String, required: true}) 62 | // @IsRequired() 63 | // @Expose() 64 | // public published!: string; 65 | } 66 | -------------------------------------------------------------------------------- /src/modules/activity/dto/external-actor.dto.ts: -------------------------------------------------------------------------------- 1 | import { resolveDomain } from '../../../common/decorators/service-domain.decorator'; 2 | 3 | // @todo - for now this is any, but will be a class with validation 4 | export class ExternalActorDto { 5 | id!: string; 6 | public readonly _local = false; 7 | public get _domain(): string { 8 | const url = new URL(this.id); 9 | return resolveDomain(url.hostname); 10 | } 11 | } -------------------------------------------------------------------------------- /src/modules/activity/dto/follow-create.dto.ts: -------------------------------------------------------------------------------- 1 | import { PartialType, PickType } from '@nestjs/swagger'; 2 | import { ASObject, Follow, IsRequired } from '@yuforium/activity-streams'; 3 | 4 | export class FollowCreateDto extends PartialType( 5 | PickType(Follow, ['id', 'name', 'type', 'actor', 'object']), 6 | ) { 7 | @IsRequired() 8 | object: ASObject | undefined; 9 | } -------------------------------------------------------------------------------- /src/modules/activity/dto/http-signature.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEnum, IsRFC3339, IsString, IsUrl } from 'class-validator'; 2 | 3 | export class HttpSignatureDto { 4 | @IsString() 5 | @IsEnum(['RsaSignature2017']) 6 | public type!: string; 7 | 8 | @IsUrl() 9 | public creator!: string; 10 | 11 | @IsString() 12 | @IsRFC3339() 13 | public created!: string; 14 | 15 | @IsString() 16 | public signatureValue!: string; 17 | } 18 | -------------------------------------------------------------------------------- /src/modules/activity/interceptor/raw-activity.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; 2 | import { map, Observable } from 'rxjs'; 3 | 4 | @Injectable() 5 | export class RawActivityInterceptor implements NestInterceptor { 6 | intercept(context: ExecutionContext, next: CallHandler): Observable { 7 | const request = context.switchToHttp().getRequest(); 8 | 9 | let rawActivity = ''; 10 | 11 | request.on('data', (chunk: string) => { 12 | rawActivity += chunk; 13 | }); 14 | 15 | request.on('end', () => { 16 | request.rawActivity = rawActivity; 17 | }); 18 | 19 | return next.handle().pipe( 20 | map(data => { 21 | return data; 22 | }) 23 | ); 24 | } 25 | } 26 | 27 | declare module 'express' { 28 | export interface Request { 29 | rawActivity: string; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/modules/activity/interface/accept-options.interface.ts: -------------------------------------------------------------------------------- 1 | export interface AcceptOptions { 2 | requestSignature?: { 3 | headers: { 4 | [key: string]: string | string[] | undefined; 5 | }; 6 | publicKey?: string; 7 | method: string; 8 | path: string; 9 | } 10 | } -------------------------------------------------------------------------------- /src/modules/activity/schema/activity.schema.ts: -------------------------------------------------------------------------------- 1 | import { Schema, SchemaFactory } from '@nestjs/mongoose'; 2 | import * as mongoose from 'mongoose'; 3 | import { ActivityDto } from '../../../modules/activity/dto/activity.dto'; 4 | import { BaseActivitySchema } from './base-activity.schema'; 5 | import { GConstructor } from '../../../common/schema/base.schema'; 6 | 7 | export type ActivityDocument = ActivityRecord & mongoose.Document; 8 | 9 | @Schema({collection: 'activities'}) 10 | export class ActivityRecord extends BaseActivitySchema>(ActivityDto) { } 11 | 12 | export const ActivitySchema = SchemaFactory.createForClass(ActivityRecord); 13 | -------------------------------------------------------------------------------- /src/modules/activity/schema/base-activity.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop } from '@nestjs/mongoose'; 2 | import { Exclude } from 'class-transformer'; 3 | import * as mongoose from 'mongoose'; 4 | import { BaseRecord, BaseSchema, GConstructor } from '../../../common/schema/base.schema'; 5 | import { Activity } from '@yuforium/activity-streams'; 6 | 7 | type BaseActivityRecord = BaseRecord & { 8 | _object?: mongoose.Schema.Types.ObjectId; 9 | _raw?: string; 10 | }; 11 | 12 | /** 13 | * Adds Activity specific record meta to a schema class 14 | * @param SourceDto 15 | * @returns 16 | */ 17 | export function BaseActivitySchema>(SourceDto: TBase): TBase & GConstructor { 18 | class BaseActivitySchema extends BaseSchema(SourceDto) implements BaseActivityRecord { 19 | @Exclude() 20 | @Prop({type: mongoose.Schema.Types.ObjectId, ref: 'objects'}) 21 | public _object?: mongoose.Schema.Types.ObjectId; 22 | 23 | @Exclude() 24 | @Prop({ type: String, required: false }) 25 | public _raw!: string; 26 | } 27 | 28 | return BaseActivitySchema; 29 | } 30 | -------------------------------------------------------------------------------- /src/modules/activity/service/activity-pub.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { ActivityPubService } from './activity-pub.service'; 3 | 4 | describe('ActivityPubService', () => { 5 | let service: ActivityPubService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [ActivityPubService], 10 | }).compile(); 11 | 12 | service = module.get(ActivityPubService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/modules/activity/service/activity-pub.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpService } from '@nestjs/axios'; 2 | import { Injectable, Logger } from '@nestjs/common'; 3 | import { ActivityDto } from '../../../modules/activity/dto/activity.dto'; 4 | import { AxiosResponse } from 'axios'; 5 | import { firstValueFrom } from 'rxjs'; 6 | import { instanceToPlain } from 'class-transformer'; 7 | import { LinkDto } from '../../../modules/object/dto/link.dto'; 8 | 9 | export interface DispatchOptions { 10 | requestSignature?: object 11 | } 12 | 13 | /** 14 | * Service responsible for interacting with (sending and receiving activities) to remote services 15 | * Also used to fetch remote objects 16 | */ 17 | @Injectable() 18 | export class ActivityPubService { 19 | protected readonly logger = new Logger(ActivityPubService.name); 20 | 21 | constructor( 22 | protected readonly httpService: HttpService, 23 | ) { } 24 | 25 | /** 26 | * Dispatch an activity to a remote actor 27 | * 28 | * @param activity 29 | * @param to 30 | * @param options 31 | * @returns 32 | */ 33 | public async dispatch(activity: ActivityDto, to: string | string[]): Promise { 34 | const obj = activity.object; 35 | 36 | if (obj instanceof LinkDto) { 37 | this.logger.warn('dispatch(): Object is not an ObjectDto and is likely a LinkDto and needs to be resolved'); 38 | return; 39 | } 40 | 41 | to = Array.isArray(to) ? to : [to]; 42 | 43 | to.filter(i => i !== 'https://www.w3.org/ns/activitystreams#Public') 44 | .forEach(async (recipient: string) => { 45 | const url = await this.getInboxUrl(recipient.toString()); 46 | this.logger.debug(`dispatch(): Sending ${activity.type} activity with id ${activity.id} to ${recipient}`); 47 | await this.send(url, activity); 48 | }); 49 | // iterate through the "to" field, and send the activity to each of the services 50 | // scenarios - 51 | // to self 52 | // to users on same service 53 | // to user on another service 54 | } 55 | 56 | /** 57 | * Dispatch an activity to a remote inbox 58 | * @param activity 59 | * @param inbox 60 | */ 61 | public async dispatchToInbox(activity: ActivityDto, inbox: string | string[]) { 62 | inbox = Array.isArray(inbox) ? inbox : [inbox]; 63 | 64 | inbox 65 | .filter(i => i !== 'https://www.w3.org/ns/activitystreams#Public') 66 | .forEach(async (recipient: string) => { 67 | this.logger.debug(`dispatchToInbox(): Sending ${activity.type} activity with id ${activity.id} to ${recipient}`); 68 | await this.sendNew(recipient, activity); 69 | }); 70 | } 71 | 72 | protected async sendNew(url: string, activity: any): Promise { 73 | const response = await fetch(url, { 74 | method: 'POST', 75 | headers: { 76 | 'Content-Type': 'application/activity+json', 77 | 'Accept': 'application/activity+json', 78 | }, 79 | body: JSON.stringify(activity), 80 | }); 81 | 82 | 83 | return response; 84 | 85 | // return firstValueFrom(this.httpService.post(url, activity)) 86 | // .then((response: AxiosResponse | undefined) => { 87 | // if (response !== undefined) { 88 | // this.logger.debug(`sendNew(): Sent ${activity.type} activity with id ${activity.id} to ${url}`); 89 | // return response.data; 90 | // } 91 | // }) 92 | // .catch(error => { 93 | // this.logger.error(`sendNew(): "${error.message}" sending ${activity.type} id ${activity.id} to ${url}`); 94 | // if (typeof error.response?.data?.message === 'string') { 95 | // this.logger.error(error.response.data.message); 96 | // } 97 | 98 | // if (Array.isArray(error.response?.data?.message)) { 99 | // error.response.data.message.forEach((e: any) => { 100 | // this.logger.error(e); 101 | // }); 102 | // } 103 | // }); 104 | } 105 | 106 | protected async send(url: string, activity: any): Promise { 107 | return firstValueFrom(this.httpService.post(url, instanceToPlain(activity))) 108 | .then((response: AxiosResponse | undefined) => { 109 | if (response !== undefined) { 110 | this.logger.debug(`send(): Sent ${activity.type} activity with id ${activity.id} to ${url}`); 111 | return response.data; 112 | } 113 | }) 114 | .catch(error => { 115 | this.logger.error(`send(): "${error.message}" sending ${activity.type} id ${activity.id} to ${url}`); 116 | if (typeof error.response?.data?.message === 'string') { 117 | this.logger.error(error.response.data.message); 118 | } 119 | 120 | if (Array.isArray(error.response?.data?.message)) { 121 | error.response.data.message.forEach((e: any) => { 122 | this.logger.error(e); 123 | }); 124 | } 125 | }); 126 | } 127 | 128 | protected async getInboxUrl(address: string): Promise { 129 | this.logger.debug(`getInboxUrl(): Getting inbox url for ${address}`); 130 | 131 | const actor = await fetch(address, {headers: {'Accept': 'application/activity+json'}}).then(res => res.json); 132 | 133 | return (actor as any).inbox; 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/modules/activity/service/activity.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { ActivityService } from './activity.service'; 3 | 4 | describe('ActivityService', () => { 5 | let service: ActivityService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [ActivityService], 10 | }).compile(); 11 | 12 | service = module.get(ActivityService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/modules/activity/service/activity.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger, NotImplementedException } from '@nestjs/common'; 2 | import { InjectModel } from '@nestjs/mongoose'; 3 | import { ActivityDocument, ActivityRecord } from '../schema/activity.schema'; 4 | import { plainToInstance } from 'class-transformer'; 5 | import { Model, Types } from 'mongoose'; 6 | import { ActivityDto } from '../dto/activity.dto'; 7 | 8 | @Injectable() 9 | export class ActivityService { 10 | 11 | protected logger = new Logger(ActivityService.name); 12 | 13 | constructor( 14 | @InjectModel('Activity') protected readonly activityModel: Model 15 | ) { } 16 | 17 | public id() { 18 | return new Types.ObjectId(); 19 | } 20 | 21 | /** 22 | * Get an Activity by its id 23 | * @param id 24 | * @returns 25 | */ 26 | public async get(id: string): Promise { 27 | const activity = await this.activityModel.findOne({id: id.toString()}); 28 | if (activity) { 29 | return plainToInstance(ActivityDto, activity, {excludeExtraneousValues: true}); 30 | } 31 | return activity; 32 | } 33 | 34 | public async createActivity(activity: ActivityRecord): Promise { 35 | const activityRecord = await this.activityModel.create(activity); 36 | return plainToInstance(ActivityDto, activityRecord, {excludeExtraneousValues: true}); 37 | } 38 | 39 | /** 40 | * Create a new activity 41 | * 42 | * @param dto 43 | * @returns 44 | */ 45 | public async create(dto: ActivityRecord): Promise { 46 | const activity = await this.activityModel.create(dto); 47 | return plainToInstance(ActivityDto, activity, {excludeExtraneousValues: true, exposeUnsetFields: false}); 48 | } 49 | 50 | public async process(activity: any) { 51 | if (activity.type === 'Create') { 52 | return Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 16); // @todo use a real, traceable id 53 | } 54 | 55 | throw new NotImplementedException(`${activity.type} is not supported at this time.`); 56 | } 57 | 58 | /** 59 | * Find an activity 60 | * @param query 61 | * @returns 62 | */ 63 | public async find(query: any): Promise { 64 | return this.activityModel.find(query).exec(); 65 | } 66 | 67 | /** 68 | * Count activities 69 | * @param query 70 | * @returns 71 | */ 72 | public async count(query: object = {}): Promise { 73 | return this.activityModel.count(query).exec(); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/modules/activity/service/inbox-processor.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { InboxProcessorService } from './inbox-processor.service'; 3 | import { ActivityService } from '../../../modules/activity/service/activity.service'; 4 | import { ObjectService } from '../../../modules/object/object.service'; 5 | import { getModelToken } from '@nestjs/mongoose'; 6 | import { ActorRecord } from '../../../modules/object/schema/content.schema'; 7 | import { ActivityPubService } from '../../../modules/activity-pub/services/activity-pub.service'; 8 | 9 | describe('InboxProcessorService', () => { 10 | let service: InboxProcessorService; 11 | 12 | beforeEach(async () => { 13 | const module: TestingModule = await Test.createTestingModule({ 14 | providers: [ 15 | InboxProcessorService, 16 | { 17 | provide: ActivityService, 18 | useValue: {}, 19 | }, 20 | { 21 | provide: ObjectService, 22 | useValue: {}, 23 | }, 24 | { 25 | provide: ActivityPubService, 26 | useValue: {} 27 | }, 28 | { 29 | provide: getModelToken(ActorRecord.name), 30 | useValue: {} 31 | } 32 | ], 33 | }).compile(); 34 | 35 | service = module.get(InboxProcessorService); 36 | }); 37 | 38 | it('should be defined', () => { 39 | expect(service).toBeDefined(); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/modules/activity/service/inbox-processor.service.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Injectable, Logger, NotImplementedException } from '@nestjs/common'; 2 | import { plainToInstance } from 'class-transformer'; 3 | import { ActivityDto } from '../../../modules/activity/dto/activity.dto'; 4 | import { ActivityService } from '../../../modules/activity/service/activity.service'; 5 | import { ObjectService } from '../../../modules/object/object.service'; 6 | import { RelationshipRecord } from '../../../modules/object/schema/relationship.schema'; 7 | import { Activity } from '@yuforium/activity-streams'; 8 | import { JwtUserActorDto } from '../../../modules/user/dto/user-actor.dto'; 9 | import { resolveDomain } from '../../../common/decorators/service-domain.decorator'; 10 | import { InjectModel } from '@nestjs/mongoose'; 11 | import { ActorDocument, ActorRecord } from '../../object/schema/actor.schema'; 12 | import { Model } from 'mongoose'; 13 | import { ObjectDto } from '../../object/dto/object.dto'; 14 | import { RelationshipType } from '../../object/type/relationship.type'; 15 | import { ActivityRecord } from '../../activity/schema/activity.schema'; 16 | 17 | @Injectable() 18 | export class InboxProcessorService { 19 | protected readonly logger = new Logger(InboxProcessorService.name); 20 | constructor( 21 | protected readonly activityService: ActivityService, 22 | protected readonly objectService: ObjectService, 23 | @InjectModel(ActorRecord.name) protected actorModel: Model, 24 | ) { } 25 | 26 | public async create(receivedActivity: ActivityDto, _raw: string, actor: JwtUserActorDto): Promise { 27 | this.logger.debug(`create(): ${receivedActivity.id}`); 28 | 29 | try { 30 | const actorRecord = await this.actorModel.findOne({ id: actor.id }); 31 | 32 | if (!actorRecord) { 33 | this.logger.debug(`create(): creating actor ${actor.id}`); 34 | const url = new URL(actor.id); 35 | await this.actorModel.create({ 36 | ...actor, 37 | _domain: resolveDomain(url.hostname), 38 | _local: false, 39 | _public: true 40 | }); 41 | } 42 | 43 | const existing = await this.activityService.get(receivedActivity.id); 44 | 45 | if (existing) { 46 | this.logger.debug('create(): activity already exists, returning null'); 47 | return null; 48 | } 49 | 50 | this.logger.debug(`create(): creating activity ${receivedActivity.id}`); 51 | 52 | const url = new URL(receivedActivity.id); 53 | const _domain = resolveDomain(url.hostname); 54 | 55 | const activityDto = plainToInstance(ActivityDto, receivedActivity, {excludeExtraneousValues: true}); 56 | const obj = plainToInstance(ObjectDto, receivedActivity.object, {excludeExtraneousValues: true}); 57 | 58 | // @todo create instead of createContent - requires create to inspect the type and route to the appropriate method 59 | await this.objectService.createContent({ 60 | ...obj, 61 | ...await this.objectService.getBaseObjectMetadata(obj) 62 | }); 63 | await this.activityService.createActivity({ 64 | ...receivedActivity, 65 | _local: false, 66 | _public: true, 67 | _domain, 68 | _raw 69 | }); 70 | 71 | Object.assign(activityDto, { object: obj }); 72 | 73 | return activityDto; 74 | } 75 | catch (e) { 76 | this.logger.error('create(): handling exception'); 77 | if (e instanceof Error) { 78 | this.logger.error(`${e.message}`) 79 | } 80 | throw e; 81 | } 82 | } 83 | 84 | public async follow(activityDto: ActivityDto, _raw: string, _actor: JwtUserActorDto): Promise { 85 | this.logger.log(`follow(): ${activityDto.id}`); 86 | 87 | if (!activityDto.id) { 88 | throw new BadRequestException('Activity must have an ID'); 89 | } 90 | 91 | if (!activityDto.object) { 92 | throw new Error('Activity does not have an object'); 93 | } 94 | 95 | if (!activityDto.actor) { 96 | throw new Error('Activity does not have an actor'); 97 | } 98 | 99 | this.logger.debug(`follow(): finding actorId ${activityDto.object}`); 100 | const actorId = (activityDto.object as string).replace('http://', 'https://'); 101 | this.logger.debug(`follow(): finding actorId ${actorId}`); 102 | const followee = await this.objectService.get(actorId); 103 | 104 | if (!followee) { 105 | throw new Error('Object does not exist'); 106 | } 107 | 108 | if (followee._local) { 109 | throw new Error('Cannot follow a remote object'); 110 | } 111 | 112 | const activityRecordDto = { 113 | ...activityDto, 114 | _domain: followee._domain, 115 | _local: false, 116 | _public: true 117 | }; 118 | 119 | this.logger.debug(`follow(): creating activity ${activityRecordDto.id}`); 120 | const activity = await this.activityService.createActivity(activityRecordDto); 121 | this.logger.debug(`follow(): created activity ${activity.id}`); 122 | 123 | const _id = this.objectService.id().toString(); 124 | 125 | const relationshipDto: RelationshipType = { 126 | '@context': 'https://www.w3.org/ns/activitystreams', 127 | id: `${followee.id}/relationship/${_id.toString()}`, 128 | type: 'Relationship', 129 | summary: 'Follows', 130 | attributedTo: followee.id, 131 | object: followee.id, 132 | subject: _actor.id, 133 | relationship: 'follows' 134 | }; 135 | 136 | const relationship = await this.objectService.create(relationshipDto); 137 | this.logger.debug(`follow(): created relationship ${relationship.id}`); 138 | 139 | const url = new URL(relationship.id); 140 | const _domain = resolveDomain(url.hostname); 141 | 142 | // @todo - if auto accept, accept the follow request, accept it anyway for now 143 | const _acceptId = this.activityService.id().toString(); 144 | const acceptActivityDto: ActivityRecord = { 145 | '@context': 'https://www.w3.org/ns/activitystreams', 146 | _id: _acceptId, 147 | _domain: _domain, 148 | // _path: `${followee._path}/${followee._pathId}/activities`, 149 | // _pathId: _acceptId, 150 | _local: true, 151 | id: `${followee.id}/activities/${_acceptId}`, 152 | type: 'Accept', 153 | actor: followee.id, 154 | object: activity.id, 155 | _public: true 156 | }; 157 | 158 | await this.activityService.createActivity(acceptActivityDto); 159 | 160 | return acceptActivityDto; 161 | } 162 | 163 | public async undo() { 164 | throw new NotImplementedException(); 165 | } 166 | 167 | protected async undoFollow(object: RelationshipRecord) { 168 | this.logger.log(`undoFollow(): ${object.id}`); 169 | } 170 | 171 | protected getObjectFromActivity(activity: Activity) { 172 | if (!activity.object) { 173 | throw new Error('Activity does not have an object'); 174 | } 175 | 176 | const object = typeof activity.object === 'string' ? activity.object : activity.object.id; 177 | 178 | if (!object) { 179 | throw new Error('Activity does not have an object'); 180 | } 181 | 182 | return this.objectService.get(object); 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /src/modules/activity/service/inbox.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { InboxService } from './inbox.service'; 3 | import { ActivityService } from './activity.service'; 4 | import { ObjectService } from '../../../modules/object/object.service'; 5 | import { getModelToken } from '@nestjs/mongoose'; 6 | import { ActorRecord } from '../../../modules/object/schema/actor.schema'; 7 | 8 | describe('InboxService', () => { 9 | let service: InboxService; 10 | 11 | beforeEach(async () => { 12 | const module: TestingModule = await Test.createTestingModule({ 13 | providers: [ 14 | InboxService, 15 | { 16 | provide: ActivityService, 17 | useValue: {} 18 | }, 19 | { 20 | provide: ObjectService, 21 | useValue: {} 22 | }, 23 | { 24 | provide: getModelToken(ActorRecord.name), 25 | useValue: { } 26 | } 27 | ], 28 | }).compile(); 29 | 30 | service = module.get(InboxService); 31 | }); 32 | 33 | it('should be defined', () => { 34 | expect(service).toBeDefined(); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /src/modules/activity/service/outbox.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { OutboxService } from './outbox.service'; 3 | 4 | describe('OutboxService', () => { 5 | let service: OutboxService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [OutboxService], 10 | }).compile(); 11 | 12 | service = module.get(OutboxService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/modules/activity/service/outbox.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ActivityService } from './activity.service'; 3 | import { ObjectService } from '../../object/object.service'; 4 | import { InjectModel } from '@nestjs/mongoose'; 5 | import { ActorDocument, ActorRecord } from '../../object/schema/actor.schema'; 6 | import { Model, Query } from 'mongoose'; 7 | import { ActivityRecord } from '../schema/activity.schema'; 8 | import { instanceToPlain } from 'class-transformer'; 9 | import { ObjectRecord } from '../../object/schema/object.schema'; 10 | import { Activity } from '@yuforium/activity-streams'; 11 | import { JwtUser } from '../../auth/auth.service'; 12 | import { ObjectType } from '../../object/type/object.type'; 13 | import { BaseObjectType } from '../../object/type/base-object.type'; 14 | 15 | export type OutboxObjectCreateType = Omit & { 16 | published: string; 17 | } 18 | 19 | @Injectable() 20 | export class OutboxService { 21 | constructor( 22 | protected readonly activityService: ActivityService, 23 | protected readonly objectService: ObjectService, 24 | @InjectModel(ObjectRecord.name) protected readonly objectModel: Model, 25 | @InjectModel(ActorRecord.name) protected readonly actorModel: Model, 26 | ) { } 27 | 28 | public async create(dto: T) { 29 | return dto; 30 | } 31 | 32 | /** 33 | * Create a new activity from an object specified by the client 34 | */ 35 | public async createObject( 36 | _domain: string, 37 | _user: JwtUser, 38 | outboxActorId: string, 39 | dto: T 40 | ) { 41 | const id = this.objectService.id(); 42 | 43 | const lookups: Query[] = []; 44 | if (Array.isArray(dto.attributedTo)) { 45 | dto.attributedTo.forEach(id => lookups.push(this.actorModel.findOne({ id }))); 46 | } 47 | else if (typeof dto.attributedTo === 'string') { 48 | lookups.push(this.actorModel.findOne({ id: dto.attributedTo })); 49 | } 50 | else { 51 | throw new Error('attributedTo must be a string or an array of strings.'); 52 | } 53 | 54 | const outboxActor = await this.objectModel.findOne({ id: outboxActorId }); 55 | 56 | if (outboxActor === null) { 57 | throw new Error(`Outbox actor not found for ${outboxActorId}.`); 58 | } 59 | 60 | const recordDto: ObjectType = await this.objectService.assignObjectMetadata({ 61 | ...dto, 62 | '@context': 'https://www.w3.org/ns/activitystreams', // note that direct assignment like dto['@context'] = '...' doesn't work 63 | id: `${outboxActor.id}/posts/${id.toString()}` 64 | }); 65 | 66 | const session = await this.objectModel.db.startSession(); 67 | session.startTransaction(); 68 | 69 | try { 70 | const obj = Array.isArray(recordDto.type) 71 | ? recordDto.type.some(type => ['Note', 'Article'].includes(type)) 72 | ? await this.objectService.createContent(recordDto) 73 | : await this.objectService.create(recordDto) 74 | : ['Note', 'Article'].includes(recordDto.type) 75 | ? await this.objectService.createContent(recordDto) 76 | : await this.objectService.create(recordDto); 77 | 78 | const activityId = this.activityService.id(); 79 | 80 | const activityDto: ActivityRecord = { 81 | '@context': 'https://www.w3.org/ns/activitystreams', 82 | id: `${outboxActor.id}/activities/${activityId.toString()}`, 83 | type: 'Create', 84 | actor: Array.isArray(dto.attributedTo) ? dto.attributedTo[0] as string : dto.attributedTo as string, 85 | object: instanceToPlain(obj), 86 | _domain: _domain, 87 | _local: true, 88 | _public: true, 89 | _raw: '' 90 | }; 91 | 92 | activityDto._raw = JSON.stringify(instanceToPlain(activityDto)); 93 | 94 | const activity = await this.activityService.create(activityDto); 95 | 96 | return activity; 97 | } 98 | catch (e) { 99 | 100 | } 101 | } 102 | 103 | /** 104 | * Return an object that is associated with this instance. 105 | * @param id 106 | * @returns 107 | */ 108 | public async getLocalObject(id: string): Promise { 109 | return this.objectService.findOne({ id, _serviceId: { $ne: null } }); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/modules/activity/service/sync-dispatch.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { SyncDispatchService } from './sync-dispatch.service'; 3 | 4 | describe('OutboxService', () => { 5 | let service: SyncDispatchService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [SyncDispatchService], 10 | }).compile(); 11 | 12 | service = module.get(SyncDispatchService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/modules/activity/service/sync-dispatch.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable, Logger, NotImplementedException, Scope } from '@nestjs/common'; 2 | import { ActivityService } from '../../activity/service/activity.service'; 3 | import { ObjectService } from '../../object/object.service'; 4 | import { ActivityPubService } from './activity-pub.service'; 5 | import { Activity, ASObject } from '@yuforium/activity-streams'; 6 | import { sign } from '@yuforium/http-signature'; 7 | import { REQUEST } from '@nestjs/core'; 8 | import { Request } from 'express'; 9 | import { PersonDto } from '../../object/dto/object/person.dto'; 10 | import { UserService } from '../../user/user.service'; 11 | import * as crypto from 'crypto'; 12 | import { OutboxService } from '../../activity/service/outbox.service'; 13 | 14 | /** 15 | * A synchronous dispatch service with no queueing. This is used for testing and development purposes only. 16 | */ 17 | @Injectable({scope: Scope.REQUEST}) 18 | export class SyncDispatchService { 19 | protected logger = new Logger(SyncDispatchService.name); 20 | 21 | constructor( 22 | protected readonly activityService: ActivityService, 23 | protected readonly objectService: ObjectService, 24 | protected readonly activityPubService: ActivityPubService, 25 | protected readonly processor: OutboxService, 26 | protected readonly userService: UserService, 27 | @Inject(REQUEST) protected readonly request: Request 28 | ) { } 29 | 30 | /** 31 | * Extract dispatch targets from an activity. These targets still need to be resolved (i.e. if the target is a collection like a followers collection). 32 | * @todo handle cc/bcc fields as well 33 | * @param activity 34 | * @returns 35 | */ 36 | protected async getDispatchTargets(activity: Activity): Promise { 37 | let to = []; 38 | 39 | if (typeof activity.object !== 'object' || activity.object.type === 'Link') { 40 | throw new NotImplementedException('link resolution is not supported'); 41 | } 42 | 43 | const obj = activity.object as ASObject; 44 | 45 | if (Array.isArray(obj.to)) { 46 | to.push(...obj.to); 47 | } 48 | else if (typeof obj.to === 'string' && obj.to) { 49 | to.push(obj.to); 50 | } 51 | 52 | const filterPredicate = async (id: any) => { 53 | if (id === 'https://www.w3.org/ns/activitystreams#Public') { 54 | return false; 55 | } 56 | 57 | // don't bother with local users 58 | if (await this.processor.getLocalObject(id.toString())) { 59 | return false; 60 | } 61 | 62 | return true; 63 | }; 64 | 65 | const filterVals = await Promise.all(to.map(val => filterPredicate(val))); 66 | 67 | to = to.filter((v, i) => filterVals[i]).map(v => v.toString()); 68 | to = await Promise.all(to.map(v => this.getInboxUrl(v))); 69 | 70 | return to; 71 | } 72 | 73 | /** 74 | * Dispatch an activity to its targets. 75 | * @param activity 76 | */ 77 | protected async dispatch(activity: Activity) { 78 | const dispatchTo = await this.getDispatchTargets(activity); 79 | 80 | dispatchTo.forEach(async to => { 81 | const response = await this.send(to, activity); 82 | return response; 83 | }); 84 | } 85 | 86 | protected async getInboxUrl(address: string): Promise { 87 | const actor = await fetch(address, {headers: {'Accept': 'application/activity+json'}}).then(res => res.json()); 88 | return actor.inbox; 89 | } 90 | 91 | /** 92 | * @todo for a production system, this would need to resolve targets (i.e. if the target was a followers collection). this would/should be done with queueing. 93 | * @param url 94 | * @param activity 95 | * @returns 96 | */ 97 | protected async send(url: string, activity: Activity): Promise { 98 | const parsedUrl = new URL(url); 99 | 100 | const actor = (this.request.user as any)?.actor as PersonDto; 101 | const username = (this.request.user as any)?.username as string; 102 | const user = await this.userService.findOne('yuforium.dev', username); 103 | const now = new Date(); 104 | const created = Math.floor(now.getTime() / 1000); 105 | const privateKey = crypto.createPrivateKey({key: user?.privateKey as string, passphrase: ''}); 106 | const body = JSON.stringify(activity); 107 | const signer = crypto.createSign('RSA-SHA256'); 108 | signer.update(body); 109 | const digest = 'SHA-256=' + crypto.createHash('sha256').update(body).digest('base64'); 110 | 111 | const opts = sign({ 112 | requestPath: parsedUrl.pathname, 113 | method: 'post', 114 | keyId: actor.publicKey?.id as string, 115 | algorithm: 'rsa-sha256', 116 | privateKey, 117 | created, 118 | expires: created + 300, 119 | headers: ['(request-target)', 'host', 'date', 'digest'], 120 | headerValues: { 121 | host: parsedUrl.host, 122 | date: now.toUTCString(), 123 | digest 124 | } 125 | }); 126 | 127 | try { 128 | const response = await fetch(url, { 129 | method: 'POST', 130 | headers: { 131 | 'content-type': 'application/activity+json', 132 | 'accept': 'application/activity+json', 133 | 'digest': digest, 134 | 'signature': `keyId="http://yuforium.dev/user/chris#main-key",headers="${opts.headers?.join(' ')}",signature="${opts.signature}"`, 135 | 'date': now.toUTCString() 136 | }, 137 | body: JSON.stringify(activity), 138 | }); 139 | if (response.ok) { 140 | this.logger.log(`send(): ${response.status} code received sending ${activity.id} to ${url}`); 141 | } 142 | else { 143 | this.logger.error(`send(): ${response.status} code received sending ${activity.id} to ${url} with response "${await response.text()}"`); 144 | } 145 | return response; 146 | } 147 | catch (err) { 148 | this.logger.error(`send(): failed delivery to ${url}`); 149 | throw err; 150 | } 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/modules/activity/validator/context.validator.ts: -------------------------------------------------------------------------------- 1 | import { ASContext } from '@yuforium/activity-streams'; 2 | import { ValidationArguments, ValidatorConstraint, ValidatorConstraintInterface } from 'class-validator'; 3 | 4 | @ValidatorConstraint({ name: 'context', async: false }) 5 | export class ContextValidator implements ValidatorConstraintInterface { 6 | /** 7 | * Validate the `@context` property of an ActivityStreams object 8 | * @param atContext The `@context` value to validate 9 | * @param _args The validation arguments 10 | * @returns `true` if the `@context` is valid, `false` otherwise 11 | */ 12 | validate(atContext: ASContext, _args: ValidationArguments) { 13 | if (typeof atContext === 'string') { 14 | return true; 15 | } 16 | 17 | return Object.entries(atContext).every(([key, val]) => { 18 | return typeof val === 'string' && typeof key === 'string'; 19 | }); 20 | } 21 | 22 | defaultMessage(_validationArguments?: ValidationArguments): string { 23 | return 'The context must be a string or an object with string values'; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/modules/auth/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yuforium/api/08bdd599ac0b713a99a9af9bf986a60a2791fed6/src/modules/auth/README.md -------------------------------------------------------------------------------- /src/modules/auth/anonymous.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Strategy } from 'passport'; 3 | import { PassportStrategy } from '@nestjs/passport'; 4 | 5 | @Injectable() 6 | export class AnonymousStrategy extends PassportStrategy(Strategy, 'anonymous') { 7 | constructor() { 8 | super(); 9 | } 10 | 11 | authenticate() { 12 | return this.success({ 13 | username: null 14 | }); 15 | } 16 | } -------------------------------------------------------------------------------- /src/modules/auth/auth.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AuthController } from './auth.controller'; 3 | import { AuthService } from './auth.service'; 4 | 5 | describe('Auth Controller', () => { 6 | let controller: AuthController; 7 | 8 | beforeEach(async () => { 9 | const module: TestingModule = await Test.createTestingModule({ 10 | controllers: [AuthController], 11 | providers: [ 12 | { 13 | provide: AuthService, 14 | useValue: {} 15 | } 16 | ], 17 | }).compile(); 18 | 19 | controller = module.get(AuthController); 20 | }); 21 | 22 | it('should be defined', () => { 23 | expect(controller).toBeDefined(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/modules/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Post, Req, UseGuards } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; 4 | import { AuthService, JwtUser } from './auth.service'; 5 | import { LoginDto } from './dto/login.dto'; 6 | import { LocalAuthGuard } from './local-auth.guard'; 7 | import { Request } from 'express'; 8 | import { UserDocument } from '../user/schemas/user.schema'; 9 | import { plainToInstance } from 'class-transformer'; 10 | import { User } from '../../common/decorators/user.decorator'; 11 | import { JwtUserActorDto } from '../user/dto/user-actor.dto'; 12 | import { ActorDto } from '../object/dto/actor/actor.dto'; 13 | 14 | @ApiTags('auth') 15 | @Controller('auth') 16 | export class AuthController { 17 | constructor(protected authService: AuthService) { } 18 | 19 | @ApiBody({type: LoginDto}) 20 | @ApiOperation({operationId: 'login'}) 21 | @UseGuards(LocalAuthGuard) 22 | @Post('login') 23 | async login(@Req() req: Request) { 24 | return {...await this.authService.login(req.user as UserDocument)}; 25 | } 26 | 27 | @ApiBearerAuth() 28 | @ApiOperation({operationId: 'profile'}) 29 | @UseGuards(AuthGuard('jwt')) 30 | @Get('profile') 31 | async profile(@User() user: JwtUser): Promise { 32 | return plainToInstance(ActorDto, user.actor); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/modules/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { forwardRef, Module } from '@nestjs/common'; 2 | import { PassportModule } from '@nestjs/passport'; 3 | import { UserModule } from '../user/user.module'; 4 | import { AuthService } from './auth.service'; 5 | import { LocalStrategy } from './local.strategy'; 6 | import { AuthController } from './auth.controller'; 7 | import { JwtModule } from '@nestjs/jwt'; 8 | import { JwtStrategy } from './jwt.strategy'; 9 | import { ConfigModule, ConfigService } from '@nestjs/config'; 10 | import { AnonymousStrategy } from './anonymous.strategy'; 11 | import { ObjectModule } from '../object/object.module'; 12 | 13 | @Module({ 14 | providers: [AuthService, LocalStrategy, JwtStrategy, AnonymousStrategy], 15 | imports: [ 16 | ObjectModule, 17 | ConfigModule, 18 | forwardRef(() => UserModule), 19 | PassportModule, 20 | JwtModule.registerAsync({ 21 | imports: [ConfigModule], 22 | inject: [ConfigService], 23 | useFactory: async (configService: ConfigService) => ({ 24 | secret: configService.get('auth.jwtSecret'), 25 | signOptions: {expiresIn: '86400s'} 26 | }) 27 | }) 28 | ], 29 | controllers: [AuthController], 30 | }) 31 | export class AuthModule {} 32 | -------------------------------------------------------------------------------- /src/modules/auth/auth.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AuthService } from './auth.service'; 3 | 4 | describe('AuthService', () => { 5 | let service: AuthService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [AuthService], 10 | }).compile(); 11 | 12 | service = module.get(AuthService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/modules/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger, UnauthorizedException } from '@nestjs/common'; 2 | import { JwtService } from '@nestjs/jwt'; 3 | import { UserService } from '../user/user.service'; 4 | import * as bcrypt from 'bcryptjs'; 5 | import { UserDocument } from '../user/schemas/user.schema'; 6 | import { instanceToPlain, plainToInstance } from 'class-transformer'; 7 | import { ObjectService } from '../object/object.service'; 8 | import { JwtUserActorDto } from '../user/dto/user-actor.dto'; 9 | 10 | export interface JwtUser { 11 | _id: string; 12 | actor: JwtUserActorDto; 13 | } 14 | 15 | @Injectable() 16 | export class AuthService { 17 | protected readonly logger = new Logger(AuthService.name); 18 | constructor( 19 | protected userService: UserService, 20 | protected jwtService: JwtService, 21 | protected objectService: ObjectService 22 | ) { } 23 | 24 | public async validateUser(serviceDomain: string, username: string, password: string): Promise { 25 | const user = await this.userService.findOne(serviceDomain, username); 26 | 27 | this.logger.debug(`Validating user "${username}@${serviceDomain}"`); 28 | 29 | if (user) { 30 | this.logger.verbose(`User "${username}@${serviceDomain}" found`); 31 | 32 | if (user.password === undefined) { 33 | throw new UnauthorizedException(); 34 | } 35 | 36 | if (await bcrypt.compare(password, user.password)) { 37 | this.logger.debug(`User "${username}@${serviceDomain}" password matches, validation succeeded`); 38 | return user; 39 | } 40 | 41 | this.logger.debug(`User "${username}@${serviceDomain}" password does not match, validation failed`); 42 | } 43 | else { 44 | this.logger.debug(`User "${username}@${serviceDomain}" not found, validation failed`); 45 | } 46 | 47 | return undefined; 48 | } 49 | 50 | public async login(user: UserDocument) { 51 | if (user.defaultIdentity === undefined) { 52 | throw new Error('User has no default identity'); 53 | } 54 | 55 | if (user.username === undefined) { 56 | throw new Error('User has no assigned username'); 57 | } 58 | 59 | const actorRecord = await this.userService.findPersonById(user.defaultIdentity); 60 | 61 | if (actorRecord === null) { 62 | throw new Error('User\'s default identity not found'); 63 | } 64 | 65 | const actor = plainToInstance(JwtUserActorDto, actorRecord, {excludeExtraneousValues: true}); 66 | actor.preferredUsername = user.username; 67 | 68 | const payload: JwtUser = { 69 | _id: user._id.toString(), 70 | actor: instanceToPlain(actor) as JwtUserActorDto 71 | }; 72 | 73 | return { 74 | access_token: this.jwtService.sign(payload), 75 | expires_in: 86400 76 | }; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/modules/auth/decorators/roles.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from '@nestjs/common'; 2 | import { Role } from '../enums/role.enum'; 3 | 4 | export const ROLES_KEY = 'roles'; 5 | export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles); -------------------------------------------------------------------------------- /src/modules/auth/dto/login.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class LoginDto { 4 | @ApiProperty() 5 | username!: string; 6 | 7 | @ApiProperty() 8 | password!: string; 9 | } -------------------------------------------------------------------------------- /src/modules/auth/enums/role.enum.ts: -------------------------------------------------------------------------------- 1 | export enum Role { 2 | User = 'user', 3 | Admin = 'admin', 4 | SuperAdmin = 'superadmin' 5 | } -------------------------------------------------------------------------------- /src/modules/auth/jwt-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | 4 | @Injectable() 5 | export class JwtAuthGuard extends AuthGuard('jwt') { } -------------------------------------------------------------------------------- /src/modules/auth/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { PassportStrategy } from '@nestjs/passport'; 4 | import { ExtractJwt, Strategy } from 'passport-jwt'; 5 | 6 | @Injectable() 7 | export class JwtStrategy extends PassportStrategy(Strategy) { 8 | constructor( 9 | protected configService: ConfigService 10 | ) { 11 | super({ 12 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 13 | ignoreExpiration: false, 14 | secretOrKey: configService.get('auth.jwtSecret') 15 | }); 16 | } 17 | 18 | async validate(payload: any) { 19 | return payload; 20 | } 21 | } -------------------------------------------------------------------------------- /src/modules/auth/local-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | 4 | @Injectable() 5 | export class LocalAuthGuard extends AuthGuard('local') { } -------------------------------------------------------------------------------- /src/modules/auth/local.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger, UnauthorizedException } from '@nestjs/common'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { Strategy } from 'passport-local'; 4 | import { AuthService } from './auth.service'; 5 | import { resolveDomain } from '../../common/decorators/service-domain.decorator'; 6 | 7 | @Injectable() 8 | export class LocalStrategy extends PassportStrategy(Strategy) { 9 | protected readonly logger: Logger = new Logger(LocalStrategy.name); 10 | 11 | constructor(protected authService: AuthService) { 12 | super({passReqToCallback: true}); 13 | } 14 | 15 | public async validate(req: any, username: string, password: string) { 16 | const serviceId = resolveDomain(req.hostname); 17 | 18 | if (typeof serviceId !== 'string') { 19 | throw new Error('not a valid domain name'); 20 | } 21 | 22 | this.logger.verbose(`Validating user "${username}@${serviceId}"`); 23 | const user = await this.authService.validateUser(serviceId, username, password); 24 | 25 | if (!user) { 26 | this.logger.debug(`User "${username}" for service "${serviceId}" validation failed`); 27 | throw new UnauthorizedException(); 28 | } 29 | 30 | return user; 31 | } 32 | } -------------------------------------------------------------------------------- /src/modules/forum/controller/forum-content.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { ForumContentController } from './forum-content.controller'; 3 | 4 | describe('ForumContentController', () => { 5 | let controller: ForumContentController; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [ForumContentController], 10 | }).compile(); 11 | 12 | controller = module.get(ForumContentController); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/modules/forum/controller/forum-content.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Logger, NotFoundException, Param, Query } from '@nestjs/common'; 2 | import { ApiExtraModels, ApiOkResponse, ApiOperation, ApiParam, ApiQuery, ApiTags, getSchemaPath } from '@nestjs/swagger'; 3 | import { ServiceDomain } from '../../../common/decorators/service-domain.decorator'; 4 | import { ForumService } from '../forum.service'; 5 | import { ForumParams } from '../dto/forum-params.dto'; 6 | import { plainToInstance } from 'class-transformer'; 7 | import { ObjectService } from '../../object/object.service'; 8 | import { StoredObjectResolver } from '../../object/resolver/stored-object.resolver'; 9 | import { ObjectDto } from '../../object/dto/object.dto'; 10 | import { ContentQueryOptionsDto } from '../../object/dto/content-query-options.dto'; 11 | import { OrderedCollectionPageDto } from '../../object/dto/collection/ordered-collection-page.dto'; 12 | 13 | /** 14 | * Structure for the forum content controller response. 15 | * 16 | * ```json 17 | * { 18 | * "id": "https://example.com/forums/1/content?page=1", 19 | * "type": "OrderedCollectionPage", 20 | * "items": [ 21 | * { 22 | * "url": [ 23 | * "https://example.com/forums/1/content/1" 24 | * ] 25 | * } 26 | * ] 27 | * } 28 | */ 29 | @ApiTags('forum') 30 | @Controller('forums/:forumname/content') 31 | export class ForumContentController { 32 | protected readonly logger: Logger = new Logger(this.constructor.name); 33 | 34 | constructor( 35 | protected readonly forumService: ForumService, 36 | protected readonly objectService: ObjectService, 37 | protected readonly resolver: StoredObjectResolver 38 | ) { } 39 | 40 | @ApiOperation({operationId: 'getForumContent', summary: 'Get forum content'}) 41 | @ApiParam({name: 'forumname', type: String, required: true, example: 'test-forum'}) 42 | @ApiExtraModels(ContentQueryOptionsDto, OrderedCollectionPageDto) 43 | @ApiQuery({ 44 | name: 'contentQuery', 45 | required: false, 46 | type: 'ContentQueryOptionsDto', 47 | style: 'deepObject', 48 | schema: { 49 | $ref: getSchemaPath(ContentQueryOptionsDto) 50 | } 51 | }) 52 | @ApiOkResponse({description: 'Forum content', type: OrderedCollectionPageDto}) 53 | @Get() 54 | public async getPosts( 55 | @ServiceDomain() domain: string, 56 | @Param() params: ForumParams, 57 | @Query('contentQuery') query: ContentQueryOptionsDto 58 | ): Promise { 59 | const collectionPage = new OrderedCollectionPageDto(); 60 | const forumId = `https://${domain}/forums/${params.forumname}`; 61 | 62 | query = query || new ContentQueryOptionsDto(); 63 | 64 | this.logger.debug(`getContent for forum ${params.forumname}@${domain}`); 65 | 66 | const forum = await this.objectService.findById(forumId); 67 | 68 | if (!forum) { 69 | this.logger.error(`getContent() forum not found for ${params.forumname}@${domain}`); 70 | throw new NotFoundException(); 71 | } 72 | 73 | const queryParams = { 74 | inReplyTo: null, 75 | $or: [ 76 | {'_attribution.id': forum.id, '_attribution.rel': 'attributedTo', '_public': true}, 77 | {'_attribution.id': forum.id, '_attribution.rel': 'to', '_public': true} 78 | ] 79 | } 80 | 81 | const opts = { 82 | skip: query.skip, 83 | limit: query.limit, 84 | sort: query.sort, 85 | }; 86 | 87 | // const posts = await this.forumService.getContent(domain, params.forumname, opts); 88 | 89 | const {data, totalItems} = await this.objectService.getContentPage(queryParams, opts); 90 | // const items = data.map(item => plainToInstance(ObjectDto, item, {excludeExtraneousValues: true, exposeUnsetFields: false})); 91 | const items = data; 92 | 93 | await Promise.all(items.map(item => this.objectService.resolveFields(item, ['attributedTo', 'audience']))); 94 | 95 | Object.assign(collectionPage, { 96 | items, 97 | totalItems: totalItems 98 | }); 99 | 100 | collectionPage.id = `https://${domain}/forums/${params.forumname}/content/page/1`; 101 | 102 | /** 103 | * @todo it is questionable whether or not we should allow a page size 104 | * param, because it affects the pagination of the forum content, and 105 | * therefore the ID that would be returned by this endpoint. That 106 | * being said, if the `size` param matches the default page size, 107 | * return a URL that would match the default page size. 108 | */ 109 | const page = Object.assign(new OrderedCollectionPageDto(), { 110 | id: `https://${domain}/forums/${params.forumname}/content/page/1`, 111 | items 112 | }); 113 | 114 | return page; 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/modules/forum/controller/forum-inbox.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { ForumInboxController } from './forum-inbox.controller'; 3 | 4 | describe('InboxController', () => { 5 | let controller: ForumInboxController; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [ForumInboxController], 10 | }).compile(); 11 | 12 | controller = module.get(ForumInboxController); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/modules/forum/controller/forum-inbox.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Get, HttpCode, HttpStatus, Logger, NotImplementedException, Param, Post, Req } from '@nestjs/common'; 2 | import { ApiOperation, ApiTags } from '@nestjs/swagger'; 3 | import { ActivityService } from '../../../modules/activity/service/activity.service'; 4 | import { Request } from 'express'; 5 | import { ForumParams } from '../dto/forum-params.dto'; 6 | 7 | @ApiTags('forum') 8 | @Controller('forums/:forumname/inbox') 9 | export class ForumInboxController { 10 | protected logger = new Logger(ForumInboxController.name); 11 | 12 | constructor(protected readonly activityService: ActivityService) {} 13 | 14 | @ApiOperation({operationId: 'getForumInbox'}) 15 | @Get() 16 | public async getForumInbox( 17 | @Param() params: ForumParams 18 | ) { 19 | this.logger.debug(`Received GET request for forum inbox ${params.forumname}`); 20 | throw new NotImplementedException(); 21 | } 22 | 23 | @ApiOperation({operationId: 'postForumInbox'}) 24 | @Post() 25 | @HttpCode(HttpStatus.ACCEPTED) 26 | public async postForumInbox( 27 | @Req() request: Request, 28 | @Param() params: ForumParams, 29 | @Body() activity: any 30 | ) { 31 | this.logger.debug(`Received "${activity.type}" activity from ${request.socket.remoteAddress}`); 32 | const receipt = await this.activityService.process(activity); 33 | 34 | return { 35 | status: 'accepted', 36 | message: 'The activity was queued for processing.', 37 | receipt 38 | }; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/modules/forum/controller/forum-outbox.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { ForumOutboxController } from './forum-outbox.controller'; 3 | 4 | describe('ForumOutboxController', () => { 5 | let controller: ForumOutboxController; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [ForumOutboxController], 10 | }).compile(); 11 | 12 | controller = module.get(ForumOutboxController); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/modules/forum/controller/forum-outbox.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Get, Logger, NotFoundException, NotImplementedException, Param, Post, UnauthorizedException, UseGuards } from '@nestjs/common'; 2 | import { ApiBearerAuth, ApiBody, ApiExtraModels, ApiOperation, ApiTags, getSchemaPath } from '@nestjs/swagger'; 3 | import { ActivityService } from '../../../modules/activity/service/activity.service'; 4 | import { ObjectService } from '../../../modules/object/object.service'; 5 | import { ForumParams } from '../dto/forum-params.dto'; 6 | import { ServiceDomain } from '../../../common/decorators/service-domain.decorator'; 7 | import { User } from '../../../common/decorators/user.decorator'; 8 | import { JwtUser } from '../../../modules/auth/auth.service'; 9 | import { ActivityStreamsPipe } from '../../../common/pipes/activity-streams.pipe'; 10 | import { ObjectCreateTransformer } from '../../../common/transformer/object-create.transformer'; 11 | import { ActivityDto } from '../../../modules/activity/dto/activity.dto'; 12 | import { ObjectCreateDto } from '../../object/dto/object-create/object-create.dto'; 13 | import { NoteCreateDto } from '../../object/dto/object-create/note-create.dto'; 14 | import { AuthGuard } from '@nestjs/passport'; 15 | import { OutboxService } from '../../activity/service/outbox.service'; 16 | 17 | /** 18 | * Forum Outbox Controller 19 | * Note that this controller has a lot of overlap to the UserOutboxController. This functionality can probably be merged into a 20 | * parent class. 21 | */ 22 | @Controller('forums/:forumname/outbox') 23 | @ApiTags('forum') 24 | export class ForumOutboxController { 25 | protected readonly logger = new Logger(ForumOutboxController.name); 26 | 27 | constructor( 28 | protected readonly activityService: ActivityService, 29 | protected readonly objectService: ObjectService, 30 | protected readonly outboxService: OutboxService 31 | ) { } 32 | 33 | @ApiBearerAuth() 34 | @ApiBody({schema: {oneOf: [{$ref: getSchemaPath(NoteCreateDto)}]}}) 35 | @ApiExtraModels(NoteCreateDto) 36 | @ApiOperation({operationId: 'postForumOutbox', summary: 'Post to a forum outbox'}) 37 | @UseGuards(AuthGuard('jwt')) 38 | @Post() 39 | public async postForumOutbox( 40 | @Param() params: ForumParams, 41 | @ServiceDomain() domain: string, 42 | @User() user: JwtUser, 43 | @Body(new ActivityStreamsPipe(ObjectCreateTransformer)) dto: ObjectCreateDto | NoteCreateDto 44 | ) { 45 | if (dto instanceof ActivityDto) { 46 | throw new NotImplementedException('Activity objects are not supported at this time.'); 47 | } 48 | 49 | const forumId = `https://${domain}/forums/${params.forumname}`; 50 | const forum = await this.objectService.get(forumId); 51 | 52 | if (!forum) { 53 | throw new NotFoundException(`Forum ${forumId} not found.`); 54 | } 55 | 56 | const actorRecord = await this.objectService.get(user.actor.id); 57 | 58 | // @todo - auth should be done via decorator on the class method 59 | if (!actorRecord || actorRecord.type === 'Tombstone') { 60 | this.logger.error(`Unauthorized access to forum outbox by ${user.actor.id}`); 61 | throw new UnauthorizedException('You are not authorized to post to this outbox.'); 62 | } 63 | 64 | this.logger.debug(`postOutbox(): ${user.actor.preferredUsername} is posting to ${params.forumname}'s outbox`); 65 | 66 | // @todo document how and why to/cc are set for various targets 67 | // see also https://github.com/mastodon/mastodon/issues/8067 and https://github.com/mastodon/mastodon/pull/3844#issuecomment-314897285 68 | const createDto = { 69 | ...dto, 70 | '@context': 'https://www.w3.org/ns/activitystreams', 71 | attributedTo: [forumId, user.actor.id], // @todo document that attributedTo is an array with the first element being the outbox, last being the actual person 72 | published: new Date().toISOString(), 73 | context: `https://yuforium.com/community/${params.forumname}`, // @todo replace with whatever the forum has as its context 74 | to: ['https://www.w3.org/ns/activitystreams#Public'], 75 | cc: [`${forumId}/followers`], // cc: is most appropriate for federation 76 | audience: forumId // represents the primary audience for the post. In Yuforium, this is the forum, and not the context which would be considered as a wider scope than the audience 77 | }; 78 | 79 | const activity = await this.outboxService.createObject(domain, user, forumId, createDto); 80 | 81 | return activity; 82 | } 83 | 84 | @ApiOperation({operationId: 'getForumOutbox', summary: 'Get a forum outbox'}) 85 | @Get() 86 | public getOutbox(@ServiceDomain() _domain: string, @Param() _params: ForumParams) { 87 | throw new NotImplementedException('Not implemented'); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/modules/forum/controller/forum.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { ForumController } from './forum.controller'; 3 | 4 | describe('Forums Controller', () => { 5 | let controller: ForumController; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [ForumController], 10 | }).compile(); 11 | 12 | controller = module.get(ForumController); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/modules/forum/controller/forum.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Param, NotFoundException, Header, Post, Body } from '@nestjs/common'; 2 | import { ApiExtraModels, ApiNotFoundResponse, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; 3 | import { plainToClass, plainToInstance } from 'class-transformer'; 4 | import { ObjectService } from '../../object/object.service'; 5 | import { ServiceDomain } from '../../../common/decorators/service-domain.decorator'; 6 | import { ForumDto } from '../dto/forum.dto'; 7 | import { ForumCollectionDto } from '../dto/forum-collection.dto'; 8 | import { ForumParams } from '../dto/forum-params.dto'; 9 | import { ForumCreateDto } from '../../object/dto/forum-create.dto'; 10 | import { ObjectDocument } from '../../../modules/object/schema/object.schema'; 11 | import { ActorDto } from '../../object/dto/actor/actor.dto'; 12 | import { ObjectDto } from '../../object/dto/object.dto'; 13 | 14 | @ApiTags('forum') 15 | @Controller('forums') 16 | export class ForumController { 17 | constructor( 18 | protected readonly objectService: ObjectService 19 | ) { } 20 | 21 | @ApiOperation({operationId: 'findForums', summary: 'Find forums'}) 22 | @Get() 23 | @Header('Content-Type', 'application/activity+json') 24 | public async findForums(@ServiceDomain() _domain: string) { 25 | const forums = await this.objectService.find({type: 'Forum', _domain}); 26 | const collection = new ForumCollectionDto(); 27 | 28 | collection.items = forums.map((item: ObjectDocument) => plainToInstance(ForumDto, item)); 29 | 30 | return collection; 31 | } 32 | 33 | @ApiOperation({operationId: 'createForum', summary: 'Create a forum'}) 34 | @Post() 35 | @Header('Content-Type', 'application/activity+json') 36 | public async create(@ServiceDomain() domain: string, @Body() forumCreateDto: ForumCreateDto): Promise { 37 | const forum = await this.objectService.create({ 38 | ...forumCreateDto, 39 | '@context': [ 40 | 'https://www.w3.org/ns/activitystreams', 41 | 'https://w3id.org/security/v1', 42 | 'https://yuforium.org/ns/activitystreams' 43 | ], 44 | id: `https://${domain}/forums/${forumCreateDto.name}`, 45 | type: ['Service', 'Forum'] 46 | }); 47 | 48 | return forum as ActorDto; 49 | } 50 | 51 | @ApiOperation({operationId: 'getForum', summary: 'Get a forum'}) 52 | @ApiOkResponse({status: 200, description: 'Forum found', type: ActorDto}) 53 | @ApiNotFoundResponse({status: 404, description: 'Forum not found'}) 54 | @ApiExtraModels(ActorDto) 55 | @Get(':forumname') 56 | @Header('Content-Type', 'application/activity+json') 57 | public async findOne( 58 | @ServiceDomain() domainId: string, 59 | @Param() params: ForumParams 60 | ): Promise { 61 | const id = `https://${domainId}/forums/${params.forumname}`; 62 | const forum = await this.objectService.get(id); 63 | 64 | if (!forum) { 65 | throw new NotFoundException(`Forum ${params.forumname} not found.`); 66 | // @todo consider autocreating a forum if it doesn't exist 67 | // const tempForum = new ForumDto(); 68 | // tempForum.id = `https://${serviceId}/forum/${params.pathId}`; 69 | // tempForum.name = `${params.pathId}`; 70 | // tempForum.summary = 'This forum has not been allocated yet.'; 71 | // return tempForum; 72 | } 73 | 74 | return plainToClass(ActorDto, forum); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/modules/forum/dto/forum-collection.dto.ts: -------------------------------------------------------------------------------- 1 | import { ActivityStreams, ASObject } from '@yuforium/activity-streams'; 2 | import { Expose, Type } from 'class-transformer'; 3 | import { ForumDto } from './forum.dto'; 4 | 5 | export class ForumCollectionDto extends ActivityStreams.collection('OrderedCollection') { 6 | static type: 'OrderedCollection'; 7 | 8 | @Expose({name: 'items'}) 9 | @Type(() => ForumDto) 10 | items: ASObject[] = []; 11 | } -------------------------------------------------------------------------------- /src/modules/forum/dto/forum-params.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Transform } from 'class-transformer'; 3 | import { Matches, IsString } from 'class-validator'; 4 | 5 | export class ForumParams { 6 | @ApiProperty({type: 'string', required: true}) 7 | @Matches(/^[a-z](?:-?[a-z0-9]+){3,255}$/i) 8 | @IsString() 9 | @Transform(({value}: {value: string}) => value.toLowerCase()) 10 | forumname!: string; 11 | } -------------------------------------------------------------------------------- /src/modules/forum/dto/forum.dto.ts: -------------------------------------------------------------------------------- 1 | import { Expose } from 'class-transformer'; 2 | import { ObjectDto } from '../../object/dto/object.dto'; 3 | 4 | export class ForumDto extends ObjectDto { 5 | @Expose() 6 | name: string | undefined; 7 | 8 | @Expose() 9 | summary: string | undefined; 10 | 11 | @Expose({name: 'inbox'}) 12 | getInbox(): string { 13 | return `${this.id}/inbox`; 14 | } 15 | 16 | @Expose() 17 | get outbox(): string { 18 | return `${this.id}/outbox`; 19 | } 20 | 21 | @Expose() 22 | get followers(): string { 23 | return `${this.id}/followers`; 24 | } 25 | 26 | @Expose() 27 | get following(): string { 28 | return `${this.id}/following`; 29 | } 30 | 31 | liked: string | undefined; 32 | } 33 | -------------------------------------------------------------------------------- /src/modules/forum/forum.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ForumController } from './controller/forum.controller'; 3 | import { ObjectModule } from '../object/object.module'; 4 | import { ActivityModule } from '../activity/activity.module'; 5 | import { ForumService } from './forum.service'; 6 | import { ForumInboxController } from './controller/forum-inbox.controller'; 7 | import { ForumOutboxController } from './controller/forum-outbox.controller'; 8 | import { ForumContentController } from './controller/forum-content.controller'; 9 | 10 | @Module({ 11 | controllers: [ 12 | ForumController, 13 | ForumInboxController, 14 | ForumOutboxController, 15 | ForumContentController 16 | ], 17 | 18 | imports: [ 19 | ObjectModule, 20 | ActivityModule 21 | ], 22 | 23 | providers: [ForumService] 24 | }) 25 | export class ForumModule { 26 | 27 | } 28 | -------------------------------------------------------------------------------- /src/modules/forum/forum.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { ForumService } from './forum.service'; 3 | import { ObjectService } from '../object/object.service'; 4 | import { getModelToken } from '@nestjs/mongoose'; 5 | import { ActorRecord } from '../object/schema/actor.schema'; 6 | 7 | describe('ForumService', () => { 8 | let service: ForumService; 9 | 10 | beforeEach(async () => { 11 | const module: TestingModule = await Test.createTestingModule({ 12 | providers: [ 13 | ForumService, 14 | { 15 | provide: ObjectService, 16 | useValue: {}, 17 | }, 18 | { 19 | provide: getModelToken(ActorRecord.name), 20 | useValue: {}, 21 | } 22 | ], 23 | }).compile(); 24 | 25 | service = module.get(ForumService); 26 | }); 27 | 28 | it('should be defined', () => { 29 | expect(service).toBeDefined(); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /src/modules/forum/forum.service.ts: -------------------------------------------------------------------------------- 1 | import { ConflictException, Injectable } from '@nestjs/common'; 2 | import { ObjectService } from '../object/object.service'; 3 | import { InjectModel } from '@nestjs/mongoose'; 4 | import { ActorDocument, ActorRecord } from '../object/schema/actor.schema'; 5 | import { Model } from 'mongoose'; 6 | import { ForumCreateDto } from '../object/dto/forum-create.dto'; 7 | 8 | export type ForumQueryOpts = { 9 | skip?: number; 10 | limit?: number; 11 | } 12 | 13 | @Injectable() 14 | export class ForumService { 15 | constructor( 16 | protected readonly objectService: ObjectService, 17 | @InjectModel(ActorRecord.name) protected readonly actorModel: Model 18 | ) { } 19 | 20 | public async create(_domain: string, forumCreateDto: ForumCreateDto): Promise { 21 | const dto = { 22 | '@context': [ 23 | 'https://www.w3.org/ns/activitystreams', 24 | 'https://yuforium.org/ns/activitystreams' 25 | ], 26 | id: `https://${_domain}/forums/${forumCreateDto.pathId}`, 27 | preferredUsername: forumCreateDto.pathId, 28 | name: forumCreateDto.name, 29 | summary: forumCreateDto.summary, 30 | _domain, 31 | type: ['Service', 'Forum'], 32 | _public: true, 33 | _local: true 34 | }; 35 | 36 | try { 37 | const forum = await this.actorModel.create(dto); 38 | return forum; 39 | } 40 | catch (e: any) { 41 | if (e.code === 11000) { 42 | throw new ConflictException(`Forum "${forumCreateDto.pathId}" already exists.`); 43 | } 44 | 45 | throw e; 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/modules/object/dto/actor/actor.dto.ts: -------------------------------------------------------------------------------- 1 | import { Prop } from '@nestjs/mongoose'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | import { Expose } from 'class-transformer'; 4 | import { IsAlphanumeric, IsNotEmpty, IsString, MaxLength, MinLength } from 'class-validator'; 5 | import { Schema } from 'mongoose'; 6 | import { BaseObjectDto } from '../base-object.dto'; 7 | 8 | /** 9 | * Yuforium's base Actor type diverges from its base Object type in that it 10 | * adds a preferredusername field and helper methods to for values such as 11 | * inbox and outbox. Multiple types are also supported. 12 | * 13 | * Usage of the @Prop decorator is required for Mongoose to properly save 14 | * an Activity Streams field into the database. Any AS field that is not 15 | * decordated with @Prop will not be saved (note the absence of the `to` 16 | * field below). 17 | */ 18 | export class ActorDto extends BaseObjectDto { 19 | @ApiProperty({ 20 | oneOf: [{type: 'string'}, {type: 'array', items: {type: 'string'}}], 21 | description: 'The context of the actor, multiple supported' 22 | }) 23 | @Expose() 24 | @Prop({type: Schema.Types.Mixed, required: true}) 25 | public '@context': string | string[] = 'https://www.w3.org/ns/activitystreams'; 26 | 27 | @ApiProperty({ 28 | oneOf: [{type: 'string'}, {type: 'array', items: {type: 'string'}}], 29 | description: 'The type of the actor, multiple supported' 30 | }) 31 | @Expose() 32 | @Prop({type: Schema.Types.Mixed, required: true}) 33 | public type!: string | string[]; 34 | 35 | @ApiProperty({type: 'string', description: 'The name of the actor'}) 36 | @Expose() 37 | @Prop({type: String, required: true}) 38 | public name!: string; 39 | 40 | @ApiProperty({type: 'string', description: 'A descriptive summary of the actor'}) 41 | @Expose() 42 | @Prop({type: String, required: false}) 43 | public summary?: string; 44 | 45 | @ApiProperty({type: 'string', description: 'The preferred username of the actor, used in mentions and other places'}) 46 | @Expose() 47 | @Prop({type: String, required: true}) 48 | @IsNotEmpty() 49 | @IsString() 50 | @IsAlphanumeric() 51 | @MinLength(5) 52 | @MaxLength(64) 53 | public preferredUsername!: string; 54 | 55 | @Expose() 56 | @ApiProperty({type: 'string', description: 'The ID of the actor'}) 57 | @Prop({type: String, required: true}) 58 | public id!: string; 59 | 60 | @Expose() 61 | @Prop({type: Schema.Types.Mixed, required: false}) 62 | public publicKey!: {id: string, owner: string, publicKeyPem: string}; 63 | 64 | @Expose({toPlainOnly: true}) 65 | public get inbox(): string { 66 | return `${this.id}/inbox`; 67 | } 68 | 69 | @Expose({toPlainOnly: true}) 70 | public get outbox(): string { 71 | return `${this.id}/outbox`; 72 | } 73 | 74 | @Expose({toPlainOnly: true}) 75 | public get followers(): string { 76 | return `${this.id}/followers`; 77 | } 78 | 79 | @Expose({toPlainOnly: true}) 80 | public get following(): string { 81 | return `${this.id}/following`; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/modules/object/dto/base-object.dto.ts: -------------------------------------------------------------------------------- 1 | import { Prop } from '@nestjs/mongoose'; 2 | import { ApiExtraModels, ApiHideProperty, ApiProperty } from '@nestjs/swagger'; 3 | import { ActivityStreams, Link } from '@yuforium/activity-streams'; 4 | import { Expose } from 'class-transformer'; 5 | import * as mongoose from 'mongoose'; 6 | 7 | const { Mixed } = mongoose.Schema.Types; 8 | 9 | /** 10 | * Helper object for defining a property that may be a string or array of strings 11 | */ 12 | const ApiPropertyOneOfStringOrArray = { 13 | required: false, 14 | oneOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }] 15 | }; 16 | 17 | /** 18 | * ObjectDto 19 | * The Object DTO extends the ActivityStreams base object type and adds 20 | * decorators for Mongoose and any custom validation overrides, including 21 | * what fields are exposed on a response (@Expose()) and OpenAPI response 22 | * decorators. 23 | * 24 | * In addition, the ObjectDto provides some static helper functions to 25 | * do things like normalize IDs or resolve link references to objects. 26 | */ 27 | @ApiExtraModels(Link) 28 | export class BaseObjectDto extends ActivityStreams.object('Object') { 29 | @ApiHideProperty() 30 | @Prop({ 31 | name: '@context', 32 | type: mongoose.Schema.Types.Mixed, 33 | required: false 34 | }) 35 | @Expose() 36 | public '@context': string | string[] = 'https://www.w3.org/ns/activitystreams'; 37 | 38 | @ApiProperty({ required: true, type: 'string' }) 39 | @Prop({ type: String, required: true }) 40 | @Expose() 41 | public id!: string; 42 | 43 | /** 44 | * @todo for validation, the first type should be a string of any supported type 45 | */ 46 | @ApiProperty({ type: 'string', required: true }) 47 | @Prop({ type: Mixed, required: true }) 48 | @Expose() 49 | public type!: string | string[]; 50 | 51 | @ApiProperty({ type: String }) 52 | @Prop({ type: Mixed }) 53 | @Expose() 54 | public context?: string | string[]; 55 | 56 | @ApiProperty({ 57 | required: false, 58 | type: 'string', 59 | oneOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }] 60 | }) 61 | @Prop({ type: String }) 62 | @Expose() 63 | public name?: string; 64 | 65 | @ApiProperty({ type: String }) 66 | @Prop({ type: String }) 67 | @Expose() 68 | public published?: string; 69 | 70 | @Prop({ type: String }) 71 | @Expose() 72 | public updated?: string; 73 | 74 | @ApiProperty(ApiPropertyOneOfStringOrArray) 75 | @Prop({ type: Mixed }) 76 | @Expose() 77 | public audience?: string | string[]; 78 | 79 | /** 80 | * @todo This belongs in a separate Actor model 81 | */ 82 | @Prop({ type: Mixed }) 83 | @Expose() 84 | public publicKey?: { 85 | id: string; 86 | owner: string; 87 | publicKeyPem: string; 88 | }; 89 | } 90 | -------------------------------------------------------------------------------- /src/modules/object/dto/collection/ordered-collection-page.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiExtraModels, ApiProperty } from '@nestjs/swagger'; 2 | import { ActivityStreams, Link } from '@yuforium/activity-streams'; 3 | import { ObjectDto } from '../object.dto'; 4 | 5 | @ApiExtraModels(ObjectDto, Link) 6 | export class OrderedCollectionPageDto extends ActivityStreams.collectionPage('OrderedCollectionPage') { 7 | static readonly type = 'OrderedCollectionPage'; 8 | 9 | @ApiProperty({type: 'integer'}) 10 | public totalItems!: number; 11 | 12 | @ApiProperty({type: [ObjectDto]}) 13 | public items!: (string | ObjectDto | Link)[]; 14 | } 15 | -------------------------------------------------------------------------------- /src/modules/object/dto/content-query-options.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiPropertyOptional } from '@nestjs/swagger'; 2 | import { Transform } from 'class-transformer'; 3 | import { IsInt, IsOptional } from 'class-validator'; 4 | 5 | /** 6 | * Base class that can be used or extended by controllers for querying content. 7 | */ 8 | export class ContentQueryOptionsDto { 9 | @ApiPropertyOptional({ 10 | name: 'type', 11 | type: 'string', 12 | format: 'form', 13 | default: 'All', 14 | description: 15 | 'Comma separated list of Activity Streams object types to include in the response.', 16 | example: 'Note,Article' 17 | }) 18 | @IsOptional() 19 | public type: string | undefined; 20 | 21 | @ApiPropertyOptional({ 22 | name: 'skip', 23 | type: 'integer', 24 | format: 'form', 25 | default: 0, 26 | description: 27 | 'The number of items to skip before returning the remaining items.' 28 | }) 29 | @Transform(({ value }) => parseInt(value, 10)) 30 | @IsInt() 31 | public skip: number = 0; 32 | 33 | @ApiPropertyOptional({ 34 | name: 'limit', 35 | type: 'integer', 36 | format: 'form', 37 | default: 20, 38 | description: 'The maximum number of items to return.', 39 | example: 1 40 | }) 41 | @Transform(({ value }) => parseInt(value, 10)) 42 | @IsInt() 43 | public limit: number = 20; 44 | 45 | @ApiPropertyOptional({ 46 | name: 'sort', 47 | type: 'string', 48 | default: '-published', 49 | format: 'form', 50 | description: 'The sort order of the returned items.', 51 | example: 'published' 52 | }) 53 | @IsOptional() 54 | public sort: string = '-published'; 55 | } 56 | -------------------------------------------------------------------------------- /src/modules/object/dto/forum-create.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString, MaxLength } from 'class-validator'; 2 | import { ApiProperty, PartialType, PickType } from '@nestjs/swagger'; 3 | import { IsRequired, Service } from '@yuforium/activity-streams'; 4 | 5 | export class ForumCreateDto extends PartialType(PickType(Service, ['name', 'summary', 'type']) 6 | ) { 7 | @ApiProperty() 8 | @IsString() 9 | @IsRequired() 10 | public pathId: string | undefined; 11 | 12 | @MaxLength(256) 13 | @ApiProperty() 14 | @IsRequired() 15 | public name: string | undefined; 16 | 17 | @MaxLength(4096) 18 | @ApiProperty() 19 | public summary: string | undefined; 20 | } -------------------------------------------------------------------------------- /src/modules/object/dto/link.dto.ts: -------------------------------------------------------------------------------- 1 | import { ActivityStreams, ASLink } from '@yuforium/activity-streams'; 2 | import { Expose } from 'class-transformer'; 3 | 4 | export class LinkDto extends ActivityStreams.link('Link') implements ASLink { 5 | @Expose() 6 | type = 'Link'; 7 | 8 | @Expose() 9 | href!: string; 10 | 11 | toValue() { 12 | return this.href; 13 | } 14 | } -------------------------------------------------------------------------------- /src/modules/object/dto/object-create/article-create.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Equals } from 'class-validator'; 3 | import { ObjectCreateDto } from './object-create.dto'; 4 | 5 | export class ArticleCreateDto extends ObjectCreateDto { 6 | static readonly type = 'Article'; 7 | 8 | @Equals('Article') 9 | @ApiProperty({type: 'string', enum: ['Article']}) 10 | public type: string = 'Article'; 11 | } 12 | -------------------------------------------------------------------------------- /src/modules/object/dto/object-create/note-create.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Equals, MaxLength } from 'class-validator'; 3 | import { ObjectCreateDto } from './object-create.dto'; 4 | 5 | export class NoteCreateDto extends ObjectCreateDto { 6 | static readonly type = 'Note'; 7 | 8 | @MaxLength(65536) // we could make this 500 to make it compatible with Mastodon, but also create a separate `TootDto` for Mastodon compatibility 9 | public content!: string; 10 | 11 | @Equals('Note') 12 | @ApiProperty({enum: ['Note']}) 13 | public type: string = 'Note'; 14 | } 15 | -------------------------------------------------------------------------------- /src/modules/object/dto/object-create/object-create.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty, PickType } from '@nestjs/swagger'; 2 | import { IsLink, IsNotEmptyArray, IsRequired } from '@yuforium/activity-streams'; 3 | import { Transform } from 'class-transformer'; 4 | import { Equals, IsOptional, IsString, MaxLength } from 'class-validator'; 5 | import { ObjectDto } from '../object.dto'; 6 | import sanitizeHtml from 'sanitize-html'; 7 | 8 | const allowedTags = sanitizeHtml.defaults.allowedTags.concat(['strong']); 9 | 10 | /** 11 | * Basic requirements class for all objects submitted for *creation* to the 12 | * API server by a user through the outbox. This is a generic class that should be extended 13 | * by more specific object types. 14 | */ 15 | export class ObjectCreateDto extends PickType(ObjectDto, ['name', 'content', 'type', 'to', 'inReplyTo']) { 16 | @ApiProperty({ required: false }) 17 | @IsString() 18 | @MaxLength(255) 19 | public name!: string; 20 | 21 | @ApiProperty({ required: true }) 22 | @IsString() 23 | @IsRequired() 24 | @MaxLength(65536) 25 | @Transform(({value}) => sanitizeHtml(value, {allowedTags}), { 26 | toClassOnly: true, 27 | // note that we _will_ sanitize HTML when a user posts it to their outbox to prevent XSS attacks. 28 | // different AP software may santize differently, so whatever we store should be the raw content, 29 | // and we should sanitize on the way out. 30 | groups: ['outbox'] 31 | }) 32 | public content!: string; 33 | 34 | @ApiProperty({ required: true, enum: ['Object'] }) 35 | @IsString() 36 | @IsRequired() 37 | @Equals('Object') 38 | public type!: string; 39 | 40 | @ApiProperty({required: true}) 41 | @IsNotEmptyArray() 42 | @IsLink({each: true}) 43 | public to!: string | string[]; 44 | 45 | @ApiProperty({ 46 | required: false, 47 | oneOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }] 48 | }) 49 | @IsOptional() 50 | @IsString({ each: true }) 51 | public attributedTo?: string | string[]; 52 | 53 | @ApiProperty({ 54 | required: false, 55 | type: 'string' 56 | }) 57 | @IsOptional() 58 | @IsLink() 59 | public inReplyTo?: string; 60 | } 61 | -------------------------------------------------------------------------------- /src/modules/object/dto/object.dto.ts: -------------------------------------------------------------------------------- 1 | import { Prop } from '@nestjs/mongoose'; 2 | import { ApiExtraModels, ApiProperty, getSchemaPath } from '@nestjs/swagger'; 3 | import { 4 | ASObjectOrLink, 5 | Collection, 6 | Link 7 | } from '@yuforium/activity-streams'; 8 | import { Expose, Transform } from 'class-transformer'; 9 | import * as mongoose from 'mongoose'; 10 | import sanitizeHtml from 'sanitize-html'; 11 | import { BaseObjectDto } from './base-object.dto'; 12 | 13 | const { Mixed } = mongoose.Schema.Types; 14 | 15 | /** 16 | * Helper object for defining a property that may be a string or array of strings 17 | */ 18 | const ApiPropertyOneOfStringOrArray = { 19 | required: false, 20 | oneOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }] 21 | }; 22 | 23 | /** 24 | * ObjectDto 25 | * The Object DTO extends the ActivityStreams base object type and adds 26 | * decorators for Mongoose and any custom validation overrides, including 27 | * what fields are exposed on a response (@Expose()) and OpenAPI response 28 | * decorators. 29 | * 30 | * In addition, the ObjectDto provides some static helper functions to 31 | * do things like normalize IDs or resolve link references to objects. 32 | */ 33 | @ApiExtraModels(Link) 34 | export class ObjectDto extends BaseObjectDto { 35 | @ApiProperty({ 36 | oneOf: [{ type: 'string' }, { $ref: getSchemaPath(Link) }, {$ref: getSchemaPath(ObjectDto) }] 37 | }) 38 | @Prop({ type: Mixed }) 39 | @Expose() 40 | public attributedTo?: ASObjectOrLink | ASObjectOrLink[]; 41 | 42 | @ApiProperty({ 43 | type: String, 44 | required: true, 45 | oneOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }], 46 | format: 'uri' 47 | }) 48 | @Prop({ type: String }) 49 | @Expose() 50 | /** 51 | * @todo Sanitize HTML on the way out. We should always preserve HTML as it was received in an activity which is we don't sanitize it before 52 | * storing it in the database. However, when we return it to the client, we should sanitize it to prevent XSS attacks. 53 | * Note that this should be improved by adding a cached field at the database level to store the sanitized version of the content. 54 | * Also note that we _will_ sanitize HTML when a user posts it to their outbox. 55 | */ 56 | @Transform(({value}) => { 57 | return sanitizeHtml(value); 58 | }, { toClassOnly: true }) 59 | public content?: string | string[]; 60 | 61 | @Prop({ type: Mixed }) 62 | @Expose() 63 | public replies?: Collection; 64 | 65 | @Prop({ type: String }) 66 | @Expose() 67 | public inReplyTo?: ASObjectOrLink; 68 | 69 | @Prop({ type: String }) 70 | @Expose() 71 | public updated?: string; 72 | 73 | @ApiProperty(ApiPropertyOneOfStringOrArray) 74 | @Prop({ type: Mixed }) 75 | @Expose() 76 | public to?: ASObjectOrLink | ASObjectOrLink[]; // note that the "to" field should always have a value, even if the spec says it's optional 77 | 78 | @ApiProperty(ApiPropertyOneOfStringOrArray) 79 | @Prop({ type: Mixed }) 80 | @Expose() 81 | public cc?: ASObjectOrLink | ASObjectOrLink[]; 82 | 83 | @ApiProperty(ApiPropertyOneOfStringOrArray) 84 | @Prop({ type: Mixed }) 85 | @Expose() 86 | public bcc?: ASObjectOrLink | ASObjectOrLink[]; 87 | } 88 | -------------------------------------------------------------------------------- /src/modules/object/dto/object/article.dto.ts: -------------------------------------------------------------------------------- 1 | import { ObjectDto } from '../object.dto'; 2 | 3 | export class ArticleDto extends ObjectDto { 4 | static readonly type = 'Article'; 5 | } 6 | -------------------------------------------------------------------------------- /src/modules/object/dto/object/note.dto.ts: -------------------------------------------------------------------------------- 1 | import { ObjectDto } from '../object.dto'; 2 | 3 | export class NoteDto extends ObjectDto { 4 | static readonly type = 'Note'; 5 | } 6 | -------------------------------------------------------------------------------- /src/modules/object/dto/object/person.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Exclude, Expose, Type } from 'class-transformer'; 3 | import { ObjectRecord } from '../../schema/object.schema'; 4 | 5 | export class PublicKey { 6 | @Expose() 7 | id!: string; 8 | 9 | @Expose() 10 | owner!: string; 11 | 12 | @Expose() 13 | publicKeyPem!: string; 14 | } 15 | 16 | @Exclude() 17 | export class PersonDto extends ObjectRecord { 18 | static readonly type = 'Person'; 19 | 20 | @Expose() 21 | public '@context': string | string[] = 'https://www.w3.org/ns/activitystreams'; 22 | 23 | @ApiProperty({type: 'string', format: 'uri', description: 'The ID of the user'}) 24 | @Expose() 25 | public id!: string; 26 | 27 | @ApiProperty({type: 'string', description: 'The name of the user'}) 28 | public name: string | undefined; 29 | 30 | @ApiProperty({type: 'string'}) 31 | @Expose() 32 | public summary?: string; 33 | 34 | @ApiProperty({type: 'string'}) 35 | @Expose() 36 | public type: string = 'Person'; 37 | 38 | @ApiProperty({type: 'string'}) 39 | @Expose() 40 | public preferredUsername: string | undefined; 41 | 42 | @Expose() 43 | @Type(() => PublicKey) 44 | public publicKey?: { 45 | id: string; 46 | owner: string; 47 | publicKeyPem: string; 48 | }; 49 | } 50 | -------------------------------------------------------------------------------- /src/modules/object/dto/object/relationship.dto.ts: -------------------------------------------------------------------------------- 1 | import { Prop } from '@nestjs/mongoose'; 2 | import { Exclude } from 'class-transformer'; 3 | import { IsString } from 'class-validator'; 4 | import { ObjectDto } from '../object.dto'; 5 | 6 | @Exclude() 7 | export class RelationshipDto extends ObjectDto { 8 | static readonly type = 'Relationship'; 9 | 10 | @Prop({type: String, required: true}) 11 | @IsString() 12 | public subject!: string; 13 | 14 | @Prop({type: String, required: true}) 15 | @IsString() 16 | public object!: string; 17 | 18 | @Prop({type: String, required: true}) 19 | @IsString() 20 | public relationship!: string; 21 | } 22 | -------------------------------------------------------------------------------- /src/modules/object/object.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { ObjectController } from './object.controller'; 3 | 4 | describe('Object Controller', () => { 5 | let controller: ObjectController; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [ObjectController], 10 | }).compile(); 11 | 12 | controller = module.get(ObjectController); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/modules/object/object.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Param } from '@nestjs/common'; 2 | import { ServiceDomain } from '../../common/decorators/service-domain.decorator'; 3 | import { ObjectService } from './object.service'; 4 | 5 | @Controller('object') 6 | // @UseInterceptors(ClassSerializerInterceptor) 7 | // @SerializeOptions({excludeExtraneousValues: true}) 8 | export class ObjectController { 9 | constructor(protected readonly objectService: ObjectService) { } 10 | 11 | @Get(':id') 12 | public async get(@ServiceDomain() serviceId: string, @Param('id') id: string): Promise { 13 | id = `https://${serviceId}/object/${id}`; 14 | return this.objectService.get(id); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/modules/object/object.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { MongooseModule } from '@nestjs/mongoose'; 3 | import { ObjectService } from './object.service'; 4 | import { ObjectRecord, ObjectSchema } from './schema/object.schema'; 5 | import { ObjectController } from './object.controller'; 6 | import { RelationshipRecord, RelationshipSchema } from './schema/relationship.schema'; 7 | import { ActorRecord, ActorSchema } from './schema/actor.schema'; 8 | import { StoredObjectResolver } from './resolver/stored-object.resolver'; 9 | import { UserActorRecord, UserActorSchema } from './schema/user-actor.schema'; 10 | 11 | @Module({ 12 | providers: [ 13 | StoredObjectResolver, 14 | ObjectService 15 | ], 16 | imports: [ 17 | MongooseModule.forFeature([ 18 | { 19 | name: ObjectRecord.name, 20 | schema: ObjectSchema, 21 | discriminators: [ 22 | {name: ActorRecord.name, schema: ActorSchema, value: 'Actor'}, 23 | {name: ActorRecord.name, schema: ActorSchema, value: 'Forum'}, 24 | {name: ActorRecord.name, schema: ActorSchema, value: 'Person'}, 25 | {name: RelationshipRecord.name, schema: RelationshipSchema, value: 'relationship'} 26 | ] 27 | }, 28 | {name: UserActorRecord.name, schema: UserActorSchema} 29 | ]) 30 | ], 31 | exports: [ 32 | ObjectService, 33 | MongooseModule, 34 | StoredObjectResolver 35 | ], 36 | controllers: [ObjectController] 37 | }) 38 | export class ObjectModule { } 39 | -------------------------------------------------------------------------------- /src/modules/object/resolver/stored-object.resolver.ts: -------------------------------------------------------------------------------- 1 | import { forwardRef, Inject, Injectable } from '@nestjs/common'; 2 | import { ActivityStreams, ASLink, ASObject } from '@yuforium/activity-streams'; 3 | import { ObjectService } from '../object.service'; 4 | 5 | @Injectable() 6 | export class StoredObjectResolver extends ActivityStreams.Resolver { 7 | constructor(@Inject(forwardRef(() => ObjectService)) protected objectService: ObjectService) { 8 | super(); 9 | } 10 | 11 | public async handle(request: string): Promise { 12 | const obj = await this.objectService.findOne({id: request}); 13 | return obj || request; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/modules/object/schema/README.md: -------------------------------------------------------------------------------- 1 | # Schemas 2 | Schemas represent interaction with an underlying database. Yuforium uses MongoDB to represent Activity Streams `Objects` in the underlying collections as exact representations of the JSON-LD objects, with the addition of metadata fields that are represented with an underscore at the beginning of the field name. 3 | 4 | Schemas are comprised of three different parts: 5 | 6 | ## Schema and Schema Class 7 | This is the Nest JS Mongoose class definition using the `@Schema` decorator, and are typically named with the suffix `Record`, such as `ObjectRecord` or `ActorRecord`. These classes are used to define the structure of the underlying MongoDB collection. 8 | 9 | ## Class Extender Functions 10 | These are functions that extend the schema class with additional functionality. This is done to keep the schema definition clean and to separate concerns. 11 | 12 | ## Types 13 | These are TypeScript types that are used to define and validate the structure of the objects and underlying schemas and schema creation, and are automatically derived from the dto definitions. 14 | -------------------------------------------------------------------------------- /src/modules/object/schema/actor.schema.ts: -------------------------------------------------------------------------------- 1 | import { Schema, SchemaFactory } from '@nestjs/mongoose'; 2 | import { GConstructor } from '../../../common/schema/base.schema'; 3 | import { ActorDto } from '../dto/actor/actor.dto'; 4 | import mongoose from 'mongoose'; 5 | import { ActorType } from '../type/actor.type'; 6 | import { baseObjectRecord } from './base-object.schema'; 7 | 8 | /** 9 | * ActorDocument is a type that extends the ActorRecord and adds the mongoose.Document type, 10 | * representing a document in the database. 11 | */ 12 | export type ActorDocument = ActorRecord & mongoose.Document; 13 | 14 | @Schema({collection: 'objects', autoIndex: true}) 15 | export class ActorRecord extends baseObjectRecord>(ActorDto) { } 16 | export const ActorSchema = SchemaFactory.createForClass(ActorRecord); 17 | -------------------------------------------------------------------------------- /src/modules/object/schema/base-object.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop } from '@nestjs/mongoose'; 2 | import { Exclude, Expose } from 'class-transformer'; 3 | import { Schema, Types } from 'mongoose'; 4 | import { GConstructor } from '../../../common/schema/base.schema'; 5 | import { BaseObjectDto } from '../dto/base-object.dto'; 6 | import { Attribution } from '../type/attribution.type'; 7 | import { BaseObjectType } from '../type/base-object.type'; 8 | 9 | export type BaseObjectDocument = BaseObjectRecord & Document; 10 | 11 | /** 12 | * Mixin type that defines common fields that are used for all stored objects. 13 | */ 14 | export type BaseObjectMetadataType = { 15 | _domain: string; 16 | _local: boolean; 17 | _public: boolean; 18 | _deleted?: boolean; 19 | 20 | /** 21 | * Only used for local actors, specifies the outbox from which the object originated. 22 | */ 23 | _attribution?: Attribution[]; 24 | } 25 | 26 | export function baseObjectRecord = GConstructor>(Base: T): T & GConstructor { 27 | class Record extends Base { 28 | /** 29 | * The ID of the object, override @Prop to force requirements + uniqueness. 30 | */ 31 | @Prop({type: String, required: true, unique: true}) 32 | @Expose() 33 | public id!: string; 34 | 35 | /** 36 | * The database ID of the object. 37 | */ 38 | @Exclude() 39 | public _id!: Types.ObjectId; 40 | 41 | /** 42 | * The domain of the object. This is used for querying content. Although 43 | * there is no support for multiple domains, this is included for future support. 44 | */ 45 | @Prop({type: String, required: true}) 46 | @Exclude() 47 | public _domain!: string; 48 | 49 | /** 50 | * Specifies if this is a local object. 51 | */ 52 | @Prop({type: Boolean, required: true}) 53 | @Exclude() 54 | public _local!: boolean; 55 | 56 | /** 57 | * Specifies if this is a public object. Used for querying content. 58 | */ 59 | @Prop({type: Boolean, default: false}) 60 | @Exclude() 61 | public _public!: boolean; 62 | 63 | /** 64 | * Specifies if this is a deleted object. This is used for querying content. 65 | */ 66 | @Prop({type: Boolean, required: false, default: false}) 67 | @Exclude() 68 | public _deleted?: boolean; 69 | 70 | @Prop({type: Schema.Types.Mixed, required: false}) 71 | @Exclude() 72 | public _attribution?: Attribution[]; 73 | } 74 | 75 | return Record; 76 | } 77 | 78 | export class BaseObjectRecord extends baseObjectRecord(BaseObjectDto) { } 79 | -------------------------------------------------------------------------------- /src/modules/object/schema/content.schema.ts: -------------------------------------------------------------------------------- 1 | import { Schema, SchemaFactory } from '@nestjs/mongoose'; 2 | import { GConstructor } from '../../../common/schema/base.schema'; 3 | import { ActorDto } from '../dto/actor/actor.dto'; 4 | import mongoose from 'mongoose'; 5 | import { ActorType } from '../type/actor.type'; 6 | import { baseObjectRecord } from './base-object.schema'; 7 | 8 | /** 9 | * ActorDocument is a type that extends the ActorRecord and adds the mongoose.Document type, 10 | * representing a document in the database. 11 | */ 12 | export type ActorDocument = ActorRecord & mongoose.Document; 13 | 14 | @Schema({collection: 'objects', autoIndex: true}) 15 | export class ActorRecord extends baseObjectRecord>(ActorDto) { } 16 | export const ActorSchema = SchemaFactory.createForClass(ActorRecord); 17 | -------------------------------------------------------------------------------- /src/modules/object/schema/object.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; 2 | import * as mongoose from 'mongoose'; 3 | import { ObjectDto } from '../dto/object.dto'; 4 | import { baseObjectRecord } from './base-object.schema'; 5 | import { GConstructor } from '../../../common/schema/base.schema'; 6 | import { ObjectType } from '../type/object.type'; 7 | import { Exclude } from 'class-transformer'; 8 | import { RepliesType } from '../type/replies.type'; 9 | 10 | /** 11 | * ObjectDocument is a type that extends the ObjectRecord and adds the mongoose.Document type, 12 | * representing a document in the database. 13 | */ 14 | export type ObjectDocument = ObjectRecord & mongoose.Document; 15 | 16 | /** 17 | * Extends the Object DTO and adds additional database fields by extending BaseObjectSchema. 18 | */ 19 | @Schema({collection: 'objects', autoIndex: true, discriminatorKey: '_baseType'}) 20 | export class ObjectRecord extends baseObjectRecord>(ObjectDto) { 21 | /** 22 | * Base Type of the object. This is used by the Object model's discriminator. 23 | */ 24 | @Prop({type: String, required: false}) 25 | @Exclude() 26 | public _baseType?: 'actor' | 'object' | 'content'; 27 | 28 | @Prop({type: mongoose.Schema.Types.Mixed, required: false}) 29 | @Exclude() 30 | public _replies?: { 31 | default: RepliesType 32 | } 33 | 34 | @Prop({type: String, required: false}) 35 | @Exclude() 36 | public _rootId!: string; 37 | } 38 | export const ObjectSchema = SchemaFactory.createForClass(ObjectRecord); 39 | -------------------------------------------------------------------------------- /src/modules/object/schema/relationship.schema.ts: -------------------------------------------------------------------------------- 1 | import { Schema, Prop, SchemaFactory } from '@nestjs/mongoose'; 2 | import mongoose from 'mongoose'; 3 | import { RelationshipDto } from '../dto/object/relationship.dto'; 4 | import { baseObjectRecord } from './base-object.schema'; 5 | import { GConstructor } from '../../../common/schema/base.schema'; 6 | import { RelationshipType } from '../type/relationship.type'; 7 | 8 | export type RelationshipDocument = RelationshipRecord & mongoose.Document; 9 | 10 | @Schema({ collection: 'objects', autoIndex: true }) 11 | export class RelationshipRecord extends baseObjectRecord>(RelationshipDto) { 12 | @Prop({ type: String, required: true }) 13 | public _relationship!: string; 14 | } 15 | 16 | export const RelationshipSchema = SchemaFactory.createForClass(RelationshipRecord); 17 | -------------------------------------------------------------------------------- /src/modules/object/schema/user-actor.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; 2 | import { BaseSchema, GConstructor } from '../../../common/schema/base.schema'; 3 | import { ActorDto } from '../dto/actor/actor.dto'; 4 | import mongoose from 'mongoose'; 5 | import { ActorType } from '../type/actor.type'; 6 | import { Expose } from 'class-transformer'; 7 | import { IsNotEmpty, IsString, IsUrl } from 'class-validator'; 8 | 9 | export type UserActorDocument = UserActorRecord & mongoose.Document; 10 | 11 | @Schema({collection: 'objects', autoIndex: true}) 12 | export class UserActorRecord extends BaseSchema>(ActorDto) { 13 | @Expose() 14 | @Prop({type: String, required: true}) 15 | @IsNotEmpty() 16 | @IsString() 17 | @IsUrl() 18 | public inbox!: string; 19 | 20 | @Expose() 21 | @Prop({type: String, required: true}) 22 | @IsNotEmpty() 23 | @IsString() 24 | @IsUrl() 25 | public outbox!: string; 26 | 27 | @Expose() 28 | @Prop({type: String, required: true}) 29 | @IsNotEmpty() 30 | @IsString() 31 | @IsUrl() 32 | public followers!: string; 33 | 34 | @Expose() 35 | @Prop({type: String, required: true}) 36 | @IsNotEmpty() 37 | @IsString() 38 | @IsUrl() 39 | public following!: string; 40 | } 41 | export const UserActorSchema = SchemaFactory.createForClass(UserActorRecord); 42 | -------------------------------------------------------------------------------- /src/modules/object/type/README.md: -------------------------------------------------------------------------------- 1 | # Object Types 2 | Defines the minimum requirements for Object types in the database. 3 | -------------------------------------------------------------------------------- /src/modules/object/type/actor.type.ts: -------------------------------------------------------------------------------- 1 | import { ActorDto } from '../dto/actor/actor.dto'; 2 | import { DtoType } from './dto.type'; 3 | 4 | export type ActorType = DtoType; 5 | -------------------------------------------------------------------------------- /src/modules/object/type/attribution.type.ts: -------------------------------------------------------------------------------- 1 | import { Types } from 'mongoose'; 2 | 3 | export type Attribution = { 4 | /** 5 | * Type of relationship between the object and the actor. 6 | */ 7 | rel: 'attributedTo' | 'to' | 'cc' | 'bcc' | 'audience' | 'context'; 8 | 9 | /** 10 | * The internal Object ID for the actor that is attributed to the object. 11 | */ 12 | _id: Types.ObjectId; 13 | 14 | id: string; 15 | description?: string; 16 | primary?: boolean; 17 | } 18 | -------------------------------------------------------------------------------- /src/modules/object/type/base-object.type.ts: -------------------------------------------------------------------------------- 1 | import { BaseObjectDto } from '../dto/base-object.dto'; 2 | import { DtoType } from './dto.type'; 3 | 4 | export type BaseObjectType = DtoType; 5 | -------------------------------------------------------------------------------- /src/modules/object/type/dto.type.ts: -------------------------------------------------------------------------------- 1 | export type DtoType = { 2 | [K in keyof T as (T[K] extends (...args: any[]) => any ? never : K)]: T[K] 3 | } 4 | -------------------------------------------------------------------------------- /src/modules/object/type/object.type.ts: -------------------------------------------------------------------------------- 1 | import { ObjectDto } from '../dto/object.dto'; 2 | import { DtoType } from './dto.type'; 3 | 4 | export type ObjectType = DtoType; 5 | -------------------------------------------------------------------------------- /src/modules/object/type/relationship.type.ts: -------------------------------------------------------------------------------- 1 | import { RelationshipDto } from '../dto/object/relationship.dto'; 2 | import { DtoType } from './dto.type'; 3 | 4 | export type RelationshipType = DtoType; 5 | -------------------------------------------------------------------------------- /src/modules/object/type/replies.type.ts: -------------------------------------------------------------------------------- 1 | export type RepliesType = { 2 | count: number; 3 | last: number; 4 | }; 5 | -------------------------------------------------------------------------------- /src/modules/object/type/user-actor.type.ts: -------------------------------------------------------------------------------- 1 | import { ObjectType } from './object.type'; 2 | 3 | export type UserActorType = ObjectType & { 4 | preferredUsername: string; 5 | inbox: string; 6 | outbox: string; 7 | followers: string; 8 | following: string; 9 | }; 10 | -------------------------------------------------------------------------------- /src/modules/user/controller/user-content.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { UserContentController } from './user-content.controller'; 3 | 4 | describe('ContentController', () => { 5 | let controller: UserContentController; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [UserContentController], 10 | }).compile(); 11 | 12 | controller = module.get(UserContentController); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/modules/user/controller/user-content.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ClassSerializerInterceptor, 3 | Controller, 4 | Get, 5 | Logger, 6 | NotFoundException, 7 | NotImplementedException, 8 | Param, 9 | Query, 10 | UseInterceptors 11 | } from '@nestjs/common'; 12 | import { 13 | ApiExtraModels, 14 | ApiOkResponse, 15 | ApiOperation, 16 | ApiParam, 17 | ApiQuery, 18 | ApiTags, 19 | getSchemaPath 20 | } from '@nestjs/swagger'; 21 | import { ServiceDomain } from '../../../common/decorators/service-domain.decorator'; 22 | import { ObjectService } from '../../../modules/object/object.service'; 23 | import { UserContentQueryOptionsDto } from '../dto/user-content-query-options.dto'; 24 | import { UserParamsDto } from '../dto/user-params.dto'; 25 | import { UserService } from '../user.service'; 26 | import { OrderedCollectionPageDto } from '../../object/dto/collection/ordered-collection-page.dto'; 27 | import { Reflector } from '@nestjs/core'; 28 | import { ContentQueryOptionsDto } from '../../object/dto/content-query-options.dto'; 29 | import { StoredObjectResolver } from '../../object/resolver/stored-object.resolver'; 30 | 31 | @UseInterceptors( 32 | new ClassSerializerInterceptor(new Reflector(), { 33 | groups: ['user-content'], 34 | excludeExtraneousValues: true 35 | }) 36 | ) 37 | @ApiTags('user') 38 | @Controller('users/:username/posts') 39 | export class UserContentController { 40 | protected readonly logger: Logger = new Logger(this.constructor.name); 41 | 42 | constructor( 43 | protected readonly objectService: ObjectService, 44 | protected readonly userService: UserService, 45 | protected readonly resolver: StoredObjectResolver 46 | ) { } 47 | 48 | @ApiParam({ 49 | name: 'username', 50 | type: String, 51 | required: true, 52 | example: 'chris' 53 | }) 54 | @ApiParam({ name: 'pageNumber', type: Number, required: true, example: 1 }) 55 | @Get('page/:pageNumber') 56 | public async getContentPage(@Query('pageNumber') _pageNumber: number) { 57 | throw new NotImplementedException(); 58 | } 59 | 60 | /** 61 | * Get user's content 62 | * @param _serviceId 63 | * @param params 64 | * @param options 65 | * @returns 66 | */ 67 | @ApiOperation({ operationId: 'getContent', summary: 'Get user content' }) 68 | @ApiParam({ 69 | name: 'username', 70 | type: String, 71 | required: true, 72 | example: 'chris' 73 | }) 74 | @ApiExtraModels(UserContentQueryOptionsDto, OrderedCollectionPageDto) 75 | @ApiQuery({ 76 | name: 'contentQuery', 77 | required: false, 78 | type: 'ContentQueryOptionsDto', 79 | style: 'deepObject', 80 | schema: { 81 | $ref: getSchemaPath(ContentQueryOptionsDto) 82 | } 83 | }) 84 | @ApiOkResponse({ 85 | description: 'User content', 86 | type: OrderedCollectionPageDto 87 | }) 88 | @Get() 89 | public async getPosts( 90 | @ServiceDomain() domain: string, 91 | @Param() params: UserParamsDto, 92 | @Query('contentQuery') contentQuery: ContentQueryOptionsDto 93 | ): Promise { 94 | const collectionPage = new OrderedCollectionPageDto(); 95 | const userId = `https://${domain}/users/${params.username}`; 96 | 97 | contentQuery = contentQuery || new ContentQueryOptionsDto(); 98 | 99 | this.logger.debug(`getContent() for user ${params.username}@${domain}`); 100 | 101 | const user = await this.userService.findOne(domain, params.username); 102 | 103 | if (!user) { 104 | this.logger.debug(`getContent() user not found for ${params.username}@${domain}`); 105 | throw new NotFoundException(); 106 | } 107 | 108 | const person = await this.objectService.findOne({ 109 | _domain: domain, 110 | _id: user.defaultIdentity 111 | }); 112 | 113 | if (!person) { 114 | this.logger.error(`getContent() user default identity not found for ${params.username}@${domain}`); 115 | throw new NotFoundException(); 116 | } 117 | 118 | const queryParams = { 119 | inReplyTo: null, 120 | $or: [ 121 | {'_attribution.id': person.id, '_attribution.rel': 'attributedTo', '_public': true}, 122 | {'_attribution.id': person.id, '_attribution.rel': 'to', '_public': true}, 123 | { '_attribution.id': person.id, '_attribution.rel': 'cc', '_public': true } 124 | ] 125 | }; 126 | 127 | const {data, totalItems: total} = await this.objectService.getContentPage(queryParams, contentQuery); 128 | 129 | const items = data; 130 | 131 | collectionPage.id = `${userId}/content`; 132 | collectionPage.items = items; 133 | collectionPage.totalItems = total; 134 | 135 | await Promise.all(items.map(item => this.objectService.resolveFields(item, ['attributedTo', 'audience']))); 136 | 137 | return collectionPage; 138 | } 139 | 140 | @Get(':postId') 141 | @ApiParam({ 142 | name: 'username', 143 | type: 'string', 144 | required: true, 145 | example: 'chris' 146 | }) 147 | public async getPost( 148 | @ServiceDomain() _serviceId: string, 149 | @Param() params: UserParamsDto, 150 | @Param('postId') postId: string 151 | ): Promise { 152 | return await this.objectService.getByPath( 153 | _serviceId, 154 | `users/${params.username}/posts`, 155 | postId 156 | ); 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/modules/user/controller/user-inbox.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { UserInboxController } from './user-inbox.controller'; 3 | 4 | describe('UserInbox Controller', () => { 5 | let controller: UserInboxController; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [UserInboxController], 10 | }).compile(); 11 | 12 | controller = module.get(UserInboxController); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/modules/user/controller/user-inbox.controller.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Body, Controller, Get, HttpCode, HttpStatus, Logger, Param, Post, RawBodyRequest, Req, UseGuards } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | import { ApiBearerAuth, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger'; 4 | import { Note } from '@yuforium/activity-streams'; 5 | import { plainToClass } from 'class-transformer'; 6 | import { ServiceDomain } from '../../../common/decorators/service-domain.decorator'; 7 | import { ObjectService } from '../../../modules/object/object.service'; 8 | import { ActivityService } from '../../activity/service/activity.service'; 9 | import { Request } from 'express'; 10 | import { ObjectDocument } from '../../../modules/object/schema/object.schema'; 11 | import { ActivityDto } from '../../../modules/activity/dto/activity.dto'; 12 | import { InboxService } from '../../../modules/activity/service/inbox.service'; 13 | 14 | @ApiTags('user') 15 | @Controller('users/:username/inbox') 16 | export class UserInboxController { 17 | protected logger = new Logger(UserInboxController.name); 18 | 19 | constructor( 20 | protected readonly activityService: ActivityService, 21 | protected readonly objectService: ObjectService, 22 | protected readonly inboxService: InboxService 23 | ) { } 24 | 25 | @ApiBearerAuth() 26 | @ApiOperation({operationId: 'getInbox'}) 27 | @ApiParam({name: 'username', required: true, type: String, 'description': 'The username of the user to get the inbox for.'}) 28 | @Get() 29 | @UseGuards(AuthGuard(['jwt', 'anonymous'])) 30 | public async getInbox(@ServiceDomain() domain: string, @Param() params: any) { 31 | const queryParams = {to: `https://${domain}/user/${params.username}`}; 32 | const content = await this.objectService.find(queryParams); 33 | const response = content.map((item: ObjectDocument) => plainToClass(Note, item, {excludeExtraneousValues: true})); 34 | 35 | return response; 36 | } 37 | 38 | /** 39 | * Inbox POST request handler 40 | * @param domain Domain for which the activity was received 41 | * @param req Request object, used to log the IP address on receipt of an activity 42 | * @param dto Activity object received in the request body. Any (json) is permitted here, since we want to preserve the original object. @todo validate this 43 | * @param username Username of the user to receive the activity 44 | * @returns Accepted status object 45 | */ 46 | @ApiBearerAuth() // @todo is this necessary? a token can be passed to authenticate the user but it's completely optional (could be used to bypass rate limiting) 47 | @ApiOperation({ operationId: 'postInbox' }) 48 | @ApiParam({ name: 'username', required: true, type: String, 'description': 'The username of the user to get the inbox for.' }) 49 | @Post() 50 | @UseGuards(AuthGuard(['jwt', 'anonymous'])) 51 | @HttpCode(HttpStatus.ACCEPTED) 52 | public async postInbox( 53 | @ServiceDomain() domain: string, @Req() req: RawBodyRequest, @Body() dto: ActivityDto, @Param('username') username: string 54 | ) { 55 | const targetUserId = `https://${domain}/user/${username}`; 56 | 57 | if (!req.rawBody) { 58 | throw new BadRequestException('The request body is empty.'); 59 | } 60 | 61 | const raw = JSON.stringify(JSON.parse(req.rawBody.toString('utf-8')), null, 4); 62 | 63 | // if (typeof activity.object.to === 'string' && activity.object.to !== targetUserId) { 64 | // throw new BadRequestException(`The activity is not intended for the user ${targetUserId}.`); 65 | // } 66 | // else if (Array.isArray(activity.object.to) && !activity.object.to.includes(targetUserId)) { 67 | // throw new BadRequestException(`The activity is not intended for the user ${targetUserId}.`); 68 | // } 69 | 70 | this.logger.debug(`postInbox(): Received "${dto.type}" activity for ${targetUserId} from ${req.socket.remoteAddress}`); 71 | 72 | try { 73 | await this.inboxService.receive(dto, raw, {requestSignature: {headers: req.headers, path: `/users/${username}/inbox`, method: 'post'}}); 74 | } 75 | catch (e: any) { 76 | this.logger.error(`postInbox(): Activity "${dto.type}" for ${targetUserId} from ${req.socket.remoteAddress} was rejected: ${e.message}`); 77 | throw e; 78 | } 79 | 80 | this.logger.debug(`postInbox(): Activity "${dto.type}" for ${targetUserId} from ${req.socket.remoteAddress} was accepted`); 81 | 82 | return { 83 | 'status': 'Accepted', 84 | 'id': dto.id, 85 | }; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/modules/user/controller/user-outbox.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { UserController } from './user.controller'; 3 | 4 | describe('User Controller', () => { 5 | let controller: UserController; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [UserController], 10 | }).compile(); 11 | 12 | controller = module.get(UserController); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/modules/user/controller/user-outbox.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Req, Controller, Get, NotImplementedException, Param, Post, UnauthorizedException, UseGuards, Logger } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger'; 4 | import { OrderedCollection } from '@yuforium/activity-streams'; 5 | import { ServiceDomain } from '../../../common/decorators/service-domain.decorator'; 6 | import { NoteCreateDto } from '../../object/dto/object-create/note-create.dto'; 7 | import { ActivityService } from '../../activity/service/activity.service'; 8 | import { ObjectService } from '../../object/object.service'; 9 | import { UserParamsDto } from '../dto/user-params.dto'; 10 | import { Request } from 'express'; 11 | import { User } from '../../../common/decorators/user.decorator'; 12 | import { ActivityDto } from '../../../modules/activity/dto/activity.dto'; 13 | import { ActivityStreamsPipe } from '../../../common/pipes/activity-streams.pipe'; 14 | import { ObjectCreateDto } from '../../object/dto/object-create/object-create.dto'; 15 | import { ObjectCreateTransformer } from '../../../common/transformer/object-create.transformer'; 16 | import { JwtUser } from '../../../modules/auth/auth.service'; 17 | import { OutboxService } from '../../activity/service/outbox.service'; 18 | import { ArticleCreateDto } from '../../object/dto/object-create/article-create.dto'; 19 | 20 | @Controller('users/:username/outbox') 21 | @ApiTags('user') 22 | export class UserOutboxController { 23 | protected readonly logger = new Logger(UserOutboxController.name); 24 | 25 | constructor( 26 | protected readonly activityService: ActivityService, 27 | protected readonly objectService: ObjectService, 28 | protected readonly outboxService: OutboxService 29 | ) { } 30 | 31 | @ApiBearerAuth() 32 | @ApiBody({type: NoteCreateDto}) 33 | @ApiParam({name: 'username', type: 'string'}) 34 | @ApiOperation({operationId: 'postUserOutbox', summary: 'Post to user outbox'}) 35 | @UseGuards(AuthGuard('jwt')) 36 | @Post() 37 | public async postOutbox( 38 | @Param() params: UserParamsDto, 39 | @ServiceDomain() domain: string, 40 | @User() user: JwtUser, 41 | @Req() req: Request, 42 | @Body(new ActivityStreamsPipe(ObjectCreateTransformer, {groups: ['outbox']})) dto: ObjectCreateDto | NoteCreateDto | ArticleCreateDto 43 | ) { 44 | if (dto instanceof ActivityDto) { 45 | throw new NotImplementedException('Activity objects are not supported at this time.'); 46 | } 47 | 48 | const userId = `https://${domain}/users/${params.username}`; 49 | const actorRecord = await this.objectService.get(user.actor.id); 50 | 51 | // @todo - auth should be done via decorator on the class method 52 | if (!actorRecord || actorRecord.type === 'Tombstone' || userId !== user.actor.id) { 53 | this.logger.error(`Unauthorized access to outbox for ${userId} by ${user.actor.id}`); 54 | throw new UnauthorizedException('You are not authorized to post to this outbox.'); 55 | } 56 | 57 | this.logger.debug(`postOutbox(): ${params.username} creating a ${dto.type}`); 58 | 59 | const createDto = { 60 | ...dto, 61 | '@context': 'https://www.w3.org/ns/activitystreams', 62 | attributedTo: (req.user as any).actor.id, 63 | published: (new Date()).toISOString(), 64 | to: Array.isArray(dto.to) ? dto.to.map(i => i.toString()) : [dto.to.toString()] 65 | }; 66 | 67 | const activity = this.outboxService.createObject(domain, user, userId, createDto); 68 | 69 | return activity; 70 | } 71 | 72 | /** 73 | * User outbox scoped to public activities 74 | * @param serviceId 75 | * @param username 76 | * @param req 77 | * @returns 78 | */ 79 | @ApiOperation({operationId: 'getUserOutbox'}) 80 | @ApiParam({name: 'username', type: 'string', required: true}) 81 | @UseGuards(AuthGuard(['anonymous', 'jwt'])) 82 | @Get() 83 | public async getOutbox(@ServiceDomain() serviceId: string, @Param() params: UserParamsDto): Promise { 84 | const collection = new OrderedCollection(); 85 | const actor = `https://${serviceId}/user/${params.username}`; 86 | const filter: any = {actor, 'object.to': 'https://www.w3.org/ns/activitystreams#Public'}; 87 | 88 | collection.totalItems = await this.activityService.count(filter); 89 | collection.first = `https://${serviceId}/user/${params.username}/outbox/first`; 90 | // collection.orderedItems = activities.map(item => plainToClass(ActivityStreams.Activity, item)); 91 | 92 | return collection; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/modules/user/controller/user.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Get, Header, Logger, NotFoundException, Param, Post, Req, UseGuards } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; 4 | import { plainToInstance } from 'class-transformer'; 5 | import { ServiceDomain } from '../../../common/decorators/service-domain.decorator'; 6 | import { ObjectService } from '../../object/object.service'; 7 | import { PersonDto } from '../../object/dto/object/person.dto'; 8 | import { UserCreateDto } from '../dto/user-create.dto'; 9 | import { UserService } from '../user.service'; 10 | import { UserParamsDto } from '../dto/user-params.dto'; 11 | import { ActorDto } from '../../object/dto/actor/actor.dto'; 12 | import { ObjectDocument } from '../../object/schema/object.schema'; 13 | 14 | @ApiTags('user') 15 | @Controller('users') 16 | export class UserController { 17 | protected logger = new Logger(UserController.name); 18 | 19 | constructor( 20 | protected userService: UserService, 21 | protected objectService: ObjectService 22 | ) { } 23 | 24 | @ApiOperation({operationId: 'find'}) 25 | @Get() 26 | @Header('Content-Type', 'application/activity+json') 27 | public async findUsers(@ServiceDomain() _serviceId: string): Promise { 28 | const users = await this.objectService.find({_serviceId, type: 'Person'}); 29 | return users.map((user: ObjectDocument) => plainToInstance(PersonDto, user)); 30 | } 31 | 32 | @ApiOperation({operationId: 'exists'}) 33 | @ApiParam({name: 'username', type: 'string', required: true, example: 'chris'}) 34 | @Get('exists/:username') 35 | @Header('Content-Type', 'application/activity+json') 36 | public async userExists(@ServiceDomain() domain: string, @Param() params: UserParamsDto): Promise { 37 | const person = await this.userService.findOne(domain, params.username.toLowerCase()); 38 | 39 | if (person) { 40 | return true; 41 | } 42 | 43 | return false; 44 | } 45 | 46 | @ApiOperation({operationId: 'whoami'}) 47 | @Get('whoami') 48 | @UseGuards(AuthGuard('jwt')) 49 | @Header('Content-Type', 'application/activity+json') 50 | public async whoAmI(@Req() req: any): Promise { 51 | return req.user.actor; 52 | } 53 | 54 | @ApiOperation({operationId: 'createUser'}) 55 | @Post() 56 | @Header('Content-Type', 'application/activity+json') 57 | public async create(@ServiceDomain() serviceId: string, @Body() userDto: UserCreateDto) { 58 | this.logger.debug(`Creating user ${userDto.username}`); 59 | userDto.username = userDto.username.toLowerCase(); 60 | userDto.email = userDto.email.toLowerCase(); 61 | 62 | return plainToInstance(PersonDto, await this.userService.create(serviceId, userDto)); 63 | } 64 | 65 | @ApiOperation({operationId: 'getUser'}) 66 | @ApiResponse({status: 200, description: 'User found', type: PersonDto}) 67 | @ApiResponse({status: 404, description: 'User not found'}) 68 | @Get(':username') 69 | @Header('Content-Type', 'application/activity+json') 70 | public async findOne(@ServiceDomain() domain: string, @Param('username') username: string): Promise { 71 | const person = await this.userService.findPerson(domain, username.toLowerCase()); 72 | if (person) { 73 | this.logger.debug(`Found user ${username}`); 74 | return person; 75 | } 76 | 77 | throw new NotFoundException('User does not exist'); 78 | } 79 | 80 | // @Get(':username/content') 81 | // @Header('Content-Type', 'application/activity+json') 82 | // public async getUserContent( 83 | // @ServiceId() serviceId: string, 84 | // @Param('username') username: string, 85 | // @Query('page') page: number = 0, 86 | // @Query('pageSize') pageSize: number = 20, 87 | // @Query('sort') sort: string = 'createdAt', // also by lastReply 88 | // ) { 89 | 90 | // const collection = OrderedCollectionPage.factory({ 91 | // id: `https://${serviceId}/users/${username}/content`, 92 | // first: `https://${serviceId}/users/${username}/content?page=0`, 93 | // last: `https://${serviceId}/users/${username}/content?last=true` 94 | // }); 95 | 96 | // return collection; 97 | // } 98 | 99 | // @Roles(Role.User) 100 | // @UseGuards(JwtAuthGuard) 101 | // @Get(':username/inbox') 102 | // public getInbox(@Param('username') username: string) { 103 | // return 'getInbox'; 104 | // } 105 | 106 | // @Roles(Role.User) 107 | // @UseGuards(JwtAuthGuard) 108 | // @Post(':username/outbox') 109 | // public async postOutbox(@ServiceId() serviceId: string, @Req() request: Request, @Param('username') username: string, @Body() body: any) { 110 | // if ((request.user as any).id !== `${serviceId}/user/${username}`) { 111 | // throw new UnauthorizedException(); 112 | // } 113 | 114 | // const message = await this.activityPubService.createObject(body); 115 | // const activity = await this.activityPubService.createActivity({ 116 | // type: 'Create', 117 | // object: message._id 118 | // }); 119 | 120 | // return message.toObject(); 121 | // return request.user; 122 | // } 123 | } 124 | -------------------------------------------------------------------------------- /src/modules/user/dto/user-actor.dto.ts: -------------------------------------------------------------------------------- 1 | import { Exclude } from 'class-transformer'; 2 | import { ActorDto } from '../../object/dto/actor/actor.dto'; 3 | 4 | export class JwtUserActorDto extends ActorDto { 5 | @Exclude() 6 | public get inbox(): string { 7 | return `${this.id}/inbox`; 8 | } 9 | 10 | @Exclude() 11 | public get outbox(): string { 12 | return `${this.id}/outbox`; 13 | } 14 | 15 | @Exclude() 16 | public get followers(): string { 17 | return `${this.id}/followers`; 18 | } 19 | 20 | @Exclude() 21 | public get following(): string { 22 | return `${this.id}/following`; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/modules/user/dto/user-content-query-options.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiPropertyOptional } from '@nestjs/swagger'; 2 | import { Transform } from 'class-transformer'; 3 | import { IsInt, IsOptional } from 'class-validator'; 4 | 5 | export class UserContentQueryOptionsDto { 6 | @ApiPropertyOptional({ 7 | name: 'type', 8 | type: 'string', 9 | format: 'form', 10 | default: 'All', 11 | description: 12 | 'Comma separated list of Activity Streams object types to include in the response.', 13 | example: 'Note,Article' 14 | }) 15 | @IsOptional() 16 | public type: string | undefined; 17 | 18 | @ApiPropertyOptional({ 19 | name: 'skip', 20 | type: 'integer', 21 | format: 'form', 22 | default: 0, 23 | description: 24 | 'The number of items to skip before returning the remaining items.' 25 | }) 26 | @Transform(({ value }) => parseInt(value, 10)) 27 | @IsInt() 28 | public skip: number = 0; 29 | 30 | @ApiPropertyOptional({ 31 | name: 'limit', 32 | type: 'integer', 33 | format: 'form', 34 | default: 20, 35 | description: 'The maximum number of items to return.', 36 | example: 1 37 | }) 38 | @Transform(({ value }) => parseInt(value, 10)) 39 | @IsInt() 40 | public limit: number = 20; 41 | 42 | @ApiPropertyOptional({ 43 | name: 'sort', 44 | type: 'string', 45 | default: '-published', 46 | format: 'form', 47 | description: 'The sort order of the returned items.', 48 | example: 'published' 49 | }) 50 | @IsOptional() 51 | public sort: string = '-published'; 52 | } 53 | -------------------------------------------------------------------------------- /src/modules/user/dto/user-create.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; 2 | import { Exclude } from 'class-transformer'; 3 | import { IsAlphanumeric, IsEmail, IsNotEmpty, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; 4 | 5 | export class UserCreateDto { 6 | @ApiProperty() 7 | @IsNotEmpty() 8 | @IsString() 9 | @IsAlphanumeric() 10 | @MinLength(5) 11 | @MaxLength(64) 12 | username!: string; 13 | 14 | @ApiProperty() 15 | @ApiPropertyOptional() 16 | @IsString() 17 | @IsOptional() 18 | @MaxLength(256) 19 | name?: string; 20 | 21 | @ApiProperty() 22 | @ApiPropertyOptional() 23 | @IsString() 24 | @IsOptional() 25 | @MaxLength(256) 26 | summary?: string; 27 | 28 | @Exclude() 29 | __v: number | undefined; 30 | 31 | @ApiProperty() 32 | @IsNotEmpty() 33 | @IsString() 34 | @MinLength(6) 35 | @MaxLength(64) 36 | password!: string; 37 | 38 | @ApiProperty() 39 | @IsNotEmpty() 40 | @IsString() 41 | @MinLength(3) 42 | @MaxLength(128) 43 | @IsEmail() 44 | email!: string; 45 | } -------------------------------------------------------------------------------- /src/modules/user/dto/user-params.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsString, Matches, MaxLength, MinLength } from 'class-validator'; 2 | 3 | export class UserParamsDto { 4 | @MinLength(5) 5 | @MaxLength(64) 6 | @Matches(/^[a-z](?:-?[a-z0-9]+){3,255}$/i) 7 | @IsString() 8 | @IsNotEmpty() 9 | username!: string; 10 | } 11 | -------------------------------------------------------------------------------- /src/modules/user/dto/user-query-options.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiPropertyOptional } from '@nestjs/swagger'; 2 | import { Transform } from 'class-transformer'; 3 | import { IsInt, IsOptional } from 'class-validator'; 4 | 5 | export class UserQueryOptionsDto { 6 | @ApiPropertyOptional({ 7 | name: 'type', 8 | type: 'string', 9 | format: 'form', 10 | default: 'All', 11 | description: 12 | 'Comma separated list of Activity Streams object types to include in the response.', 13 | example: 'Note,Article' 14 | }) 15 | @IsOptional() 16 | public type: string | undefined; 17 | 18 | @ApiPropertyOptional({ 19 | name: 'skip', 20 | type: 'integer', 21 | format: 'form', 22 | default: 0, 23 | description: 24 | 'The number of items to skip before returning the remaining items.' 25 | }) 26 | @Transform(({ value }) => parseInt(value, 10)) 27 | @IsInt() 28 | public skip: number = 0; 29 | 30 | @ApiPropertyOptional({ 31 | name: 'limit', 32 | type: 'integer', 33 | format: 'form', 34 | default: 20, 35 | description: 'The maximum number of items to return.', 36 | example: 1 37 | }) 38 | @Transform(({ value }) => parseInt(value, 10)) 39 | @IsInt() 40 | public limit: number = 20; 41 | 42 | @ApiPropertyOptional({ 43 | name: 'sort', 44 | type: 'string', 45 | default: '-published', 46 | format: 'form', 47 | description: 'The sort order of the returned items.', 48 | example: 'published' 49 | }) 50 | @IsOptional() 51 | public sort: string = '-published'; 52 | } 53 | -------------------------------------------------------------------------------- /src/modules/user/dto/user.dto.ts: -------------------------------------------------------------------------------- 1 | import { UserCreateDto } from './user-create.dto'; 2 | 3 | export class UserDto extends UserCreateDto { 4 | } -------------------------------------------------------------------------------- /src/modules/user/schemas/user.schema.ts: -------------------------------------------------------------------------------- 1 | import { Schema, Prop, SchemaFactory } from '@nestjs/mongoose'; 2 | import { Actor } from '@yuforium/activity-streams'; 3 | import * as mongoose from 'mongoose'; 4 | 5 | export type UserDocument = User & mongoose.Document; 6 | 7 | @Schema({collection: 'users', autoIndex: true}) 8 | export class User { 9 | @Prop({type: String, required: true}) 10 | domain!: string; 11 | 12 | @Prop({type: String, required: true, lowercase: true}) 13 | username!: string; 14 | 15 | @Prop({type: String, required: true}) 16 | password!: string | undefined; 17 | 18 | @Prop({type: String}) 19 | type: string | undefined; 20 | 21 | @Prop({type: Array}) 22 | identities: any[] = []; 23 | 24 | @Prop({type: mongoose.Schema.Types.ObjectId}) 25 | defaultIdentity!: mongoose.Schema.Types.ObjectId; 26 | 27 | @Prop({type: String, required: true}) 28 | email!: string; 29 | 30 | @Prop({type: String}) 31 | privateKey: string | undefined; 32 | 33 | actor!: Actor; 34 | } 35 | 36 | export const UserSchema = SchemaFactory.createForClass(User); 37 | 38 | UserSchema.index({domain: 1, username: 1}, {unique: true}); 39 | -------------------------------------------------------------------------------- /src/modules/user/user.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { MongooseModule } from '@nestjs/mongoose'; 3 | import { User, UserSchema } from './schemas/user.schema'; 4 | import { UserService } from './user.service'; 5 | import { UserController } from './controller/user.controller'; 6 | import { UserInboxController } from './controller/user-inbox.controller'; 7 | import { UserOutboxController } from './controller/user-outbox.controller'; 8 | import { ActivityModule } from '../activity/activity.module'; 9 | import { ObjectModule } from '../object/object.module'; 10 | import { UserContentController } from './controller/user-content.controller'; 11 | 12 | @Module({ 13 | providers: [UserService], 14 | exports: [UserService], 15 | imports: [ 16 | MongooseModule.forFeature([{name: User.name, schema: UserSchema}]), 17 | ObjectModule, 18 | ActivityModule, 19 | ActivityModule 20 | ], 21 | controllers: [ 22 | UserOutboxController, 23 | UserInboxController, 24 | UserContentController, 25 | UserController 26 | ] 27 | }) 28 | export class UserModule { } 29 | -------------------------------------------------------------------------------- /src/modules/user/user.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { UserService } from './user.service'; 3 | 4 | describe('UserService', () => { 5 | let service: UserService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [UserService], 10 | }).compile(); 11 | 12 | service = module.get(UserService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/modules/user/user.service.ts: -------------------------------------------------------------------------------- 1 | import { ConflictException, Injectable, Logger } from '@nestjs/common'; 2 | import { InjectModel } from '@nestjs/mongoose'; 3 | import * as bcrypt from 'bcryptjs'; 4 | import { plainToInstance } from 'class-transformer'; 5 | import { generateKeyPairSync } from 'crypto'; 6 | import { MongoServerError } from 'mongodb'; 7 | import { Model, Schema } from 'mongoose'; 8 | import { ActorDto } from '../object/dto/actor/actor.dto'; 9 | import { ObjectService } from '../object/object.service'; 10 | import { ActorDocument } from '../object/schema/actor.schema'; 11 | import { UserActorDocument, UserActorRecord } from '../object/schema/user-actor.schema'; 12 | import { UserCreateDto } from './dto/user-create.dto'; 13 | import { User, UserDocument } from './schemas/user.schema'; 14 | 15 | @Injectable() 16 | export class UserService { 17 | protected logger = new Logger(UserService.name); 18 | 19 | constructor( 20 | protected readonly objectService: ObjectService, 21 | @InjectModel(User.name) protected userModel: Model, 22 | @InjectModel(UserActorRecord.name) protected userActorModel: Model 23 | ) { } 24 | 25 | public async create(domain: string, userDto: UserCreateDto): Promise { 26 | const saltRounds = 10; 27 | const {username, password} = userDto; 28 | 29 | if (password === undefined) { 30 | throw new Error('Password is required'); 31 | } 32 | 33 | try { 34 | const {publicKey, privateKey} = await this.generateUserKeyPair(); 35 | 36 | const user = await this.userModel.create({ 37 | domain: domain, 38 | username, 39 | email: userDto.email, 40 | password: await bcrypt.hash(password, saltRounds), 41 | privateKey: privateKey.toString() 42 | }); 43 | 44 | const _path = 'users'; 45 | const _pathId = userDto.username; 46 | 47 | this.logger.debug(`Creating person object for user "${userDto.username}"`); 48 | 49 | const id = `https://${domain}/${_path}/${_pathId}`; 50 | const personDtoParams: UserActorRecord = { 51 | '@context': ['https://www.w3.org/ns/activitystreams', 'https://w3id.org/security/v1'], 52 | type: 'Person', 53 | id: id, 54 | attributedTo: id, // assume the user is creating themselves for now 55 | outbox: `${id}/outbox`, 56 | inbox: `${id}/inbox`, 57 | following: `${id}/following`, 58 | followers: `${id}/followers`, 59 | name: userDto.name ?? user.username, 60 | preferredUsername: user.username, 61 | // summary: userDto.summary, 62 | _domain: domain, 63 | _local: true, 64 | _public: true, 65 | publicKey: { 66 | id: `https://${domain}/${_path}/${_pathId}#main-key`, 67 | owner: `https://${domain}/${_path}/${_pathId}`, 68 | publicKeyPem: publicKey.toString() 69 | } 70 | }; 71 | 72 | // effectively the person is creating themselves 73 | const record = await this.userActorModel.create(personDtoParams); 74 | 75 | // @todo - a Create activity should be associated with the person object, attributed to the user, and to any other related information (such as IP address) 76 | 77 | user.identities = [record._id]; 78 | user.defaultIdentity = record._id; 79 | 80 | await user.save(); 81 | 82 | return record; 83 | } 84 | catch (e) { 85 | if (e instanceof MongoServerError) { 86 | if (e.code === 11000) { 87 | throw new ConflictException('Username already exists'); 88 | } 89 | } 90 | 91 | throw e; 92 | } 93 | } 94 | 95 | /** 96 | * @todo this method is problematic, it shouldn't be limited to finding just by username and be more like the Mongoose findOne() method 97 | * @param serviceId 98 | * @param username 99 | * @returns 100 | */ 101 | public async findOne(serviceDomain: string, username: string): Promise { 102 | return await this.userModel.findOne({domain: serviceDomain, username: {'$eq': username}}); 103 | } 104 | 105 | public async findPerson(_domain: string, username: string): Promise { 106 | this.logger.debug(`findPerson "${username}" at domain "${_domain}"`); 107 | 108 | const person = await this.userActorModel.findOne({ 109 | id: `https://${_domain}/users/${username}`, 110 | type: 'Person', 111 | preferredUsername: username 112 | }); 113 | 114 | return plainToInstance(ActorDto, person); 115 | } 116 | 117 | public async find(): Promise { 118 | return this.userModel.find({}); 119 | } 120 | 121 | public async generateUserKeyPair(): Promise { 122 | const {publicKey, privateKey} = generateKeyPairSync('rsa', { 123 | modulusLength: 2048, 124 | publicKeyEncoding: { 125 | type: 'spki', 126 | format: 'pem' 127 | }, 128 | privateKeyEncoding: { 129 | type: 'pkcs8', 130 | format: 'pem', 131 | cipher: 'aes-256-cbc', 132 | passphrase: '' 133 | } 134 | }); 135 | 136 | return {publicKey, privateKey}; 137 | } 138 | 139 | /** 140 | * Reset a user's password 141 | * @param serviceId 142 | * @param username 143 | * @param hashedPassword bcrypt hashed password 144 | * @returns 145 | */ 146 | public async resetPassword(domain: string, username: string, hashedPassword: string): Promise { 147 | const user = await this.findOne(domain, username); 148 | 149 | if (!user) { 150 | this.logger.error(`User "${username}" not found`); 151 | return; 152 | } 153 | 154 | // const newPassword = Math.random().toString(36).slice(-8); // generate a random 8-character password 155 | // const hashedPassword = await bcrypt.hash(newPassword, 10); // hash the password using bcrypt 156 | 157 | user.password = hashedPassword; 158 | await user.save(); // save the updated user document 159 | 160 | return hashedPassword; 161 | } 162 | 163 | /** 164 | * Reset a user's key pair 165 | */ 166 | public async resetKey(serviceId: string, username: string): Promise { 167 | const user = await this.findOne(serviceId, username); 168 | 169 | if (!user) { 170 | this.logger.error(`User "${username}" not found`); 171 | return null; 172 | } 173 | 174 | const person = await this.objectService.findByInternalId(user.defaultIdentity); 175 | 176 | if (person) { 177 | person['@context'] = ['https://www.w3.org/ns/activitystreams', 'https://w3id.org/security/v1']; 178 | const {publicKey, privateKey} = await this.generateUserKeyPair(); 179 | 180 | person.publicKey = { 181 | 'id': `${person.id}#main-key`, 182 | 'owner': person.id, 183 | 'publicKeyPem': publicKey.toString() 184 | }; 185 | 186 | user.privateKey = privateKey.toString(); 187 | 188 | await person.save(); 189 | await user.save(); 190 | 191 | this.logger.log(`Reset key for user "${username}"`); 192 | } 193 | else { 194 | this.logger.error(`Default identity not found for user: ${username}`); 195 | return null; 196 | } 197 | } 198 | 199 | public async findPersonById(_id: string | Schema.Types.ObjectId): Promise { 200 | return this.userActorModel.findOne({_id, type: 'Person'}); 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /src/modules/well-known/README.md: -------------------------------------------------------------------------------- 1 | # Well Known Module 2 | Responsible for handling all endpoints on the `.well-known` path, this module handles Webfinger and Host Metadata requests which are used by other ActivityPub service. -------------------------------------------------------------------------------- /src/modules/well-known/dto/webfinger-link.dto.ts: -------------------------------------------------------------------------------- 1 | import { Expose } from 'class-transformer'; 2 | 3 | export class WebfingerLinkDto { 4 | @Expose() 5 | public rel!: string; 6 | 7 | @Expose() 8 | public type!: string; 9 | 10 | @Expose() 11 | public href!: string; 12 | } -------------------------------------------------------------------------------- /src/modules/well-known/dto/webfinger.dto.ts: -------------------------------------------------------------------------------- 1 | import { Expose, Type } from 'class-transformer'; 2 | import { WebfingerLinkDto } from './webfinger-link.dto'; 3 | 4 | export class WebfingerDto { 5 | @Expose() 6 | public subject!: string; 7 | 8 | @Expose() 9 | public aliases!: string[]; 10 | 11 | @Expose() 12 | @Type(() => WebfingerLinkDto) 13 | public links!: WebfingerLinkDto[]; 14 | } -------------------------------------------------------------------------------- /src/modules/well-known/host-meta.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { HostMetaController } from './host-meta.controller'; 3 | 4 | describe('HostMetaController', () => { 5 | let controller: HostMetaController; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [HostMetaController], 10 | }).compile(); 11 | 12 | controller = module.get(HostMetaController); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/modules/well-known/host-meta.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Header, Logger } from '@nestjs/common'; 2 | import { ServiceDomain } from '../../common/decorators/service-domain.decorator'; 3 | 4 | @Controller('.well-known/host-meta') 5 | export class HostMetaController { 6 | protected readonly logger = new Logger(HostMetaController.name); 7 | @Get() 8 | @Header('Content-Type', 'application/xrd+xml') 9 | async getHostMeta(@ServiceDomain() domain: string) { 10 | this.logger.debug(`host-meta: ${domain}`); 11 | return ` 12 | 13 | 14 | `; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/modules/well-known/webfinger.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { WebfingerController } from './webfinger.controller'; 3 | 4 | describe('Webfinger Controller', () => { 5 | let controller: WebfingerController; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [WebfingerController], 10 | }).compile(); 11 | 12 | controller = module.get(WebfingerController); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/modules/well-known/webfinger.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Header, Logger, NotFoundException, Query } from '@nestjs/common'; 2 | import { plainToInstance } from 'class-transformer'; 3 | import { ServiceDomain } from '../../common/decorators/service-domain.decorator'; 4 | import { WebfingerDto } from './dto/webfinger.dto'; 5 | import { WebfingerService } from './webfinger.service'; 6 | 7 | @Controller('.well-known/webfinger') 8 | export class WebfingerController { 9 | protected readonly logger = new Logger(WebfingerController.name); 10 | 11 | constructor(protected readonly webfingerService: WebfingerService) {} 12 | 13 | @Get() 14 | @Header('Content-Type', 'application/jrd+json') 15 | public async webfinger(@ServiceDomain() domain: string, @Query('resource') resource: string): Promise { 16 | if (!resource) { 17 | this.logger.error('webfinger: no resource specified'); 18 | throw new NotFoundException(); 19 | } 20 | 21 | this.logger.debug(`webfinger: ${resource}`); 22 | const [, username, parsedServiceId] = /^acct:([\w_]*)@(.*)$/i.exec(resource) || []; 23 | 24 | if (!username || !parsedServiceId) { 25 | this.logger.error(`webfinger: invalid resource specified: ${resource}`); 26 | throw new NotFoundException(); 27 | } 28 | 29 | if (parsedServiceId !== domain) { 30 | throw new NotFoundException(); 31 | } 32 | 33 | const response = this.webfingerService.getAccount(domain, username); 34 | 35 | return plainToInstance(WebfingerDto, response); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/modules/well-known/webfinger.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { WebfingerService } from './webfinger.service'; 3 | 4 | describe('WebfingerService', () => { 5 | let service: WebfingerService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [WebfingerService], 10 | }).compile(); 11 | 12 | service = module.get(WebfingerService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/modules/well-known/webfinger.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NotFoundException } from '@nestjs/common'; 2 | import { ObjectService } from '../object/object.service'; 3 | import { UserService } from '../user/user.service'; 4 | import { WebfingerDto } from './dto/webfinger.dto'; 5 | 6 | @Injectable() 7 | export class WebfingerService { 8 | constructor( 9 | protected userService: UserService, 10 | protected objectService: ObjectService 11 | ) { } 12 | 13 | public async getAccount(domain: string, username: string): Promise { 14 | const user = await this.userService.findOne(domain, username); 15 | 16 | if (!user) { 17 | throw new NotFoundException(); 18 | } 19 | 20 | const person = await this.objectService.findByInternalId(user.defaultIdentity); 21 | 22 | if (!person) { 23 | throw new NotFoundException(); 24 | } 25 | 26 | return { 27 | subject: `acct:${username}@${domain}`, 28 | aliases: [person.id], 29 | links: [ 30 | { 31 | rel: 'self', 32 | type: 'application/activity+json', 33 | href: person.id, 34 | }, 35 | { 36 | rel: 'https://webfinger.net/rel/profile-page', 37 | type: 'text/html', 38 | href: person.id 39 | } 40 | ], 41 | }; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/modules/well-known/well-known.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ObjectModule } from '../object/object.module'; 3 | import { UserModule } from '../user/user.module'; 4 | import { WebfingerController } from './webfinger.controller'; 5 | import { WebfingerService } from './webfinger.service'; 6 | import { HostMetaController } from './host-meta.controller'; 7 | 8 | @Module({ 9 | controllers: [WebfingerController, HostMetaController], 10 | providers: [WebfingerService], 11 | imports: [ObjectModule, UserModule] 12 | }) 13 | export class WellKnownModule {} 14 | -------------------------------------------------------------------------------- /src/repl.ts: -------------------------------------------------------------------------------- 1 | import { repl } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | 4 | async function bootstrap() { 5 | await repl(AppModule); 6 | } 7 | 8 | bootstrap(); -------------------------------------------------------------------------------- /test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import * as request from 'supertest'; 3 | import { AppModule } from './../src/app.module'; 4 | 5 | describe('AppController (e2e)', () => { 6 | let app: any; 7 | 8 | beforeEach(async () => { 9 | const moduleFixture: TestingModule = await Test.createTestingModule({ 10 | imports: [AppModule], 11 | }).compile(); 12 | 13 | app = moduleFixture.createNestApplication(); 14 | await app.init(); 15 | }); 16 | 17 | it('/ (GET)', () => { 18 | return request(app.getHttpServer()) 19 | .get('/') 20 | .expect(200) 21 | .expect('Hello World!'); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "esModuleInterop": true, 8 | "experimentalDecorators": true, 9 | "target": "es2017", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | "noImplicitAny": true, 16 | "strictNullChecks": true, 17 | "strict": true, 18 | "allowJs": true 19 | }, 20 | "exclude": ["node_modules", "dist"] 21 | } 22 | -------------------------------------------------------------------------------- /tsfmt.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseIndentSize": 0, 3 | "indentSize": 2, 4 | "tabSize": 4, 5 | "indentStyle": 2, 6 | "newLineCharacter": "\r\n", 7 | "convertTabsToSpaces": true, 8 | "insertSpaceAfterCommaDelimiter": true, 9 | "insertSpaceAfterSemicolonInForStatements": true, 10 | "insertSpaceBeforeAndAfterBinaryOperators": true, 11 | "insertSpaceAfterConstructor": false, 12 | "insertSpaceAfterKeywordsInControlFlowStatements": true, 13 | "insertSpaceAfterFunctionKeywordForAnonymousFunctions": false, 14 | "insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis": false, 15 | "insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets": false, 16 | "insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces": true, 17 | "insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces": false, 18 | "insertSpaceAfterOpeningAndBeforeClosingJsxExpressionBraces": false, 19 | "insertSpaceAfterTypeAssertion": false, 20 | "insertSpaceBeforeFunctionParenthesis": false, 21 | "insertSpaceBeforeTypeAnnotation": false, 22 | "placeOpenBraceOnNewLineForFunctions": false, 23 | "placeOpenBraceOnNewLineForControlBlocks": false 24 | } -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": ["tslint:recommended"], 4 | "jsRules": { 5 | "no-unused-expression": true 6 | }, 7 | "rules": { 8 | "quotemark": [true, "single"], 9 | "member-access": [false], 10 | "ordered-imports": [false], 11 | "max-line-length": [true, 150], 12 | "member-ordering": [false], 13 | "interface-name": [false], 14 | "arrow-parens": false, 15 | "object-literal-sort-keys": false 16 | }, 17 | "rulesDirectory": [] 18 | } 19 | --------------------------------------------------------------------------------