├── .dockerignore ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .husky ├── .gitignore └── pre-commit ├── .vscode └── launch.json ├── Dockerfile ├── LICENSE ├── assets ├── activitypub.json └── client.json ├── biome.json ├── docker-compose.yml ├── docs ├── attachments.md ├── docker.md ├── env.md ├── gateway.md ├── janus.md ├── nginx.md ├── nginx_complex.md └── systemd.md ├── federation.md ├── package-lock.json ├── package.json ├── readme.md ├── shoot.code-workspace ├── src ├── bootstrap.ts ├── cli │ ├── cli.ts │ ├── handlers │ │ ├── addUser.ts │ │ ├── generateKeys.ts │ │ ├── generateRegInvite.ts │ │ ├── index.ts │ │ └── instance.ts │ ├── index.ts │ └── util.ts ├── entity │ ├── DMChannel.ts │ ├── actor.ts │ ├── apcache.ts │ ├── attachment.ts │ ├── basemodel.ts │ ├── channel.ts │ ├── embed.ts │ ├── guild.ts │ ├── instanceInvite.ts │ ├── invite.ts │ ├── member.ts │ ├── message.ts │ ├── migrations.ts │ ├── pushSubscription.ts │ ├── relationship.ts │ ├── role.ts │ ├── session.ts │ ├── textChannel.ts │ ├── upload.ts │ └── user.ts ├── gateway │ ├── bootstrap.ts │ ├── handlers │ │ ├── heartbeat.ts │ │ ├── identify.ts │ │ ├── index.ts │ │ └── members.ts │ ├── readme.md │ ├── server.ts │ ├── socket │ │ ├── close.ts │ │ ├── connection.ts │ │ └── message.ts │ └── util │ │ ├── codes.ts │ │ ├── listener.ts │ │ ├── validation │ │ ├── receive.ts │ │ └── send.ts │ │ └── websocket.ts ├── http │ ├── api │ │ ├── auth │ │ │ ├── login.ts │ │ │ └── register.ts │ │ ├── channel │ │ │ └── #id │ │ │ │ ├── attachments.ts │ │ │ │ ├── call.ts │ │ │ │ ├── index.ts │ │ │ │ └── messages │ │ │ │ ├── #id │ │ │ │ └── index.ts │ │ │ │ └── index.ts │ │ ├── guild │ │ │ ├── #id │ │ │ │ ├── channel.ts │ │ │ │ └── index.ts │ │ │ └── index.ts │ │ ├── index.ts │ │ ├── invite │ │ │ └── index.ts │ │ ├── upload │ │ │ └── index.ts │ │ └── users │ │ │ ├── #id │ │ │ ├── channels.ts │ │ │ ├── index.ts │ │ │ └── relationship.ts │ │ │ └── @me │ │ │ ├── channels.ts │ │ │ ├── guild.ts │ │ │ ├── index.ts │ │ │ └── push.ts │ ├── bootstrap.ts │ ├── middleware │ │ ├── auth.ts │ │ ├── error.ts │ │ ├── httpsig.ts │ │ └── rate.ts │ ├── routes.ts │ ├── s2s │ │ ├── actor.ts │ │ ├── channel │ │ │ └── #id │ │ │ │ ├── index.ts │ │ │ │ └── message │ │ │ │ └── #id │ │ │ │ └── index.ts │ │ ├── guild │ │ │ └── #id │ │ │ │ ├── index.ts │ │ │ │ └── role │ │ │ │ └── index.ts │ │ ├── inbox.ts │ │ ├── index.ts │ │ ├── invite │ │ │ └── #id │ │ │ │ └── index.ts │ │ └── users │ │ │ └── #id │ │ │ └── index.ts │ ├── server.ts │ └── wellknown │ │ ├── host-meta.ts │ │ ├── index.ts │ │ ├── nodeinfo.ts │ │ └── webfinger.ts ├── media │ ├── bootstrap.ts │ ├── handlers │ │ ├── heartbeat.ts │ │ ├── identify.ts │ │ └── index.ts │ ├── janus │ │ ├── index.ts │ │ └── types.ts │ ├── readme.md │ ├── server.ts │ ├── socket │ │ ├── close.ts │ │ ├── connection.ts │ │ └── message.ts │ └── util │ │ ├── events.ts │ │ ├── janus.ts │ │ ├── rooms.ts │ │ ├── validation │ │ ├── receive.ts │ │ └── send.ts │ │ └── websocket.ts ├── push │ ├── notifications.ts │ └── worker.ts ├── receiver │ └── index.ts ├── scripts │ ├── openapi.ts │ └── stress │ │ └── users.ts ├── sender │ └── index.ts └── util │ ├── activitypub │ ├── constants.ts │ ├── error.ts │ ├── httpsig.ts │ ├── inbox │ │ ├── handlers │ │ │ ├── accept.ts │ │ │ ├── announce.ts │ │ │ ├── create.ts │ │ │ ├── follow.ts │ │ │ ├── index.ts │ │ │ ├── join.ts │ │ │ ├── like.ts │ │ │ └── undo.ts │ │ └── index.ts │ ├── instanceActor.ts │ ├── instanceBehaviour.ts │ ├── instances.ts │ ├── orderedCollection.ts │ ├── resolve.ts │ ├── transformers │ │ ├── actor.ts │ │ ├── invite.ts │ │ ├── message.ts │ │ └── role.ts │ └── util.ts │ ├── checkPermission.ts │ ├── config.ts │ ├── database.ts │ ├── datasource.ts │ ├── embeds │ ├── generators │ │ ├── generic.ts │ │ ├── index.ts │ │ └── simple.ts │ └── index.ts │ ├── entity │ ├── actor.ts │ ├── channel.ts │ ├── guild.ts │ ├── invite.ts │ ├── member.ts │ ├── message.ts │ ├── relationship.ts │ ├── resolve.ts │ ├── role.ts │ └── user.ts │ ├── events.ts │ ├── httperror.ts │ ├── log.ts │ ├── migration │ └── postgres │ │ ├── 1749945089530-local_upload.ts │ │ ├── 1750057984384-channelOrdering.ts │ │ ├── 1751438079643-register-invites.ts │ │ ├── 1757219628590-embeds.ts │ │ ├── 1757588526875-uploadsCascade.ts │ │ ├── 1757745204938-inviteIndex.ts │ │ └── 1758865221251-pushSubscription.ts │ ├── object.ts │ ├── permission.ts │ ├── route.ts │ ├── rsa.ts │ ├── storage │ ├── index.ts │ ├── local.ts │ └── s3.ts │ ├── token.ts │ ├── types.ts │ ├── url.ts │ └── voice.ts ├── test ├── cli │ ├── adduser.ts │ └── config.ts ├── gateway │ └── index.ts ├── helpers │ ├── channel.ts │ ├── config.ts │ ├── database.ts │ ├── env.ts │ ├── force_exit.mjs │ ├── guild.ts │ ├── types.ts │ └── users.ts ├── http │ ├── auth.ts │ ├── channels.ts │ ├── crud │ │ ├── channels.ts │ │ └── guilds.ts │ ├── dm.ts │ ├── guild.ts │ ├── users │ │ ├── @me.ts │ │ └── relationship.ts │ └── wellknown │ │ ├── hostmeta.ts │ │ ├── snapshots │ │ ├── hostmeta.ts.md │ │ ├── hostmeta.ts.snap │ │ ├── webfinger.ts.md │ │ └── webfinger.ts.snap │ │ └── webfinger.ts ├── inbox │ └── create.ts ├── template.ts.disabled ├── unit │ ├── httpsig.ts │ └── sender.ts └── webrtc │ └── call.ts ├── todo.md └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | **/.classpath 2 | **/.dockerignore 3 | **/.env 4 | **/.git 5 | **/.gitignore 6 | **/.project 7 | **/.settings 8 | **/.toolstarget 9 | **/.vs 10 | **/.vscode 11 | **/.next 12 | **/.cache 13 | **/*.*proj.user 14 | **/*.dbmdl 15 | **/*.jfm 16 | **/charts 17 | **/docker-compose* 18 | **/compose.y*ml 19 | **/Dockerfile* 20 | **/node_modules 21 | **/npm-debug.log 22 | **/obj 23 | **/secrets.dev.yaml 24 | **/values.dev.yaml 25 | **/build 26 | **/dist 27 | config/** 28 | LICENSE 29 | README.md -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | services: 13 | postgres: 14 | image: postgres:latest 15 | env: 16 | POSTGRES_DB: postgres 17 | POSTGRES_PASSWORD: postgres 18 | POSTGRES_USER: postgres 19 | ports: 20 | - 5432:5432 21 | # Set health checks to wait until postgres has started 22 | options: >- 23 | --health-cmd pg_isready 24 | --health-interval 10s 25 | --health-timeout 5s 26 | --health-retries 5 27 | 28 | strategy: 29 | matrix: 30 | node-version: [20.x] 31 | 32 | steps: 33 | - uses: actions/checkout@v4 34 | 35 | - name: Use Node.js ${{ matrix.node-version }} 36 | uses: actions/setup-node@v4 37 | with: 38 | node-version: ${{ matrix.node-version }} 39 | cache: "npm" 40 | 41 | - run: npm ci 42 | 43 | - run: npm run build 44 | 45 | - run: npm run openapi 46 | 47 | - run: npm test 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /config 3 | /dist 4 | /database.db 5 | /coverage 6 | /storage -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged 2 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Bootstrap", 11 | "skipFiles": [ 12 | "/**" 13 | ], 14 | "program": "${workspaceFolder}/src/bootstrap.ts", 15 | "outFiles": [ 16 | "${workspaceFolder}/dist/**/*.js" 17 | ], 18 | "preLaunchTask": "tsc: build - tsconfig.json", 19 | "outputCapture": "std", 20 | "smartStep": true, 21 | "runtimeArgs": [ 22 | "--enable-source-maps" 23 | ], 24 | }, 25 | { 26 | "type": "node", 27 | "request": "launch", 28 | "name": "Current File", 29 | "skipFiles": [ 30 | "/**" 31 | ], 32 | "program": "${file}", 33 | "outFiles": [ 34 | "${workspaceFolder}/dist/**/*.js" 35 | ], 36 | "preLaunchTask": "tsc: build - tsconfig.json", 37 | "outputCapture": "std" 38 | }, 39 | { 40 | "type": "node", 41 | "request": "launch", 42 | "name": "Debug test file", 43 | "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/ava", 44 | "args": [ 45 | "${file}" 46 | ], 47 | "outputCapture": "std", 48 | "skipFiles": [ 49 | "/**/*.js" 50 | ], 51 | "console": "integratedTerminal", 52 | "internalConsoleOptions": "neverOpen", 53 | "outFiles": [ 54 | "${workspaceFolder}/dist/**/*.js" 55 | ], 56 | "cwd": "${workspaceFolder}" 57 | } 58 | ] 59 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | 3 | FROM node:20-slim AS base 4 | 5 | COPY . /app 6 | WORKDIR /app 7 | 8 | FROM base AS prod-deps 9 | RUN --mount=type=bind,source=package.json,target=package.json \ 10 | --mount=type=bind,source=package-lock.json,target=package-lock.json \ 11 | --mount=type=cache,target=/root/.npm \ 12 | npm ci --omit=dev 13 | 14 | FROM base AS build 15 | RUN --mount=type=bind,source=package.json,target=package.json \ 16 | --mount=type=bind,source=package-lock.json,target=package-lock.json \ 17 | --mount=type=cache,target=/root/.npm \ 18 | npm ci 19 | RUN npm run build 20 | 21 | FROM gcr.io/distroless/nodejs20-debian11 22 | 23 | COPY --from=prod-deps /app/node_modules /app/node_modules 24 | COPY --from=build /app/dist /app/dist 25 | COPY --from=build /app/package.json /app/package.json 26 | 27 | WORKDIR /app 28 | 29 | EXPOSE 3000 30 | 31 | CMD [ "./dist/src/bootstrap.js" ] -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "$schema": "https://biomejs.dev/schemas/2.2.4/schema.json", 4 | "formatter": { 5 | "enabled": true, 6 | "formatWithErrors": true, 7 | "indentStyle": "tab", 8 | "indentWidth": 4, 9 | "lineEnding": "lf", 10 | "lineWidth": 80, 11 | "attributePosition": "auto" 12 | }, 13 | "assist": { 14 | "actions": { 15 | "source": { 16 | "organizeImports": "on" 17 | } 18 | } 19 | }, 20 | "linter": { 21 | "enabled": true, 22 | "rules": { 23 | "recommended": true, 24 | "style": { 25 | "noParameterAssign": "off" 26 | }, 27 | "correctness": { 28 | "noUnusedImports": "warn" 29 | }, 30 | "performance": { 31 | "noBarrelFile": "error" 32 | } 33 | } 34 | }, 35 | "javascript": { 36 | "formatter": { 37 | "jsxQuoteStyle": "double", 38 | "quoteProperties": "asNeeded", 39 | "trailingCommas": "all", 40 | "semicolons": "always", 41 | "arrowParentheses": "always", 42 | "bracketSpacing": true, 43 | "bracketSameLine": false, 44 | "quoteStyle": "double", 45 | "attributePosition": "auto" 46 | } 47 | } 48 | } -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | postgresdb: 3 | image: postgres 4 | ports: 5 | - 5432:5432 6 | networks: 7 | - default 8 | volumes: 9 | - shoot-postgres:/var/lib/postgresql/data 10 | environment: 11 | - POSTGRES_PASSWORD=postgres 12 | - POSTGRES_USER=postgres 13 | - POSTGRES_DB=shoot 14 | healthcheck: 15 | test: [ "CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}" ] 16 | interval: 10s 17 | timeout: 60s 18 | start_period: 5s 19 | retries: 5 20 | 21 | backend: 22 | networks: 23 | - default 24 | build: 25 | context: . 26 | ports: 27 | - 3000:3000 28 | volumes: 29 | - ./config:/app/config 30 | - shoot-storage:/app/storage 31 | depends_on: 32 | postgresdb: 33 | condition: service_healthy 34 | environment: 35 | - 'NODE_CONFIG={"database":{"url":"postgres://postgres:postgres@postgresdb/shoot"}}' 36 | command: ./dist/src/bootstrap.js 37 | restart: always 38 | 39 | networks: 40 | default: 41 | volumes: 42 | shoot-postgres: 43 | shoot-storage: 44 | -------------------------------------------------------------------------------- /docs/docker.md: -------------------------------------------------------------------------------- 1 | # Docker 2 | 3 | Shoot provides a Dockerfile and docker-compose file. 4 | 5 | The docker-compose file will: 6 | - Set up a basic Shoot instance (single process `npm run start` equivalent) 7 | - Set up a postgres database 8 | 9 | It will not set up any optional dependencies. 10 | 11 | It is a bare-bones installation for those who wish to get up and running quickly. 12 | For those who wish to set up more sophisticated Shoot instances, you must still do so manually. 13 | 14 | ## Usage 15 | 16 | Requirements: 17 | - [Docker](https://www.docker.com/) 18 | - [Docker Compose](https://docs.docker.com/compose/install/) 19 | 20 | Simply run the following: 21 | 22 | ```sh 23 | docker compose up 24 | ``` 25 | 26 | You will now have a Shoot instance running on port `3000` 27 | 28 | ## Configuration 29 | 30 | Shoot configuration is mounted on `./config`. 31 | You may use the Shoot CLI to generate keys as normal. 32 | 33 | The Postgres database is persisted via a volume. Do not compose down as you will lose the volume. 34 | User content uploaded to Shoot (i.e. when not using s3) is also persisted to a volume. 35 | 36 | ## CLI 37 | 38 | Set Shoot's `database.url` config to `postgres://postgres:postgres@localhost` as defined by `docker-compose.yml`. 39 | 40 | You may now use the Shoot CLI as normal. If you wish to do database operations, the postgres container must up. 41 | 42 | You do not need to make any changes to config when you restart the Shoot container, as the docker compose file overwrites the `database.url`. -------------------------------------------------------------------------------- /docs/env.md: -------------------------------------------------------------------------------- 1 | # Environment Variables 2 | 3 | Below is a list of all environment variables Shoot uses. 4 | 5 | Shoot uses node-config which also supports various variables which you may find [here](https://github.com/node-config/node-config/wiki/Environment-Variables) 6 | 7 | | name | description | 8 | | - | - | 9 | | PORT | Network port to listen on | 10 | | MEDIA_PORT | Network port to listen on for webrtc signalling | -------------------------------------------------------------------------------- /docs/janus.md: -------------------------------------------------------------------------------- 1 | # WebRTC (Voice) Support 2 | 3 | Shoot supports voice calling via [Janus Gateway](https://github.com/meetecho/janus-gateway). 4 | 5 | You must build Janus with the following optional dependencies: 6 | - [libopus](https://opus-codec.org/) 7 | - [libwebsockets](https://libwebsockets.org/). Otherwise, must use Unix sockets. HTTP API is not supported. 8 | 9 | You will also need an address to host Shoot's signalling server (preferably a (sub?)domain). 10 | 11 | ## Janus Configuration 12 | 13 | Consult the Janus documentation. 14 | 15 | For my case running an instance in Oracle Cloud, I had to provide the interface (`-i`) and public IP `--nat-1-1`. 16 | Whether or not this is *correct* is unknown to me. 17 | 18 | ## Shoot Configuration 19 | 20 | Shoot has a number of [configuration options](https://github.com/MaddyUnderStars/shoot/blob/main/src/util/config.ts) under the `webrtc` key for configuring webrtc. 21 | 22 | The options `webrtc.janus_secret` and `webrtc.janus_url` work with Janus' default config (no secret, on localhost via websocket). You may change this if Janus is hosted on a separate machine or you have configured a secret. 23 | 24 | The only option we currently need to set is `webrtc.signal_address`, which is the address of the Shoot signalling server given to clients to negotiate connections. 25 | 26 | Shoot by default starts the signalling server on port 3003 (`MEDIA_PORT` env var), even if you're using the single process bootstrap script (`npm run start`). 27 | 28 | As with other Shoot services, you may start the signalling server separately via `npm run start:media` 29 | 30 | ## Nginx Configuration 31 | 32 | Below is an example Nginx config for Shoot's signalling server. 33 | 34 | Make sure to change any placeholders, wrapped in `<>` 35 | 36 | ```nginx 37 | server { 38 | server_name ; 39 | 40 | client_max_body_size 50M; 41 | 42 | proxy_set_header Host $host; 43 | proxy_set_header X-Forwarded-For $remote_addr; 44 | 45 | # enable websockets 46 | proxy_set_header Upgrade $http_upgrade; 47 | proxy_set_header Connection "upgrade"; 48 | 49 | location / { 50 | proxy_pass http://127.0.0.1:3003; 51 | } 52 | } 53 | ``` 54 | 55 | ## Janus SystemD 56 | 57 | Below is an example SystemD unit file for running Janus. 58 | 59 | It assumes you have installed Janus at `/opt/janus`. 60 | 61 | ```ini 62 | [Unit] 63 | Description=Janus Gateway 64 | 65 | [Service] 66 | User=ubuntu 67 | WorkingDirectory=/opt/janus/bin 68 | ExecStart=/opt/janus/bin/janus 69 | Restart=always 70 | StandardError=journal 71 | StandardOutput=journal 72 | 73 | [Install] 74 | WantedBy=multi-user.target 75 | ``` -------------------------------------------------------------------------------- /docs/nginx.md: -------------------------------------------------------------------------------- 1 | # Nginx Configuration 2 | 3 | When hosting both the Shoot server and the [client](https://github.com/MaddyUnderStars/shoot-client), 4 | it may be desirable to have them on the same hostname. 5 | 6 | You can easily do so via a reverse proxy such as [Nginx](https://nginx.org/) 7 | 8 | Below is a sample Nginx configuration to achieve: 9 | 10 | - API and S2S on `example.com/api` 11 | - Client on `example.com` 12 | 13 | We assume that the client has been built, and the build artefacts have been placed in `/var/www/html` 14 | 15 | The below will need some changes not covered here if you wish to have multiple backend servers with load balancing, for example. 16 | 17 | You may configure TLS via [Certbot](https://certbot.eff.org/) for example. 18 | 19 | Make sure to change any placeholders, wrapped in `<>` 20 | 21 | ```nginx 22 | server { 23 | server_name ; 24 | listen 80; 25 | 26 | location ~ ^/(api|\.well-known) { 27 | # Shoot API hosted on `/api` and `.well-known` pass through 28 | 29 | proxy_pass http://127.0.0.1:3001; 30 | 31 | proxy_no_cache 1; 32 | 33 | proxy_set_header Host $host; 34 | proxy_set_header X-Forwarded-For $remote_addr; 35 | 36 | # enable websockets 37 | proxy_set_header Upgrade $http_upgrade; 38 | proxy_set_header Connection "upgrade"; 39 | 40 | # remove the api prefix on the request before sending it to backend 41 | rewrite /api(/?)(.*) /$2 break; 42 | } 43 | 44 | location / { 45 | # Shoot client hosted on `/` 46 | 47 | root /var/www/html; 48 | index index.html; 49 | try_files $uri /index.html =404; 50 | } 51 | } 52 | ``` -------------------------------------------------------------------------------- /docs/nginx_complex.md: -------------------------------------------------------------------------------- 1 | # Nginx Configuration 2 | 3 | When you scale up your deployment, it may be useful to horizontally scale Shoot's services. 4 | 5 | Below is a sample Nginx configuration to achieve: 6 | - Load balanced API/S2S on `api.example.com` 7 | - Load balanced Gateway on `gateway.example.com` 8 | 9 | Requirements: 10 | - [RabbitMQ](https://www.rabbitmq.com/) 11 | - Running Shoot as individual services (npm `start:http`, `start:gateway` etc) 12 | 13 | Make sure to change any placeholders, wrapped in `<>` 14 | 15 | ```nginx 16 | upstream api { 17 | server <127.0.0.1:3001>; # first API server 18 | server <127.0.0.1:4001>; # second API server 19 | # ... additional API servers. 20 | 21 | # See https://docs.nginx.com/nginx/admin-guide/load-balancer/http-load-balancer/ 22 | } 23 | 24 | upstream gateway { 25 | # ensure users reconnect to the same gateway 26 | ip_hash; 27 | 28 | server <127.0.0.1:3002>; # first gateway server 29 | server <127.0.0.1:4002>; # second gateway server 30 | # ... additional gateway servers. 31 | } 32 | 33 | server { 34 | server_name ; 35 | listen 80; 36 | 37 | location / { 38 | proxy_pass http://api; 39 | 40 | proxy_no_cache 1; 41 | 42 | proxy_set_header Host $host; 43 | proxy_set_header X-Forwarded-For $remote_addr; 44 | } 45 | } 46 | 47 | server { 48 | server_name ; 49 | listen 80; 50 | 51 | location / { 52 | proxy_pass http://gateway; 53 | 54 | proxy_set_header Host $host; 55 | proxy_set_header X-Forwarded-For $remote_addr; 56 | 57 | proxy_set_header Upgrade $http_upgrade; 58 | proxy_set_header Connection "upgrade"; 59 | } 60 | } 61 | ``` -------------------------------------------------------------------------------- /docs/systemd.md: -------------------------------------------------------------------------------- 1 | # SystemD 2 | 3 | Below is an example SystemD service file for Shoot. 4 | 5 | You may put it in `/etc/systemd/system/shoot.service` 6 | 7 | Be sure to replace any placeholders, wrapped in `<>`. 8 | 9 | - `` refers to the root directory of Shoot. 10 | - `` refers to the user you wish Shoot to run on. It is recommended you create a new user, and that you do not run Shoot as root. 11 | 12 | ```ini 13 | [Unit] 14 | Description=Shoot Server 15 | 16 | [Service] 17 | User= 18 | WorkingDirectory= 19 | ExecStart=npm run start 20 | Restart=always 21 | StandardError=journal 22 | StandardOutput=journal 23 | 24 | [Install] 25 | WantedBy=multi-user.target 26 | ``` -------------------------------------------------------------------------------- /federation.md: -------------------------------------------------------------------------------- 1 | # How we implement federation 2 | 3 | ## Supported standards 4 | 5 | - [Activitypub](https://www.w3.org/TR/activitypub/) 6 | - [Webfinger](https://datatracker.ietf.org/doc/html/rfc7033) 7 | - [Http Signatures](https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures#section-2.1) 8 | - [NodeInfo](https://nodeinfo.diaspora.software/) 9 | 10 | - [FEP-2677: Identifying the Application Actor](https://codeberg.org/fediverse/fep/src/commit/f5ab0557712522ebb579c86f165ee1f3152a85d3/fep/2677/fep-2677.md) 11 | - [FEP-2c59: Discovery of a Webfinger address from an ActivityPub actor](https://codeberg.org/fediverse/fep/src/commit/f5ab0557712522ebb579c86f165ee1f3152a85d3/fep/2c59/fep-2c59.md) 12 | 13 | ## Usage 14 | 15 | TBD 16 | 17 | # Helpful Activitypub Resources 18 | 19 | ## Activitypub Specification 20 | 21 | - [Activitystreams vocab](https://www.w3.org/TR/activitystreams-vocabulary) 22 | - [Activitystreams](https://www.w3.org/TR/activitystreams-core) 23 | - [Activitypub spec](https://www.w3.org/TR/activitypub/) 24 | 25 | ## Community posts 26 | 27 | - [Activitypub as it has been understood](https://flak.tedunangst.com/post/ActivityPub-as-it-has-been-understood) 28 | - [Guide for new ActivityPub implementers](https://socialhub.activitypub.rocks/t/guide-for-new-activitypub-implementers/479) 29 | - Understanding activitypub 30 | [part 1](https://seb.jambor.dev/posts/understanding-activitypub/), 31 | [part 2](https://seb.jambor.dev/posts/understanding-activitypub-part-2-lemmy/), 32 | [part 3](https://seb.jambor.dev/posts/understanding-activitypub-part-3-the-state-of-mastodon/) 33 | - [Nodejs Express Activitypub sample implementation](https://github.com/dariusk/express-activitypub) 34 | - [Reading Activitypub](https://tinysubversions.com/notes/reading-activitypub/#the-ultimate-tl-dr) 35 | 36 | ## Tools 37 | 38 | - [Actor verification](https://verify.funfedi.dev) 39 | -------------------------------------------------------------------------------- /shoot.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "src" 5 | }, 6 | { 7 | "path": "test" 8 | }, 9 | { 10 | "path": "config" 11 | }, 12 | { 13 | "path": "assets" 14 | }, 15 | { 16 | "path": "docs" 17 | }, 18 | { 19 | "name": "shoot", 20 | "path": "." 21 | } 22 | ], 23 | "settings": { 24 | "typescript.tsdk": "shoot/node_modules/typescript/lib", 25 | "editor.defaultFormatter": "biomejs.biome", 26 | "editor.formatOnSave": true, 27 | "[typescript]": { 28 | "editor.defaultFormatter": "biomejs.biome" 29 | } 30 | }, 31 | } -------------------------------------------------------------------------------- /src/bootstrap.ts: -------------------------------------------------------------------------------- 1 | import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi"; 2 | import { z } from "zod"; 3 | 4 | extendZodWithOpenApi(z); 5 | 6 | import "dotenv/config"; 7 | import { createServer } from "node:http"; 8 | import { GatewayServer } from "./gateway/server"; 9 | import { APIServer } from "./http/server"; 10 | import { MediaGatewayServer } from "./media/server"; 11 | 12 | import { config } from "./util/config"; 13 | import { createLogger, setLogOptions } from "./util/log"; 14 | 15 | setLogOptions(config.log); 16 | const Log = createLogger("bootstrap"); 17 | Log.msg("Starting"); 18 | 19 | // Check nodejs version 20 | const NODE_REQUIRED_VERSION = 18; 21 | const [NODE_MAJOR_VERSION] = process.versions.node.split(".").map(Number); 22 | if (NODE_MAJOR_VERSION < NODE_REQUIRED_VERSION) { 23 | Log.error( 24 | `You are running node version ${NODE_MAJOR_VERSION}. We require a version > ${NODE_REQUIRED_VERSION}. Please upgrade.`, 25 | ); 26 | process.exit(1); 27 | } 28 | 29 | const PORT = process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 3001; 30 | const MEDIA_PORT = process.env.MEDIA_PORT 31 | ? Number.parseInt(process.env.MEDIA_PORT, 10) 32 | : 3003; 33 | 34 | process.on("uncaughtException", (error, origin) => { 35 | Log.error(`Caught ${origin}`, error); 36 | }); 37 | 38 | const http = createServer(); 39 | 40 | const api = new APIServer(http); 41 | const gateway = new GatewayServer(http); 42 | 43 | const media = new MediaGatewayServer(); 44 | 45 | Promise.all([api.listen(PORT), gateway.listen(PORT), media.listen(MEDIA_PORT)]); 46 | -------------------------------------------------------------------------------- /src/cli/cli.ts: -------------------------------------------------------------------------------- 1 | import { createLogger } from "../util/log"; 2 | import { cliHandlers } from "./handlers"; 3 | 4 | const Log = createLogger("cli"); 5 | 6 | export const handleCli = async (argv: string[]) => { 7 | const args = argv.slice(2); 8 | const cmd = args.shift()?.toLowerCase(); 9 | 10 | if (!cmd) { 11 | Log.warn( 12 | "Syntax: `npm run cli -- [option]. Options:\n" + 13 | "generate-keys - Generate signing keys for federation HTTP signatures and user tokens\n" + 14 | "generate-reg-invite [code?] [maxUses?] [expiry?] - Generate a registration invite. Provide -1 for no restriction for field\n" + 15 | "add-user [username] [email?] - Register a new user\n" + 16 | "instance [url] [action?] - View, block, limit, or allow instances\n", 17 | ); 18 | return; 19 | } 20 | 21 | const exec = cliHandlers[cmd]; 22 | if (!exec) { 23 | Log.error(`Unknown option ${cmd}`); 24 | return; 25 | } 26 | 27 | try { 28 | await exec(...args); 29 | } catch (e) { 30 | Log.error(e instanceof Error ? e.message : e); 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /src/cli/handlers/addUser.ts: -------------------------------------------------------------------------------- 1 | import crypto from "node:crypto"; 2 | import { createLogger } from "../../util/log"; 3 | 4 | const Log = createLogger("cli"); 5 | 6 | export const addUser = async (username: string, email?: string) => { 7 | const generatePassword = ( 8 | length = 20, 9 | characters = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz~!@-#$", 10 | ) => 11 | Array.from(crypto.randomFillSync(new Uint32Array(length))) 12 | .map((x) => characters[x % characters.length]) 13 | .join(""); 14 | 15 | const password = generatePassword(); 16 | 17 | if (!username) { 18 | Log.error("Must specify username"); 19 | return; 20 | } 21 | 22 | const { config } = await import("../../util/config"); 23 | const { initDatabase, closeDatabase } = await import("../../util/database"); 24 | const { registerUser } = await import("../../util/entity/user"); 25 | 26 | const handle = `${username}@${config.federation.webapp_url.hostname}`; 27 | 28 | await initDatabase(); 29 | try { 30 | await registerUser(username, password, email, true); 31 | } catch (e) { 32 | Log.error( 33 | `Could not register user ${handle},`, 34 | e instanceof Error ? e.message : e, 35 | ); 36 | return; 37 | } 38 | 39 | Log.msg(`Registered user '${handle}' with password '${password}'`); 40 | 41 | closeDatabase(); 42 | }; 43 | -------------------------------------------------------------------------------- /src/cli/handlers/generateKeys.ts: -------------------------------------------------------------------------------- 1 | import crypto from "node:crypto"; 2 | import { promisify } from "node:util"; 3 | import { createLogger } from "../../util/log"; 4 | import { KEY_OPTIONS } from "../../util/rsa"; 5 | import { appendToConfig } from "../util"; 6 | 7 | const generateKeyPair = promisify(crypto.generateKeyPair); 8 | 9 | const Log = createLogger("cli"); 10 | 11 | export const generateKeys = async () => { 12 | Log.msg("Generating public/private keys"); 13 | 14 | const keys = await generateKeyPair("rsa", KEY_OPTIONS); 15 | 16 | await appendToConfig( 17 | { 18 | federation: { 19 | public_key: keys.publicKey, 20 | private_key: keys.privateKey, 21 | }, 22 | security: { 23 | jwt_secret: crypto.randomBytes(256).toString("base64"), 24 | }, 25 | }, 26 | "./config/default.json", 27 | ); 28 | 29 | Log.msg("Saved to ./config/default.json"); 30 | }; 31 | -------------------------------------------------------------------------------- /src/cli/handlers/generateRegInvite.ts: -------------------------------------------------------------------------------- 1 | import { createLogger } from "../../util/log"; 2 | 3 | const Log = createLogger("cli"); 4 | 5 | export const generateRegInvite = async ( 6 | code?: string, 7 | maxUses?: string, 8 | expiry?: string, 9 | ) => { 10 | const { initDatabase, closeDatabase } = await import("../../util/database"); 11 | 12 | await initDatabase(); 13 | 14 | const { InstanceInvite } = await import("../../entity/instanceInvite"); 15 | const { generateInviteCode } = await import("../../util/entity/invite"); 16 | 17 | if (!code || code === "-1") { 18 | code = await generateInviteCode( 19 | async (x) => 20 | (await InstanceInvite.count({ where: { code: x } })) !== 0, 21 | ); 22 | } 23 | 24 | await InstanceInvite.create({ 25 | code, 26 | expires: !expiry || expiry === "-1" ? null : new Date(expiry), 27 | maxUses: 28 | !maxUses || maxUses === "-1" ? null : Number.parseInt(maxUses, 10), 29 | }).save(); 30 | 31 | Log.msg(`Saved invite with code ${code}`); 32 | 33 | await closeDatabase(); 34 | }; 35 | -------------------------------------------------------------------------------- /src/cli/handlers/index.ts: -------------------------------------------------------------------------------- 1 | import { addUser } from "./addUser"; 2 | import { generateKeys } from "./generateKeys"; 3 | import { generateRegInvite } from "./generateRegInvite"; 4 | import { instance } from "./instance"; 5 | 6 | export const cliHandlers = { 7 | "generate-keys": generateKeys, 8 | "generate-reg-invite": generateRegInvite, 9 | "add-user": addUser, 10 | instance: instance, 11 | } as { [key: string]: (...rest: string[]) => unknown }; 12 | -------------------------------------------------------------------------------- /src/cli/index.ts: -------------------------------------------------------------------------------- 1 | import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi"; 2 | import { z } from "zod"; 3 | 4 | extendZodWithOpenApi(z); 5 | 6 | import { handleCli } from "./cli"; 7 | 8 | handleCli(process.argv); 9 | -------------------------------------------------------------------------------- /src/cli/util.ts: -------------------------------------------------------------------------------- 1 | import { mkdir, readFile, writeFile } from "node:fs/promises"; 2 | import { dirname } from "node:path"; 3 | import { merge } from "ts-deepmerge"; 4 | import type { DeepPartial } from "typeorm"; 5 | import type { config } from "../util/config"; 6 | 7 | export const appendToConfig = async >( 8 | obj: T, 9 | file = "./config/default.json", 10 | ) => { 11 | let existing: string | undefined; 12 | try { 13 | existing = (await readFile(file)).toString(); 14 | } catch (_) {} 15 | 16 | const existingConfig = existing ? JSON.parse(existing) : {}; 17 | 18 | await mkdir(dirname(file), { recursive: true }); 19 | await writeFile(file, JSON.stringify(merge(existingConfig, obj), null, 2)); 20 | }; 21 | -------------------------------------------------------------------------------- /src/entity/DMChannel.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChildEntity, 3 | JoinColumn, 4 | JoinTable, 5 | ManyToMany, 6 | ManyToOne, 7 | } from "typeorm"; 8 | import z from "zod"; 9 | import { ActorMention } from "../util/activitypub/constants"; 10 | import { DefaultPermissions, type PERMISSION } from "../util/permission"; 11 | import { Channel, PublicChannel } from "./channel"; 12 | import type { User } from "./user"; 13 | 14 | // TODO: DM channels should not exist 15 | // Client side, a dm channel should just be a guild channel that gets pinned to the users channel list 16 | // This would allow you to convert a dm channel to a guild and back easily 17 | // and would simplify code a bit 18 | @ChildEntity("dm") 19 | export class DMChannel extends Channel { 20 | /** The recipients of the DM channel, other than the owner */ 21 | @ManyToMany("users") 22 | @JoinTable() 23 | recipients: User[]; 24 | 25 | @ManyToOne("users") 26 | @JoinColumn() 27 | owner: User; 28 | 29 | public toPublic(): PublicDmChannel { 30 | return { 31 | ...super.toPublic(), 32 | 33 | owner: this.owner.mention, 34 | recipients: this.recipients.map((x) => x.mention), 35 | }; 36 | } 37 | 38 | public checkPermission = async ( 39 | user: User, 40 | permission: PERMISSION | PERMISSION[], 41 | ) => { 42 | permission = Array.isArray(permission) ? permission : [permission]; 43 | 44 | if ( 45 | this.owner.id === user.id || 46 | this.recipients.find((x) => x.id === user.id) 47 | ) 48 | return permission.every((x) => DefaultPermissions.includes(x)); 49 | 50 | return false; 51 | }; 52 | } 53 | 54 | export type PublicDmChannel = PublicChannel & { 55 | owner: ActorMention; 56 | recipients: ActorMention[]; 57 | }; 58 | 59 | export const PublicDmChannel: z.ZodType = PublicChannel.and( 60 | z.object({ 61 | owner: ActorMention, 62 | recipients: ActorMention.array(), 63 | }), 64 | ).openapi("PublicDmChannel"); 65 | -------------------------------------------------------------------------------- /src/entity/actor.ts: -------------------------------------------------------------------------------- 1 | import { Column, CreateDateColumn, Index } from "typeorm"; 2 | import type { ActorMention } from "../util/activitypub/constants"; 3 | import { BaseModel } from "./basemodel"; 4 | 5 | export abstract class Actor extends BaseModel { 6 | @CreateDateColumn() 7 | created_date: Date; 8 | 9 | /** The remote address of this actor. If this actor is local, it will be null. */ 10 | @Column({ nullable: true, type: String }) 11 | remote_address: string | null; 12 | 13 | @Column({ type: String, nullable: true }) 14 | @Index({ unique: true, where: "remote_id IS NOT NULL" }) 15 | remote_id: string | null; 16 | 17 | /** The domain of this actor. null if local */ 18 | @Column({ type: String, nullable: true }) 19 | domain: string | null; 20 | 21 | /** 22 | * The addresses of the collections related to the remote actor 23 | */ 24 | @Column({ type: "simple-json", nullable: true }) 25 | collections: { 26 | inbox: string; 27 | outbox: string; 28 | shared_inbox: string | undefined; 29 | followers?: string; 30 | following?: string; 31 | } | null; 32 | 33 | /** 34 | * The private key for this user, used to sign activities sent to external instances 35 | * If this is a remote actor, it will be null. 36 | */ 37 | @Column({ type: String, nullable: true }) 38 | private_key: string | null; 39 | 40 | /** The public key of this user. Exists on both external and internal users */ 41 | @Column({ nullable: true, type: String }) 42 | public_key: string; 43 | 44 | public get mention(): ActorMention { 45 | return `${this.remote_id ?? this.id}@${this.domain}`; 46 | } 47 | 48 | public toPublic(): unknown { 49 | throw new Error("don't"); 50 | } 51 | 52 | public toPrivate() { 53 | return this.toPublic(); 54 | } 55 | 56 | // TODO: how do I narrow this class so that it does contain remote_address 57 | /** Whether or not this actor is controlled by us */ 58 | public isRemote() { 59 | return !!this.remote_address; 60 | } 61 | 62 | public toString() { 63 | return `${this.constructor.name}[${this.mention}]`; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/entity/apcache.ts: -------------------------------------------------------------------------------- 1 | import type { AnyAPObject, APActivity } from "activitypub-types"; 2 | import { 3 | BaseEntity, 4 | Column, 5 | CreateDateColumn, 6 | Entity, 7 | PrimaryColumn, 8 | } from "typeorm"; 9 | 10 | @Entity("activitypub_objects") 11 | export class ApCache extends BaseEntity { 12 | @PrimaryColumn() 13 | id: string; 14 | 15 | @CreateDateColumn() 16 | received: Date; 17 | 18 | @Column({ type: "simple-json" }) 19 | raw: AnyAPObject | APActivity; 20 | } 21 | -------------------------------------------------------------------------------- /src/entity/attachment.ts: -------------------------------------------------------------------------------- 1 | import { BeforeRemove, Column, Entity, ManyToOne } from "typeorm"; 2 | import { z } from "zod"; 3 | import { createLogger } from "../util/log"; 4 | import { deleteFile } from "../util/storage"; 5 | import { BaseModel } from "./basemodel"; 6 | import type { Message } from "./message"; 7 | import { LocalUpload } from "./upload"; 8 | 9 | const Log = createLogger("attachments"); 10 | 11 | @Entity("attachments") 12 | export class Attachment extends BaseModel { 13 | /** user set name of this attachment */ 14 | @Column() 15 | name: string; 16 | 17 | /** ID of this attachment in storage provider */ 18 | @Column() 19 | hash: string; 20 | 21 | /** mime type */ 22 | @Column() 23 | type: string; 24 | 25 | @Column() 26 | size: number; 27 | 28 | @Column({ type: Number, nullable: true }) 29 | width: number | null; 30 | 31 | @Column({ type: Number, nullable: true }) 32 | height: number | null; 33 | 34 | @ManyToOne("messages", (obj: Message) => obj.files, { onDelete: "CASCADE" }) 35 | message: Message; 36 | 37 | public toPublic(): PublicAttachment { 38 | return { 39 | name: this.name, 40 | type: this.type, 41 | hash: this.hash, 42 | size: this.size, 43 | width: this.width, 44 | height: this.height, 45 | }; 46 | } 47 | 48 | @BeforeRemove() 49 | public on_delete() { 50 | deleteFile(this.message.channel.id, this.hash) 51 | .catch((e) => Log.error("Failed to delete attachment", e)) 52 | .then(() => { 53 | LocalUpload.delete({ 54 | hash: this.hash, 55 | channel: { id: this.message.channel.id }, 56 | }).catch((_) => 57 | Log.error("Failed to delete LocalUpload for attachment"), 58 | ); 59 | }); 60 | } 61 | } 62 | 63 | export const PublicAttachment = z 64 | .object({ 65 | name: z.string(), 66 | hash: z.string(), 67 | type: z.string(), 68 | size: z.number(), 69 | width: z.number().nullable().optional(), 70 | height: z.number().nullable().optional(), 71 | }) 72 | .openapi("PublicAttachment"); 73 | 74 | export type PublicAttachment = z.infer; 75 | -------------------------------------------------------------------------------- /src/entity/basemodel.ts: -------------------------------------------------------------------------------- 1 | import { merge } from "ts-deepmerge"; 2 | import { BaseEntity, BeforeInsert, BeforeUpdate, PrimaryColumn } from "typeorm"; 3 | import { v7 as uuidv7 } from "uuid"; 4 | 5 | export abstract class BaseModel extends BaseEntity { 6 | @PrimaryColumn({ type: "uuid" }) 7 | id: string; 8 | 9 | /** Get a public representation of this entity, to be sent to clients. */ 10 | public abstract toPublic(): unknown; 11 | /** Get a private representation of this entity, to be sent to clients. */ 12 | public toPrivate() { 13 | return this.toPublic(); 14 | } 15 | 16 | // biome-ignore lint/correctness/noUnusedPrivateClassMembers: incorrect lint 17 | private toJSON = () => { 18 | throw new Error( 19 | "Do not return database entities directly. Call .toPublic or .toPrivate", 20 | ); 21 | }; 22 | 23 | // todo: better types 24 | public assign(props: object) { 25 | Object.assign(this, merge(this, props)); 26 | return this; 27 | } 28 | 29 | @BeforeInsert() 30 | @BeforeUpdate() 31 | public generate_id() { 32 | if (!this.id) this.id = uuidv7(); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/entity/channel.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, TableInheritance } from "typeorm"; 2 | import { z } from "zod"; 3 | import { ActorMention } from "../util/activitypub/constants"; 4 | import { HttpError } from "../util/httperror"; 5 | import type { PERMISSION } from "../util/permission"; 6 | import { Actor } from "./actor"; 7 | import type { User } from "./user"; 8 | 9 | @Entity("channels") 10 | @TableInheritance({ column: { type: String, name: "type" } }) 11 | export class Channel extends Actor { 12 | @Column() 13 | name: string; 14 | 15 | public toPublic(): PublicChannel { 16 | return { 17 | mention: this.mention, 18 | name: this.name, 19 | }; 20 | } 21 | 22 | public toPrivate(): PublicChannel { 23 | return this.toPublic(); 24 | } 25 | 26 | public throwPermission = async ( 27 | user: User, 28 | permission: PERMISSION | PERMISSION[], 29 | ) => { 30 | // todo: which permission? 31 | if (!(await this.checkPermission(user, permission))) 32 | throw new HttpError("Missing permission", 400); 33 | return true; 34 | }; 35 | 36 | public checkPermission = async ( 37 | _user: User, 38 | _permission: PERMISSION | PERMISSION[], 39 | ): Promise => false; 40 | } 41 | 42 | export type PublicChannel = Pick; 43 | 44 | export const PublicChannel: z.ZodType = z 45 | .object({ 46 | mention: ActorMention, 47 | name: z.string(), 48 | }) 49 | .openapi("PublicChannel"); 50 | -------------------------------------------------------------------------------- /src/entity/guild.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AfterLoad, 3 | Column, 4 | Entity, 5 | JoinColumn, 6 | ManyToOne, 7 | OneToMany, 8 | } from "typeorm"; 9 | import { z } from "zod"; 10 | import { ActorMention } from "../util/activitypub/constants"; 11 | import { checkPermission } from "../util/checkPermission"; 12 | import { HttpError } from "../util/httperror"; 13 | import type { PERMISSION } from "../util/permission"; 14 | import { Actor } from "./actor"; 15 | import { PublicRole, type Role } from "./role"; 16 | import { type GuildTextChannel, PublicGuildTextChannel } from "./textChannel"; 17 | import type { User } from "./user"; 18 | 19 | @Entity("guilds") 20 | export class Guild extends Actor { 21 | @Column() 22 | name: string; 23 | 24 | @Column({ type: String, nullable: true }) 25 | summary: string | null; 26 | 27 | @OneToMany("roles", "guild") 28 | roles: Role[]; 29 | 30 | @ManyToOne("users", { onDelete: "CASCADE" }) 31 | @JoinColumn() 32 | owner: User; 33 | 34 | @OneToMany("channels", "guild") 35 | channels: GuildTextChannel[]; 36 | 37 | public toPublic(): PublicGuild { 38 | return { 39 | mention: this.mention, 40 | name: this.name, 41 | 42 | channels: this.channels 43 | ? this.channels.map((x) => x.toPublic()) 44 | : undefined, 45 | 46 | roles: this.roles ? this.roles.map((x) => x.toPublic()) : undefined, 47 | }; 48 | } 49 | 50 | public toPrivate(): PublicGuild { 51 | return this.toPublic(); 52 | } 53 | 54 | public throwPermission = async ( 55 | user: User, 56 | permission: PERMISSION | PERMISSION[], 57 | ) => { 58 | // todo: which permission? 59 | if (!(await this.checkPermission(user, permission))) 60 | throw new HttpError("Missing permission", 400); 61 | return true; 62 | }; 63 | 64 | public checkPermission = async ( 65 | user: User, 66 | permission: PERMISSION | PERMISSION[], 67 | ) => checkPermission(user, this, permission); 68 | 69 | @AfterLoad() 70 | _sort = () => { 71 | if (this.roles) this.roles.sort((a, b) => b.position - a.position); 72 | if (this.channels) 73 | this.channels.sort((a, b) => b.position - a.position); 74 | }; 75 | } 76 | 77 | export type PublicGuild = Pick & { 78 | channels?: PublicGuildTextChannel[]; 79 | roles?: PublicRole[]; 80 | mention: ActorMention; 81 | }; 82 | 83 | export const PublicGuild: z.ZodType = z 84 | .object({ 85 | mention: ActorMention, 86 | name: z.string(), 87 | channels: PublicGuildTextChannel.array().optional(), 88 | roles: PublicRole.array().optional(), 89 | }) 90 | .openapi("PublicGuild"); 91 | -------------------------------------------------------------------------------- /src/entity/instanceInvite.ts: -------------------------------------------------------------------------------- 1 | import { BaseEntity, Column, Entity, OneToMany, PrimaryColumn } from "typeorm"; 2 | import { User } from "./user"; 3 | 4 | /** 5 | * Instance registration invite codes 6 | * If registration is disabled, you may give out codes which allow 7 | * users to register. 8 | */ 9 | @Entity("instance_invites") 10 | export class InstanceInvite extends BaseEntity { 11 | @PrimaryColumn() 12 | code: string; 13 | 14 | /** the expiry date */ 15 | @Column({ type: Date, nullable: true }) 16 | expires: Date | null; 17 | 18 | @OneToMany( 19 | () => User, 20 | (user) => user.invite, 21 | ) 22 | users: User[]; 23 | 24 | /** the maximum number of uses for this invite. if null, unlimited usage */ 25 | @Column({ type: Number, nullable: true }) 26 | maxUses: number | null; 27 | } 28 | -------------------------------------------------------------------------------- /src/entity/invite.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, Index, ManyToOne } from "typeorm"; 2 | import { z } from "zod"; 3 | import { ActorMention } from "../util/activitypub/constants"; 4 | import { BaseModel } from "./basemodel"; 5 | import type { Guild } from "./guild"; 6 | 7 | @Entity("invites") 8 | @Index(["code", "guild"], { unique: true }) 9 | export class Invite extends BaseModel { 10 | @Column() 11 | code: string; 12 | 13 | @Column({ type: Date, nullable: true }) 14 | expires: Date | null; 15 | 16 | @ManyToOne("guilds", { onDelete: "CASCADE" }) 17 | guild: Guild; 18 | 19 | public toPrivate(): PublicInvite { 20 | return this.toPublic(); 21 | } 22 | 23 | public toPublic(): PublicInvite { 24 | return { 25 | code: this.code, 26 | expires: this.expires, 27 | guild: this.guild.mention, 28 | }; 29 | } 30 | } 31 | 32 | export type PublicInvite = Pick & { 33 | guild: ActorMention; 34 | }; 35 | 36 | export const PublicInvite: z.ZodType = z.object({ 37 | code: z.string(), 38 | guild: ActorMention, 39 | expires: z.date(), 40 | }); 41 | -------------------------------------------------------------------------------- /src/entity/member.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, ManyToMany, ManyToOne } from "typeorm"; 2 | import { z } from "zod"; 3 | import { BaseModel } from "./basemodel"; 4 | import type { Role } from "./role"; 5 | import { PublicUser, type User } from "./user"; 6 | 7 | @Entity("guild_members") 8 | export class Member extends BaseModel { 9 | @Column({ type: String, nullable: true }) 10 | nickname: string | null; 11 | 12 | @ManyToOne("users", { onDelete: "CASCADE" }) 13 | user: User; 14 | 15 | @ManyToMany("roles", "members", { onDelete: "CASCADE" }) 16 | roles: Role[]; 17 | 18 | public toPublic() { 19 | return { 20 | id: this.id, 21 | nickname: this.nickname, 22 | user: this.user.toPublic(), 23 | roles: this.roles.map((x) => x.remote_id ?? x.id), 24 | }; 25 | } 26 | 27 | public toPrivate() { 28 | return this.toPublic(); 29 | } 30 | } 31 | 32 | export type PublicMember = Pick & { 33 | user: PublicUser; 34 | roles: string[]; 35 | }; 36 | 37 | export const PublicMember: z.ZodType = z.object({ 38 | id: z.string().uuid(), // TODO: See #79 and #78 39 | nickname: z.string(), 40 | user: PublicUser, 41 | roles: z.string().array(), 42 | }); 43 | -------------------------------------------------------------------------------- /src/entity/migrations.ts: -------------------------------------------------------------------------------- 1 | import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from "typeorm"; 2 | 3 | @Entity("migrations") 4 | export class Migration extends BaseEntity { 5 | @PrimaryGeneratedColumn() 6 | id: number; 7 | 8 | @Column({ type: "bigint" }) 9 | timestamp: number; 10 | 11 | @Column() 12 | name: string; 13 | } 14 | -------------------------------------------------------------------------------- /src/entity/pushSubscription.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseEntity, 3 | Column, 4 | CreateDateColumn, 5 | Entity, 6 | ManyToOne, 7 | PrimaryColumn, 8 | } from "typeorm"; 9 | import type { User } from "./user"; 10 | 11 | /** 12 | * Web Push subscriptions 13 | * https://developer.mozilla.org/en-US/docs/Web/API/PushSubscription 14 | */ 15 | @Entity("push_subscriptions") 16 | export class PushSubscription extends BaseEntity { 17 | @PrimaryColumn() 18 | userId: string; 19 | 20 | @ManyToOne("users", { onDelete: "CASCADE" }) 21 | user: User; 22 | 23 | /** 24 | * The name of the device/browser associated with this subscription 25 | */ 26 | @PrimaryColumn() 27 | name: string; 28 | 29 | /** 30 | * The PushSubscription endpoint 31 | */ 32 | @Column() 33 | endpoint: string; 34 | 35 | @Column() 36 | p256dh: string; 37 | 38 | @Column() 39 | auth: string; 40 | 41 | @CreateDateColumn() 42 | created: Date; 43 | 44 | public asStandard() { 45 | return { 46 | endpoint: this.endpoint, 47 | expirationTime: null, 48 | keys: { 49 | p256dh: this.p256dh, 50 | auth: this.auth, 51 | }, 52 | }; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/entity/relationship.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | CreateDateColumn, 4 | Entity, 5 | Index, 6 | JoinColumn, 7 | ManyToOne, 8 | OneToOne, 9 | } from "typeorm"; 10 | import { z } from "zod"; 11 | import type { ApCache } from "./apcache"; 12 | import { BaseModel } from "./basemodel"; 13 | import { PublicUser, type User } from "./user"; 14 | 15 | export enum RelationshipType { 16 | pending = 0, 17 | accepted = 1, 18 | blocked = 2, 19 | } 20 | 21 | @Entity("relationships") 22 | @Index(["from", "to"], { unique: true }) 23 | export class Relationship extends BaseModel { 24 | @ManyToOne("users") 25 | from: User; 26 | 27 | @ManyToOne("users") 28 | to: User; 29 | 30 | /** The state of the relationship in the direction of from user -> to user */ 31 | @Column({ enum: RelationshipType }) 32 | from_state: RelationshipType; 33 | 34 | @Column({ enum: RelationshipType }) 35 | to_state: RelationshipType; 36 | 37 | @CreateDateColumn() 38 | created: Date; 39 | 40 | /** 41 | * The reference object this message was created from. 42 | * Messages sent from here don't have this. 43 | */ 44 | @OneToOne("activitypub_objects", { nullable: true }) 45 | @JoinColumn() 46 | reference_object: ApCache | null; 47 | 48 | public isBlock() { 49 | return ( 50 | this.to_state === RelationshipType.blocked || 51 | this.from_state === RelationshipType.blocked 52 | ); 53 | } 54 | 55 | public toPublic() { 56 | throw new Error("Use .toClient"); 57 | } 58 | 59 | public toPrivate() { 60 | throw new Error("Use .toClient"); 61 | } 62 | 63 | /** 64 | * TODO: Bad function whom I hate 65 | * It returns the representation of this relationship for the specified user 66 | */ 67 | public toClient(our_id: string): PrivateRelationship { 68 | const dir = this.to?.id === our_id; 69 | 70 | return { 71 | created: this.created, 72 | user: dir ? this.from.toPublic() : this.to.toPublic(), 73 | type: dir ? this.to_state : this.from_state, 74 | }; 75 | } 76 | } 77 | 78 | export const PrivateRelationship = z 79 | .object({ 80 | created: z.date(), 81 | user: PublicUser, 82 | type: z.nativeEnum(RelationshipType), 83 | }) 84 | .openapi("PrivateRelationship"); 85 | 86 | export type PrivateRelationship = z.infer; 87 | -------------------------------------------------------------------------------- /src/entity/role.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | Entity, 4 | Index, 5 | JoinTable, 6 | ManyToMany, 7 | ManyToOne, 8 | } from "typeorm"; 9 | import { z } from "zod"; 10 | import { ActorMention } from "../util/activitypub/constants"; 11 | import { DefaultPermissions, PERMISSION } from "../util/permission"; 12 | import { BaseModel } from "./basemodel"; 13 | import type { Guild } from "./guild"; 14 | import type { Member } from "./member"; 15 | 16 | @Entity("roles") 17 | @Index(["position", "guild"], { unique: true }) 18 | export class Role extends BaseModel { 19 | /** 20 | * Roles are federated objects, and to prevent ID collision, 21 | * we need to generate local IDs and store the remote ID to track foreign roles 22 | */ 23 | @Column({ type: String, nullable: true }) 24 | @Index({ unique: true, where: "remote_id IS NOT NULL" }) 25 | remote_id: string | null; 26 | 27 | @Column() 28 | name: string; 29 | 30 | @ManyToOne("guilds", { onDelete: "CASCADE" }) 31 | guild: Guild; 32 | 33 | @Column() 34 | position: number; 35 | 36 | @Column({ 37 | type: "enum", 38 | enum: PERMISSION, 39 | array: true, 40 | default: DefaultPermissions, 41 | }) 42 | allow: PERMISSION[]; 43 | 44 | @Column({ 45 | type: "enum", 46 | enum: PERMISSION, 47 | array: true, 48 | default: [], 49 | }) 50 | deny: PERMISSION[]; 51 | 52 | @ManyToMany("guild_members", "roles") 53 | @JoinTable() 54 | members: Member[]; 55 | 56 | public toPublic(): PublicRole { 57 | return { 58 | id: this.remote_id ?? this.id, 59 | name: this.name, 60 | guild: this.guild?.mention, 61 | allow: this.allow, 62 | deny: this.deny, 63 | }; 64 | } 65 | 66 | public toPrivate(): PublicRole { 67 | return this.toPublic(); 68 | } 69 | } 70 | 71 | export type PublicRole = Pick & { 72 | guild?: ActorMention; 73 | }; 74 | 75 | export const PublicRole: z.ZodType> = z 76 | .object({ 77 | id: z.string().uuid(), 78 | name: z.string(), 79 | allow: z.number().array(), 80 | deny: z.number().array(), 81 | guild: ActorMention, 82 | }) 83 | .openapi("PublicRole"); 84 | -------------------------------------------------------------------------------- /src/entity/session.ts: -------------------------------------------------------------------------------- 1 | import { CreateDateColumn, Entity, ManyToOne } from "typeorm"; 2 | import { BaseModel } from "./basemodel"; 3 | import type { User } from "./user"; 4 | 5 | @Entity("sessions") 6 | export class Session extends BaseModel { 7 | @CreateDateColumn() 8 | created_at: Date; 9 | 10 | @ManyToOne("users", { onDelete: "CASCADE" }) 11 | user: User; 12 | 13 | /* 14 | TODO: 15 | - presence 16 | */ 17 | 18 | public toPrivate() { 19 | return { 20 | id: this.id, 21 | created_at: this.created_at, 22 | user_id: this.user.id, 23 | } as PrivateSession; 24 | } 25 | 26 | public toPublic() { 27 | return {}; 28 | } 29 | } 30 | 31 | export type PrivateSession = Omit & { user_id: string }; 32 | -------------------------------------------------------------------------------- /src/entity/textChannel.ts: -------------------------------------------------------------------------------- 1 | import { ChildEntity, Column, ManyToOne, Unique } from "typeorm"; 2 | import { z } from "zod"; 3 | import { ActorMention } from "../util/activitypub/constants"; 4 | import { checkPermission } from "../util/checkPermission"; 5 | import type { PERMISSION } from "../util/permission"; 6 | import { Channel, PublicChannel } from "./channel"; 7 | import type { Guild } from "./guild"; 8 | import type { User } from "./user"; 9 | 10 | @ChildEntity("guild_text") 11 | @Unique("channel_ordering", ["position", "guild"], { 12 | deferrable: "INITIALLY DEFERRED", 13 | }) 14 | export class GuildTextChannel extends Channel { 15 | // permission overwrites 16 | // category? 17 | 18 | @ManyToOne("guilds", { onDelete: "CASCADE" }) 19 | guild: Guild; 20 | 21 | @Column() 22 | position: number; 23 | 24 | public toPublic(): PublicGuildTextChannel { 25 | return { 26 | ...super.toPublic(), 27 | guild: this.guild?.mention, 28 | }; 29 | } 30 | 31 | public toPrivate(): PublicGuildTextChannel { 32 | return this.toPublic(); 33 | } 34 | 35 | public checkPermission = async ( 36 | user: User, 37 | permission: PERMISSION | PERMISSION[], 38 | ) => checkPermission(user, this.guild, permission); 39 | } 40 | 41 | export type PublicGuildTextChannel = PublicChannel & { 42 | guild?: ActorMention; 43 | }; 44 | 45 | export const PublicGuildTextChannel: z.ZodType = 46 | PublicChannel.and( 47 | z.object({ 48 | guild: ActorMention.optional(), 49 | }), 50 | ).openapi("PublicGuildTextChannel"); 51 | -------------------------------------------------------------------------------- /src/entity/upload.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * { 3 | channel_id: string; 4 | name: string; 5 | size: number; 6 | mime: string; 7 | md5: string; 8 | width?: number; 9 | height?: number; 10 | } 11 | */ 12 | 13 | import { Column, Entity, ManyToOne } from "typeorm"; 14 | import { BaseModel } from "./basemodel"; 15 | import type { Channel } from "./channel"; 16 | 17 | /** 18 | * TODO: this class duplicates data in the Attachment entity unfortunately 19 | * It's meant to mirror how s3 stores metadata about an object, 20 | * and I didn't want to create Attachments on upload vs on message send which is currently done 21 | */ 22 | @Entity("local_uploads") 23 | export class LocalUpload extends BaseModel { 24 | @ManyToOne("channels", { onDelete: "CASCADE" }) 25 | channel: Channel; 26 | 27 | @Column() 28 | hash: string; 29 | 30 | @Column() 31 | size: number; 32 | 33 | @Column() 34 | mime: string; 35 | 36 | @Column() 37 | md5: string; 38 | 39 | @Column({ type: Number, nullable: true }) 40 | width: number | null; 41 | 42 | @Column({ type: Number, nullable: true }) 43 | height: number | null; 44 | 45 | public toPublic() { 46 | throw new Error("Unused"); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/entity/user.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, Index, ManyToOne } from "typeorm"; 2 | import { z } from "zod"; 3 | import { ActorMention } from "../util/activitypub/constants"; 4 | import { Actor } from "./actor"; 5 | import { InstanceInvite } from "./instanceInvite"; 6 | 7 | @Entity("users") 8 | @Index(["name", "domain"], { unique: true }) 9 | export class User extends Actor { 10 | /** 11 | * The username of this user. Forms their mention 12 | * Do not allow modification, as this will break federation. 13 | */ 14 | @Column({ update: false }) 15 | name: string; 16 | 17 | /** Tokens generated past this date are valid */ 18 | @Column({ nullable: true, type: "timestamptz" }) 19 | valid_tokens_since: Date | null; 20 | 21 | /** The password hash of this user. If null, this user is not from our domain */ 22 | @Column({ nullable: true, type: String }) 23 | password_hash: string | null; 24 | 25 | /** The email address of this user */ 26 | @Column({ type: String, nullable: true }) 27 | email: string | null; 28 | 29 | /** the invite code used to register to this instance */ 30 | @ManyToOne(() => InstanceInvite, { 31 | nullable: true, 32 | onDelete: "CASCADE", 33 | }) 34 | invite: InstanceInvite | null; 35 | 36 | /** User customisation fields start here */ 37 | 38 | /** The preferred/display name of this user */ 39 | @Column() 40 | display_name: string; 41 | 42 | /** The user's bio */ 43 | @Column({ nullable: true, type: String }) 44 | summary: string | null; 45 | 46 | public get mention(): ActorMention { 47 | return `${this.name}@${this.domain}`; 48 | } 49 | 50 | public toPublic = (): PublicUser => { 51 | return { 52 | mention: this.mention, 53 | name: this.name, 54 | display_name: this.display_name, 55 | summary: this.summary, 56 | }; 57 | }; 58 | 59 | public toPrivate = (): PrivateUser => { 60 | return { 61 | email: this.email, 62 | 63 | ...this.toPublic(), 64 | }; 65 | }; 66 | } 67 | 68 | export type PublicUser = Pick & { 69 | mention: ActorMention; 70 | }; 71 | export type PrivateUser = PublicUser & Pick; 72 | 73 | export const PublicUser: z.ZodType = z 74 | .object({ 75 | mention: ActorMention, 76 | name: z.string(), 77 | summary: z.string(), 78 | display_name: z.string(), 79 | }) 80 | .openapi("PublicUser"); 81 | 82 | export const PrivateUser: z.ZodType = PublicUser.and( 83 | z.object({ 84 | email: z.string(), 85 | }), 86 | ).openapi("PrivateUser"); 87 | -------------------------------------------------------------------------------- /src/gateway/bootstrap.ts: -------------------------------------------------------------------------------- 1 | import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi"; 2 | import { z } from "zod"; 3 | 4 | extendZodWithOpenApi(z); 5 | 6 | import "dotenv/config"; 7 | import { config } from "../util/config"; 8 | import { createLogger, setLogOptions } from "../util/log"; 9 | import { GatewayServer } from "./server"; 10 | 11 | setLogOptions(config.log); 12 | const Log = createLogger("bootstrap"); 13 | 14 | const PORT = process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 3002; 15 | 16 | process.on("uncaughtException", (error, origin) => { 17 | Log.error(`Caught ${origin}`, error); 18 | }); 19 | 20 | const gateway = new GatewayServer(); 21 | 22 | gateway.listen(PORT); 23 | -------------------------------------------------------------------------------- /src/gateway/handlers/heartbeat.ts: -------------------------------------------------------------------------------- 1 | import { CLOSE_CODES } from "../util/codes"; 2 | import { consume } from "../util/listener"; 3 | import { HEARTBEAT } from "../util/validation/receive"; 4 | import type { HEARTBEAT_ACK } from "../util/validation/send"; 5 | import type { Websocket } from "../util/websocket"; 6 | import { makeHandler } from "."; 7 | export const onHeartbeat = makeHandler(async function (payload) { 8 | if (payload.s !== this.sequence) 9 | // TODO: send them back the missing events 10 | throw new Error("Out of sync. Reconnect"); 11 | 12 | clearTimeout(this.heartbeat_timeout); 13 | startHeartbeatTimeout(this); 14 | 15 | const ret: HEARTBEAT_ACK = { 16 | type: "HEARTBEAT_ACK", 17 | }; 18 | 19 | return await consume(this, ret); 20 | }, HEARTBEAT); 21 | 22 | export const heartbeatTimeout = (socket: Websocket) => { 23 | socket.close(CLOSE_CODES.HEARTBEAT_TIMEOUT); 24 | }; 25 | 26 | export const startHeartbeatTimeout = (socket: Websocket) => { 27 | socket.heartbeat_timeout = setTimeout( 28 | () => heartbeatTimeout(socket), 29 | 10_000, 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /src/gateway/handlers/identify.ts: -------------------------------------------------------------------------------- 1 | import { DMChannel } from "../../entity/DMChannel"; 2 | import { Session } from "../../entity/session"; 3 | import type { User } from "../../entity/user"; 4 | import { getDatabase } from "../../util/database"; 5 | import { getGuilds } from "../../util/entity/guild"; 6 | import { fetchRelationships } from "../../util/entity/relationship"; 7 | import { getUserFromToken } from "../../util/token"; 8 | import { CLOSE_CODES } from "../util/codes"; 9 | import { consume, listenEvents } from "../util/listener"; 10 | import { IDENTIFY } from "../util/validation/receive"; 11 | import type { READY } from "../util/validation/send"; 12 | import { makeHandler } from "."; 13 | import { startHeartbeatTimeout } from "./heartbeat"; 14 | 15 | /** 16 | * - Authenticate user 17 | * - Create session 18 | * - Get associated guilds, channels, relationships 19 | * - Build payload and send to user 20 | */ 21 | export const onIdentify = makeHandler(async function (payload) { 22 | let user: User; 23 | try { 24 | user = await getUserFromToken(payload.token); 25 | } catch (_) { 26 | this.close(CLOSE_CODES.BAD_TOKEN); 27 | return; 28 | } 29 | 30 | this.user_id = user.id; 31 | 32 | const [session, dmChannels, guilds, relationships] = await Promise.all([ 33 | Session.create({ 34 | user, 35 | }).save(), 36 | 37 | getDatabase() 38 | .getRepository(DMChannel) 39 | .createQueryBuilder("dm") 40 | .leftJoinAndSelect("dm.owner", "owner") 41 | .leftJoinAndSelect("dm.recipients", "recipients") 42 | .where("owner.id = :user_id", { user_id: this.user_id }) 43 | .orWhere("recipients.id = :user_id", { user_id: this.user_id }) 44 | .getMany(), 45 | 46 | getGuilds(this.user_id), 47 | 48 | fetchRelationships(this.user_id), 49 | ]); 50 | 51 | this.session = session; 52 | 53 | listenEvents(this, [ 54 | user, 55 | // TODO: for relationships, see #54 56 | ...dmChannels, 57 | ...guilds, 58 | ...guilds.flatMap((x) => x.channels), 59 | ]); 60 | 61 | const ret: READY = { 62 | type: "READY", 63 | session: this.session.toPrivate(), 64 | user: user.toPrivate(), 65 | channels: dmChannels.map((x) => x.toPublic()), 66 | guilds: guilds.map((x) => x.toPublic()), 67 | relationships: relationships.map((x) => x.toClient(this.user_id)), 68 | }; 69 | 70 | startHeartbeatTimeout(this); 71 | 72 | clearTimeout(this.auth_timeout); 73 | 74 | return await consume(this, ret); 75 | }, IDENTIFY); 76 | -------------------------------------------------------------------------------- /src/gateway/handlers/index.ts: -------------------------------------------------------------------------------- 1 | import type { ZodSchema } from "zod"; 2 | import { createLogger } from "../../util/log"; 3 | import { CLOSE_CODES } from "../util/codes"; 4 | import type { Websocket } from "../util/websocket"; 5 | 6 | const Log = createLogger("gateway"); 7 | 8 | export type GatewayMessageHandler = ( 9 | this: Websocket, 10 | message: T, 11 | ) => Promise | unknown; 12 | 13 | export const makeHandler = ( 14 | handler: GatewayMessageHandler, 15 | schema: ZodSchema, 16 | ) => { 17 | return function func(this: Websocket, data: T) { 18 | const ret = schema.safeParse(data); 19 | if (!ret.success) { 20 | Log.verbose(`${this.ip_address} sent malformed data`); 21 | this.close(CLOSE_CODES.BAD_PAYLOAD); 22 | return; 23 | } 24 | 25 | return handler.call(this, data as T); 26 | }; 27 | }; 28 | 29 | export const handlers: Record> = { 30 | identify: require("./identify").onIdentify, 31 | heartbeat: require("./heartbeat").onHeartbeat, 32 | members: require("./members").onSubscribeMembers, 33 | }; 34 | -------------------------------------------------------------------------------- /src/gateway/readme.md: -------------------------------------------------------------------------------- 1 | # Gateway 2 | 3 | Real time communication with connected clients about events like message/channel/guild CRUD 4 | 5 | On client connect: 6 | 7 | 1. Client sends Identify to authenticate 8 | 2. Gateway responds Ready with initial sync data: guilds, channels, relationships, presence data 9 | 3. On CRUD for objects client cares about, Gateway sends those events 10 | -------------------------------------------------------------------------------- /src/gateway/server.ts: -------------------------------------------------------------------------------- 1 | import http from "node:http"; 2 | import ws from "ws"; 3 | import { initDatabase } from "../util/database"; 4 | import { initRabbitMQ } from "../util/events"; 5 | import { createLogger } from "../util/log"; 6 | import { onConnection } from "./socket/connection"; 7 | 8 | const Log = createLogger("GATEWAY"); 9 | 10 | export class GatewayServer { 11 | server: http.Server; 12 | socket: ws.Server; 13 | 14 | public constructor(server?: http.Server) { 15 | this.server = server ?? http.createServer(); 16 | 17 | this.socket = new ws.Server({ 18 | server: this.server, 19 | }); 20 | 21 | this.socket.on("connection", onConnection); 22 | } 23 | 24 | public async listen(port: number) { 25 | this.server.on("listening", () => { 26 | Log.msg(`Listening on port ${port}`); 27 | }); 28 | 29 | await initDatabase(); 30 | await initRabbitMQ(true); 31 | 32 | if (!this.server.listening) this.server.listen(port); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/gateway/socket/close.ts: -------------------------------------------------------------------------------- 1 | import type { CloseEvent } from "ws"; 2 | import { Session } from "../../entity/session"; 3 | import { createLogger } from "../../util/log"; 4 | import type { Websocket } from "../util/websocket"; 5 | 6 | const Log = createLogger("gateway"); 7 | 8 | export async function onClose(this: Websocket, event: CloseEvent) { 9 | Log.verbose(`${this.ip_address} disconnected with code ${event.code}`); 10 | 11 | clearTimeout(this.auth_timeout); 12 | 13 | // remove all gateway event listeners for this socket 14 | for (const target in this.events) { 15 | this.events[target](); 16 | } 17 | 18 | if (this.session) await Session.delete({ id: this.session.id }); 19 | } 20 | -------------------------------------------------------------------------------- /src/gateway/socket/connection.ts: -------------------------------------------------------------------------------- 1 | import type { IncomingMessage } from "node:http"; 2 | import type ws from "ws"; 3 | import { createLogger } from "../../util/log"; 4 | import { CLOSE_CODES } from "../util/codes"; 5 | import { send, type Websocket } from "../util/websocket"; 6 | import { onClose } from "./close"; 7 | import { onMessage } from "./message"; 8 | 9 | const Log = createLogger("GATEWAY"); 10 | 11 | export function onConnection( 12 | this: ws.Server, 13 | socket: Websocket, 14 | request: IncomingMessage, 15 | ) { 16 | socket.events = {}; 17 | socket.member_list = { 18 | channel_id: undefined, 19 | range: undefined, 20 | events: {}, 21 | }; 22 | 23 | socket.sequence = 0; 24 | 25 | //@ts-expect-error 26 | socket.raw_send = socket.send; 27 | socket.send = send.bind(socket); 28 | 29 | // TODO: trust proxy 30 | const ip = 31 | request.headers["x-forwarded-for"] || 32 | request.socket.remoteAddress || 33 | "unknown"; 34 | socket.ip_address = Array.isArray(ip) ? ip[0] : ip; 35 | 36 | Log.verbose(`New client from '${socket.ip_address}'`); 37 | 38 | //@ts-expect-error what is wrong here 39 | socket.addEventListener("close", onClose); 40 | socket.addEventListener("message", async function (ev) { 41 | try { 42 | await onMessage.call(socket, ev); 43 | } catch (e) { 44 | this.close(CLOSE_CODES.SERVER_ERROR); 45 | Log.error("message handler failed with", e); 46 | } 47 | }); 48 | 49 | // Trigger auth timeout after 10 seconds 50 | socket.auth_timeout = setTimeout( 51 | () => socket.close(CLOSE_CODES.IDENTIFY_TIMEOUT), 52 | 10_000, 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /src/gateway/socket/message.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { handlers } from "../handlers"; 3 | import type { Websocket } from "../util/websocket"; 4 | 5 | export async function onMessage(this: Websocket, event: MessageEvent) { 6 | const parsed = validate(event.data); 7 | 8 | const handler = handlers[parsed.t]; 9 | if (!handler) throw new Error("invalid opcode"); 10 | 11 | await handler.call(this, parsed); 12 | } 13 | 14 | const GatewayPayload = z 15 | .object({ 16 | t: z.string(), 17 | }) 18 | .passthrough(); 19 | 20 | const validate = (message: unknown) => { 21 | let ret: unknown; 22 | if (typeof message === "string") ret = JSON.parse(message); 23 | else throw new Error("unimplemented"); 24 | 25 | return GatewayPayload.parse(ret); 26 | }; 27 | -------------------------------------------------------------------------------- /src/gateway/util/codes.ts: -------------------------------------------------------------------------------- 1 | // Defined here are close codes we may send 2 | export enum CLOSE_CODES { 3 | CLOSE_NORMAL = 1000, 4 | CLOSE_TOO_LARGE = 1009, 5 | SERVER_ERROR = 1011, 6 | SERVICE_RESTART = 1012, 7 | TRY_AGAIN_LATER = 1013, 8 | 9 | /** We did not receive heartbeat in time */ 10 | HEARTBEAT_TIMEOUT = 4000, 11 | 12 | /** We did not receive auth in time */ 13 | IDENTIFY_TIMEOUT = 4001, 14 | 15 | /** We received a payload that failed validation */ 16 | BAD_PAYLOAD = 4002, 17 | 18 | /** The token provided in IDENTIFY was invalid */ 19 | BAD_TOKEN = 4100, 20 | } 21 | -------------------------------------------------------------------------------- /src/gateway/util/validation/receive.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { ActorMention } from "../../../util/activitypub/constants"; 3 | 4 | export const IDENTIFY = z.object({ 5 | /** User token to use to login */ 6 | token: z.string(), 7 | }); 8 | 9 | export const HEARTBEAT = z.object({ 10 | s: z.number(), 11 | }); 12 | 13 | export const SUBSCRIBE_MEMBERS = z.object({ 14 | /** Channel mention to subscribe to */ 15 | channel_id: ActorMention, 16 | /** The range to subscribe to 17 | * @example [0, 100] 18 | */ 19 | range: z.tuple([z.number(), z.number()]), 20 | /** Subscribe to only online members */ 21 | online: z.boolean().nullable().optional(), 22 | }); 23 | -------------------------------------------------------------------------------- /src/gateway/util/websocket.ts: -------------------------------------------------------------------------------- 1 | import type { Session } from "../../entity/session"; 2 | import type { GATEWAY_PAYLOAD } from "./validation/send"; 3 | 4 | export interface Websocket extends Omit { 5 | /** The source IP address of this socket */ 6 | ip_address: string; 7 | 8 | /** The user ID of this authenticated socket */ 9 | user_id: string; 10 | 11 | /** The session attached to this connection */ 12 | session: Session; 13 | 14 | /** The current sequence/event number for this socket */ 15 | sequence: number; 16 | 17 | /** When triggered, disconnect this client. They have not authed in time */ 18 | auth_timeout?: NodeJS.Timeout; 19 | 20 | /** When triggered, disconnect this client. They have not sent a heartbeat in time */ 21 | heartbeat_timeout?: NodeJS.Timeout; 22 | 23 | /** Event emitter UUID -> listener cancel function */ 24 | events: Record unknown>; 25 | 26 | member_list: { 27 | /** 28 | * Event ID -> listener cancel function 29 | * member_events are specifically for events related to member list subscriptions 30 | */ 31 | events: Record unknown>; 32 | 33 | /** The subscribed channel ranges */ 34 | range?: [number, number]; 35 | 36 | /** The ID of the subscribed channel */ 37 | channel_id?: string; 38 | }; 39 | 40 | /** The original socket.send function */ 41 | raw_send: ( 42 | this: Websocket, 43 | data: string | ArrayBufferLike | Blob | ArrayBufferView, 44 | ) => unknown; 45 | 46 | /** The new socket.send function */ 47 | send: (this: Websocket, data: GATEWAY_PAYLOAD) => unknown; 48 | } 49 | 50 | export function send(this: Websocket, data: GATEWAY_PAYLOAD) { 51 | // TODO: zlib encoding? 52 | 53 | const ret = { ...data, s: this.sequence++ }; 54 | 55 | return this.raw_send(JSON.stringify(ret)); 56 | } 57 | -------------------------------------------------------------------------------- /src/http/api/auth/login.ts: -------------------------------------------------------------------------------- 1 | import bcrypt from "bcrypt"; 2 | import { Router } from "express"; 3 | import z from "zod"; 4 | 5 | import { PublicUser, User } from "../../../entity/user"; 6 | import { config } from "../../../util/config"; 7 | import { HttpError } from "../../../util/httperror"; 8 | import { route } from "../../../util/route"; 9 | import { generateToken } from "../../../util/token"; 10 | 11 | const router = Router(); 12 | 13 | const LoginRequest = z.object({ 14 | username: z.string(), 15 | password: z.string(), 16 | }); 17 | 18 | const LoginResponse = z.object({ 19 | token: z.string(), 20 | user: PublicUser, 21 | }); 22 | 23 | const INVALID_LOGIN = "Invalid login"; 24 | 25 | router.post( 26 | "/login", 27 | route( 28 | { 29 | body: LoginRequest, 30 | response: LoginResponse, 31 | }, 32 | async (req, res) => { 33 | const { username, password } = req.body; 34 | 35 | const user = await User.findOneOrFail({ 36 | where: { 37 | name: username.toLowerCase(), 38 | domain: config.federation.webapp_url.hostname, 39 | }, 40 | }).catch(() => { 41 | // Throw the same error, to prevent knowing accounts exists 42 | throw new HttpError(INVALID_LOGIN, 401); 43 | }); 44 | 45 | if (!user.password_hash) throw new HttpError(INVALID_LOGIN, 401); 46 | 47 | if (!(await bcrypt.compare(password, user.password_hash))) 48 | throw new HttpError(INVALID_LOGIN, 401); 49 | 50 | return res.json({ 51 | token: await generateToken(user.id), 52 | user: user.toPrivate(), 53 | }); 54 | }, 55 | ), 56 | ); 57 | 58 | export default router; 59 | -------------------------------------------------------------------------------- /src/http/api/auth/register.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import z from "zod"; 3 | import { InstanceInvite } from "../../../entity/instanceInvite"; 4 | import { User } from "../../../entity/user"; 5 | import { config } from "../../../util/config"; 6 | import { registerUser } from "../../../util/entity/user"; 7 | import { HttpError } from "../../../util/httperror"; 8 | import { route } from "../../../util/route"; 9 | import { generateToken } from "../../../util/token"; 10 | 11 | const router = Router(); 12 | 13 | const RegisterRequest = z.object({ 14 | username: z.string(), 15 | password: z.string(), 16 | email: z.string().optional(), 17 | invite: z.string().optional().describe("Instance registration invite"), 18 | }); 19 | 20 | const RegisterResponse = z.object({ 21 | token: z.string(), 22 | }); 23 | 24 | router.post( 25 | "/register", 26 | route( 27 | { body: RegisterRequest, response: RegisterResponse }, 28 | async (req, res) => { 29 | if (!config.registration.enabled && !req.body.invite) 30 | throw new HttpError("Registration is disabled", 400); 31 | 32 | const { username, email, password } = req.body; 33 | 34 | let invite: InstanceInvite | undefined; 35 | 36 | if (req.body.invite) { 37 | invite = await InstanceInvite.createQueryBuilder("invite") 38 | .where("invite.code = :code", { code: req.body.invite }) 39 | .andWhere( 40 | "(invite.expires < now() or invite.expires is null)", 41 | ) 42 | .andWhere((qb) => { 43 | const inner = qb 44 | .createQueryBuilder() 45 | .from(User, "users") 46 | .where("users.invite = invite.code") 47 | .select("count(*)") 48 | .getSql(); 49 | 50 | return `(invite.maxUses > (${inner}) or invite.maxUses is null)`; 51 | }) 52 | .getOneOrFail(); 53 | } 54 | 55 | const user = await registerUser( 56 | username.toLowerCase(), 57 | password, 58 | email, 59 | false, 60 | invite, 61 | ); 62 | 63 | return res.json({ token: await generateToken(user.id) }); 64 | }, 65 | ), 66 | ); 67 | 68 | export default router; 69 | -------------------------------------------------------------------------------- /src/http/api/channel/#id/call.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { z } from "zod"; 3 | import { ActorMention } from "../../../../util/activitypub/constants"; 4 | import { config } from "../../../../util/config"; 5 | import { getOrFetchChannel } from "../../../../util/entity/channel"; 6 | import { PERMISSION } from "../../../../util/permission"; 7 | import { route } from "../../../../util/route"; 8 | import { askForMediaToken, generateMediaToken } from "../../../../util/voice"; 9 | 10 | const router = Router({ mergeParams: true }); 11 | 12 | export const MediaTokenResponse = z.object({ 13 | token: z.string(), 14 | ip: z.string(), 15 | }); 16 | 17 | router.post( 18 | "/", 19 | route( 20 | { 21 | params: z.object({ 22 | channel_id: ActorMention, 23 | }), 24 | response: MediaTokenResponse, 25 | errors: { 202: z.literal("Accepted") }, 26 | }, 27 | async (req, res) => { 28 | const channel = await getOrFetchChannel(req.params.channel_id); 29 | 30 | await channel.throwPermission(req.user, PERMISSION.CALL_CHANNEL); 31 | 32 | // If this channel is remote, we have to request the token from them 33 | // It'll be delivered to our inbox, and we can send it to the client over gateway 34 | if (channel.isRemote()) { 35 | await askForMediaToken(req.user, channel); 36 | return res.sendStatus(202); 37 | } 38 | 39 | const token = await generateMediaToken( 40 | req.user.mention, 41 | channel.id, 42 | ); 43 | 44 | // TODO 45 | return res.json({ token, ip: config.webrtc.signal_address || "" }); 46 | }, 47 | ), 48 | ); 49 | 50 | export default router; 51 | -------------------------------------------------------------------------------- /src/http/api/guild/#id/channel.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { z } from "zod"; 3 | import { PublicGuildTextChannel } from "../../../../entity/textChannel"; 4 | import { ActorMention } from "../../../../util/activitypub/constants"; 5 | import { createGuildTextChannel } from "../../../../util/entity/channel"; 6 | import { getOrFetchGuild } from "../../../../util/entity/guild"; 7 | import { PERMISSION } from "../../../../util/permission"; 8 | import { route } from "../../../../util/route"; 9 | 10 | const router = Router({ mergeParams: true }); 11 | 12 | router.post( 13 | "/", 14 | route( 15 | { 16 | params: z.object({ guild_id: ActorMention }), 17 | body: z.object({ name: z.string() }), 18 | response: PublicGuildTextChannel, 19 | }, 20 | async (req, res) => { 21 | const guild = await getOrFetchGuild(req.params.guild_id); 22 | 23 | await guild.throwPermission(req.user, PERMISSION.MANAGE_CHANNELS); 24 | 25 | const channel = await createGuildTextChannel(req.body.name, guild); 26 | 27 | res.json(channel.toPublic()); 28 | }, 29 | ), 30 | ); 31 | 32 | export default router; 33 | -------------------------------------------------------------------------------- /src/http/api/guild/index.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { z } from "zod"; 3 | import { PublicGuild } from "../../../entity/guild"; 4 | import { createGuild } from "../../../util/entity/guild"; 5 | import { route } from "../../../util/route"; 6 | 7 | const router = Router({ mergeParams: true }); 8 | 9 | const GuildCreate = z.object({ 10 | name: z.string().min(1), 11 | }); 12 | 13 | router.post( 14 | "/", 15 | route( 16 | { 17 | body: GuildCreate, 18 | response: PublicGuild, 19 | }, 20 | async (req, res) => { 21 | const { name } = req.body; 22 | 23 | const owner = req.user; 24 | 25 | const guild = await createGuild(name, owner); 26 | 27 | return res.json(guild.toPublic()); 28 | }, 29 | ), 30 | ); 31 | 32 | export default router; 33 | -------------------------------------------------------------------------------- /src/http/api/upload/index.ts: -------------------------------------------------------------------------------- 1 | // TODO: might want to move this into it's own http server so that it can 2 | // be moved onto a different process by admins more easily 3 | 4 | import crypto from "node:crypto"; 5 | import { createWriteStream } from "node:fs"; 6 | import { mkdir, rm } from "node:fs/promises"; 7 | import path from "node:path"; 8 | import { Router } from "express"; 9 | import jwt from "jsonwebtoken"; 10 | import { z } from "zod"; 11 | import { LocalUpload } from "../../../entity/upload"; 12 | import { config } from "../../../util/config"; 13 | import { route } from "../../../util/route"; 14 | import type { localFileJwt } from "../../../util/storage/local"; 15 | 16 | const router = Router({ mergeParams: true }); 17 | 18 | router.put( 19 | "/", 20 | route({ query: z.object({ t: z.string().jwt() }) }, async (req, res) => { 21 | const token = await new Promise((resolve, reject) => { 22 | jwt.verify(req.query.t, config.security.jwt_secret, (err, d) => { 23 | if (err || !d || typeof d === "string") return reject(err); 24 | resolve(d as localFileJwt); 25 | }); 26 | }); 27 | 28 | const upload = LocalUpload.create({ 29 | hash: token.key.split("/").slice(-1)[0], 30 | channel: { id: token.channel_id }, 31 | width: token.width, 32 | height: token.height, 33 | mime: token.mime, 34 | md5: token.md5, 35 | size: token.size, 36 | }); 37 | 38 | const p = path.join(config.storage.directory, token.key); 39 | 40 | await mkdir(path.dirname(p), { recursive: true }); 41 | 42 | const destination = createWriteStream(p); 43 | 44 | const signer = crypto.createHash("md5"); 45 | 46 | req.on("end", async () => { 47 | const md5 = signer.digest("base64"); 48 | 49 | if (token.md5 !== md5) { 50 | await rm(p); 51 | res.sendStatus(400); 52 | } 53 | 54 | await upload.save(); 55 | 56 | res.sendStatus(200); 57 | }); 58 | req.on("error", async () => { 59 | await rm(p); 60 | res.sendStatus(500); 61 | }); 62 | 63 | for await (const r of req) { 64 | signer.write(r); 65 | destination.write(r); 66 | } 67 | 68 | signer.end(); 69 | destination.close(); 70 | }), 71 | ); 72 | 73 | export default router; 74 | -------------------------------------------------------------------------------- /src/http/api/users/#id/index.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { z } from "zod"; 3 | import { PublicUser } from "../../../../entity/user"; 4 | import { ActorMention } from "../../../../util/activitypub/constants"; 5 | import { getOrFetchUser } from "../../../../util/entity/user"; 6 | import { route } from "../../../../util/route"; 7 | 8 | const router = Router({ mergeParams: true }); 9 | 10 | router.get( 11 | "/", 12 | route( 13 | { 14 | params: z.object({ 15 | user_id: ActorMention, 16 | }), 17 | response: PublicUser, 18 | }, 19 | async (req, res) => { 20 | const { user_id } = req.params; 21 | 22 | const user = await getOrFetchUser(user_id); 23 | 24 | return res.json(user.toPublic()); 25 | }, 26 | ), 27 | ); 28 | 29 | export default router; 30 | -------------------------------------------------------------------------------- /src/http/api/users/@me/channels.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { Channel, PublicChannel } from "../../../../entity/channel"; 3 | import { getDatabase } from "../../../../util/database"; 4 | import { route } from "../../../../util/route"; 5 | 6 | const router = Router({ mergeParams: true }); 7 | 8 | router.get( 9 | "/", 10 | route( 11 | { 12 | response: PublicChannel.array(), 13 | }, 14 | async (req, res) => { 15 | // this is so bad 16 | const channels = await getDatabase() 17 | .createQueryBuilder(Channel, "channels") 18 | .select("channels") 19 | .leftJoinAndSelect("channels.recipients", "recipients") 20 | .leftJoinAndSelect("channels.owner", "owner") 21 | .where("channels.owner.id = :user_id", { user_id: req.user.id }) 22 | .orWhere("recipients.id = :id", { id: req.user.id }) 23 | // TODO: or are in recipients 24 | .getMany(); 25 | 26 | return res.json(channels.map((x) => x.toPublic())); 27 | }, 28 | ), 29 | ); 30 | 31 | export default router; 32 | -------------------------------------------------------------------------------- /src/http/api/users/@me/guild.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { z } from "zod"; 3 | import { Guild, PublicGuild } from "../../../../entity/guild"; 4 | import { Member } from "../../../../entity/member"; 5 | import { splitQualifiedMention } from "../../../../util/activitypub/util"; 6 | import { getDatabase } from "../../../../util/database"; 7 | import { getGuilds } from "../../../../util/entity/guild"; 8 | import { emitGatewayEvent } from "../../../../util/events"; 9 | import { route } from "../../../../util/route"; 10 | 11 | const router = Router({ mergeParams: true }); 12 | 13 | // Get list of guilds 14 | router.get( 15 | "/", 16 | route( 17 | { 18 | response: PublicGuild.array(), 19 | }, 20 | async (req, res) => { 21 | const user_id = req.user.id; 22 | 23 | const guilds = await getGuilds(user_id); 24 | 25 | return res.json(guilds.map((x) => x.toPublic())); 26 | }, 27 | ), 28 | ); 29 | 30 | /** Leave guild */ 31 | router.delete( 32 | "/:guild_id", 33 | route({ params: z.object({ guild_id: z.string() }) }, async (req, res) => { 34 | const mention = splitQualifiedMention(req.params.guild_id); 35 | 36 | /* 37 | delete from "guild_members" 38 | where "userId" = 'user id' and 39 | "id" in ( 40 | select "guildMembersId" from "roles_members_guild_members" 41 | where "rolesId" = 'guild id' 42 | ) 43 | */ 44 | 45 | const deleted = await getDatabase() 46 | .createQueryBuilder() 47 | .delete() 48 | .from(Member) 49 | .where( 50 | (qb) => { 51 | const sub = qb.connection 52 | .createQueryBuilder() 53 | .subQuery() 54 | .select("roles.guildMembersId") 55 | .from("roles_members_guild_members", "roles") 56 | .where("roles.rolesId = :guild_id") 57 | .getQuery(); 58 | 59 | return `guild_members.id IN ${sub}`; 60 | }, 61 | { guild_id: mention.id }, 62 | ) 63 | .andWhere('"guild_members"."userId" = :user_id', { 64 | user_id: req.user.id, 65 | }) 66 | .output("guild_members.id") 67 | .execute(); 68 | 69 | if (deleted.affected) { 70 | emitGatewayEvent(Guild.create({ id: req.params.guild_id }), { 71 | type: "MEMBER_LEAVE", 72 | guild: `${mention.id}@${mention.domain}`, 73 | user: req.user.mention, 74 | }); 75 | } 76 | 77 | return res.sendStatus(200); 78 | }), 79 | ); 80 | 81 | export default router; 82 | -------------------------------------------------------------------------------- /src/http/api/users/@me/index.ts: -------------------------------------------------------------------------------- 1 | import bcrypt from "bcrypt"; 2 | import { Router } from "express"; 3 | import { z } from "zod"; 4 | import { PrivateUser, User } from "../../../../entity/user"; 5 | import { HttpError } from "../../../../util/httperror"; 6 | import { route } from "../../../../util/route"; 7 | 8 | const router = Router({ mergeParams: true }); 9 | 10 | router.get( 11 | "/", 12 | route( 13 | { 14 | response: PrivateUser, 15 | }, 16 | async (req, res) => { 17 | return res.json(req.user.toPrivate()); 18 | }, 19 | ), 20 | ); 21 | 22 | const UserModifySchema = z 23 | .object({ 24 | display_name: z.string(), 25 | summary: z.string(), 26 | // todo: profile picture 27 | 28 | current_password: z.string(), 29 | 30 | // the below fields require current_password 31 | password: z.string(), 32 | email: z.string().email(), 33 | }) 34 | .partial() 35 | .strict() 36 | .refine((obj) => { 37 | if (obj.password !== undefined || obj.email !== undefined) { 38 | return obj.current_password !== undefined; 39 | } 40 | 41 | return true; 42 | }, "Must provide current password"); 43 | 44 | router.patch( 45 | "/", 46 | route( 47 | { 48 | body: UserModifySchema, 49 | }, 50 | async (req, res) => { 51 | if (req.body.current_password) { 52 | if ( 53 | !req.user.password_hash || 54 | !(await bcrypt.compare( 55 | req.body.current_password, 56 | req.user.password_hash, 57 | )) 58 | ) 59 | throw new HttpError("Invalid login", 401); 60 | } 61 | 62 | const update: Partial = {}; 63 | 64 | // I'm sure there's a better way to do this... 65 | if (req.body.display_name) 66 | update.display_name = req.body.display_name; 67 | if (req.body.summary) update.summary = req.body.summary; 68 | 69 | // current_password is required to set email 70 | if (req.body.email) update.email = req.body.email; 71 | 72 | if (req.body.password) { 73 | // zod schema enforces that current_password is provided 74 | 75 | update.password_hash = await bcrypt.hash(req.body.password, 12); 76 | 77 | // TODO: we might want to invalidate all this users sessions 78 | // and give them a new token 79 | } 80 | 81 | if (Object.keys(update).length === 0) { 82 | throw new HttpError("Must specify at least one property", 401); 83 | } 84 | 85 | await User.update({ id: req.user.id }, update); 86 | return res.sendStatus(200); 87 | }, 88 | ), 89 | ); 90 | 91 | export default router; 92 | -------------------------------------------------------------------------------- /src/http/api/users/@me/push.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import z from "zod"; 3 | import { PushSubscription } from "../../../../entity/pushSubscription"; 4 | import { route } from "../../../../util/route"; 5 | 6 | const router = Router({ mergeParams: true }); 7 | 8 | router.get( 9 | "/", 10 | route( 11 | { 12 | response: z 13 | .object({ 14 | name: z.string(), 15 | created: z.date(), 16 | }) 17 | .array(), 18 | }, 19 | async (req, res) => { 20 | const subscriptions = await PushSubscription.find({ 21 | where: { userId: req.user.id }, 22 | }); 23 | 24 | return res.json( 25 | subscriptions.map((x) => ({ 26 | name: x.name, 27 | created: x.created, 28 | })), 29 | ); 30 | }, 31 | ), 32 | ); 33 | 34 | router.post( 35 | "/", 36 | route( 37 | { 38 | body: z.object({ 39 | name: z.string(), 40 | endpoint: z.string().url(), 41 | p256dh: z.string(), 42 | auth: z.string(), 43 | }), 44 | errors: { 204: z.literal("No Content") }, 45 | }, 46 | async (req, res) => { 47 | const subscription = PushSubscription.create({ 48 | userId: req.user.id, 49 | name: req.body.name, 50 | endpoint: req.body.endpoint, 51 | p256dh: req.body.p256dh, 52 | auth: req.body.auth, 53 | }); 54 | 55 | await subscription.save(); 56 | 57 | return res.sendStatus(204); 58 | }, 59 | ), 60 | ); 61 | 62 | router.delete( 63 | "/:name", 64 | route( 65 | { 66 | params: z.object({ 67 | name: z.string(), 68 | }), 69 | errors: { 202: z.literal("Accepted") }, 70 | }, 71 | async (req, res) => { 72 | await PushSubscription.delete({ 73 | name: req.params.name, 74 | userId: req.user.id, 75 | }); 76 | 77 | return res.sendStatus(202); 78 | }, 79 | ), 80 | ); 81 | 82 | export default router; 83 | -------------------------------------------------------------------------------- /src/http/bootstrap.ts: -------------------------------------------------------------------------------- 1 | import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi"; 2 | import { z } from "zod"; 3 | 4 | extendZodWithOpenApi(z); 5 | 6 | import "dotenv/config"; 7 | import { config } from "../util/config"; 8 | import { createLogger, setLogOptions } from "../util/log"; 9 | import { APIServer } from "./server"; 10 | 11 | setLogOptions(config.log); 12 | const Log = createLogger("bootstrap"); 13 | 14 | const PORT = process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 3001; 15 | 16 | process.on("uncaughtException", (error, origin) => { 17 | Log.error(`Caught ${origin}`, error); 18 | }); 19 | 20 | const api = new APIServer(); 21 | 22 | api.listen(PORT); 23 | -------------------------------------------------------------------------------- /src/http/middleware/auth.ts: -------------------------------------------------------------------------------- 1 | import type { RequestHandler } from "express"; 2 | import type { Actor } from "../../entity/actor"; 3 | import type { User } from "../../entity/user"; 4 | import { ACTIVITY_JSON_ACCEPT } from "../../util/activitypub/constants"; 5 | import { HttpError } from "../../util/httperror"; 6 | import { getUserFromToken } from "../../util/token"; 7 | 8 | export const NO_AUTH_ROUTES = [ 9 | "/auth/login", 10 | "/auth/register", 11 | /\.well-known/, 12 | "/nodeinfo/2.0.json", 13 | 14 | // TODO: this might not be a good idea? 15 | /channel\/.*?\/attachments\/.+$/, 16 | 17 | // TODO: there are here because lemmy keeps requesting them 18 | // and it throws a huge stack trace in my terminal 19 | "/api/v3/site", 20 | "/api/v3/federated_instances", 21 | ]; 22 | 23 | export const authHandler: RequestHandler = async (req, _res, next) => { 24 | const url = req.url; 25 | 26 | if ( 27 | NO_AUTH_ROUTES.some((x) => { 28 | if (typeof x === "string") return url.startsWith(x); 29 | return x.test(url); 30 | }) || 31 | ACTIVITY_JSON_ACCEPT.some((v) => req.headers.accept?.includes(v)) || 32 | ACTIVITY_JSON_ACCEPT.some((v) => 33 | req.headers["content-type"]?.includes(v), 34 | ) 35 | ) 36 | return next(); 37 | 38 | const { authorization } = req.headers; 39 | if (!authorization) 40 | return next(new HttpError("Missing `authorization` header", 401)); 41 | 42 | let user: User; 43 | try { 44 | user = await getUserFromToken(authorization); 45 | } catch (e) { 46 | return next(e); 47 | } 48 | 49 | req.user = user; 50 | 51 | return next(); 52 | }; 53 | 54 | declare global { 55 | namespace Express { 56 | interface Request { 57 | /** For local authenticated routes (using a token), contains the User object associated with the token */ 58 | user: User; 59 | /** For s2s/federated routes (using http signatures), contains the Actor that signed this request */ 60 | actor: Actor; 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/http/middleware/error.ts: -------------------------------------------------------------------------------- 1 | import type { ErrorRequestHandler } from "express"; 2 | import z from "zod"; 3 | import { InstanceBlockedError } from "../../util/activitypub/instances"; 4 | import { HttpError } from "../../util/httperror"; 5 | import { createLogger } from "../../util/log"; 6 | import { ValidationError } from "../../util/route"; 7 | 8 | const ENTITY_NOT_FOUND_REGEX = /"(\w+)"/; 9 | 10 | const Log = createLogger("HTTP"); 11 | 12 | export const errorHandler: ErrorRequestHandler = (error, _req, res, next) => { 13 | if (res.headersSent) return next(error); 14 | 15 | let code = 400; 16 | let message: string = error.message; 17 | 18 | let hasLogged = false; 19 | 20 | switch (true) { 21 | case error instanceof ValidationError: 22 | res.status(code).json({ 23 | code: 400, 24 | message: "Invalid request", 25 | detail: Object.fromEntries( 26 | Object.entries(error.issues).map(([key, value]) => [ 27 | key, 28 | value.issues, 29 | ]), 30 | ), 31 | }); 32 | return; 33 | case error instanceof z.ZodError: 34 | message = error.errors[0].message; 35 | break; 36 | case error instanceof InstanceBlockedError: 37 | // TODO: I'd prefer to shadow ban i.e. send them a response as if it worked normally 38 | code = 401; 39 | message = "Unauthorised"; 40 | break; 41 | case error instanceof SyntaxError: 42 | // silence 43 | break; 44 | case error instanceof HttpError: 45 | code = error.code; 46 | break; 47 | case error.name === "EntityNotFoundError": { 48 | code = 404; 49 | const name = 50 | error.message.match(ENTITY_NOT_FOUND_REGEX)?.[1] || "Object"; 51 | message = `${name} could not be found`; 52 | break; 53 | } 54 | case error.name === "QueryFailedError": 55 | code = 500; 56 | 57 | if (error.message.toLowerCase().includes("unique")) { 58 | code = 400; 59 | message = "Object already exists"; 60 | } 61 | break; 62 | case error.message === "fetch failed": 63 | code = 500; 64 | message = 65 | error?.cause?.errors?.[0]?.message || 66 | error?.cause?.message || 67 | error.message; 68 | break; 69 | case "$metadata" in error: { 70 | // aws s3 client error 71 | code = 500; 72 | message = 73 | "$response" in error 74 | ? error.$response.reason 75 | : "Internal server error"; 76 | break; 77 | } 78 | default: 79 | Log.error(error); 80 | hasLogged = true; 81 | break; 82 | } 83 | 84 | if (!hasLogged) Log.verbose(error); 85 | 86 | return res.status(code).json({ code, message }); 87 | }; 88 | -------------------------------------------------------------------------------- /src/http/middleware/httpsig.ts: -------------------------------------------------------------------------------- 1 | import bodyParser from "body-parser"; 2 | import type { RequestHandler } from "express"; 3 | import { ACTIVITY_JSON_ACCEPT } from "../../util/activitypub/constants"; 4 | import { validateHttpSignature } from "../../util/activitypub/httpsig"; 5 | import { config } from "../../util/config"; 6 | import { createLogger } from "../../util/log"; 7 | import { makeInstanceUrl } from "../../util/url"; 8 | 9 | const Log = createLogger("httpsignatures"); 10 | 11 | export const verifyHttpSig: RequestHandler = async (req, res, next) => { 12 | if (req.originalUrl === "/actor" && req.method === "GET") { 13 | return next(); // allow GET /actor unsigned 14 | } 15 | 16 | if (!req.headers.signature && !config.federation.require_http_signatures) { 17 | /** 18 | * This request hasn't been signed and we don't require sigs for every req 19 | * 20 | * NOTE: If a route requires a http sig (e.g. sending a friend req), it will try to access req.actor, 21 | * which will be undefined if the signature isn't provided. 22 | */ 23 | 24 | return next(); 25 | } 26 | 27 | // we want to only run bodyParser.raw if the request actually needs to be verified 28 | // otherwise, it'll get bodyParser.json further down 29 | 30 | const parser = bodyParser.raw({ 31 | type: ACTIVITY_JSON_ACCEPT, 32 | inflate: true, 33 | }); 34 | 35 | await new Promise((resolve, reject) => { 36 | parser(req, res, (err) => { 37 | if (err) { 38 | return reject(err); 39 | } 40 | 41 | resolve(); 42 | }); 43 | }); 44 | 45 | // we have req.body now. 46 | // later in the request stack, JSON.parse or the normal bodyParser.json will be run 47 | // depending on if req.body exists 48 | 49 | try { 50 | req.actor = await validateHttpSignature( 51 | new URL(makeInstanceUrl(req.originalUrl)).pathname, 52 | req.method, 53 | req.headers, 54 | req.body, 55 | // Object.values(req.body ?? {}).length > 0 ? req.body : undefined, 56 | ); 57 | } catch (e) { 58 | Log.verbose( 59 | `${req.originalUrl} : ${e instanceof Error ? e.message : e}`, 60 | ); 61 | return next(e); 62 | } 63 | 64 | return next(); 65 | }; 66 | -------------------------------------------------------------------------------- /src/http/middleware/rate.ts: -------------------------------------------------------------------------------- 1 | import rateLimit, { type RateLimitRequestHandler } from "express-rate-limit"; 2 | import { config } from "../../util/config"; 3 | 4 | const NONE: RateLimitRequestHandler = (_req, _res, next) => { 5 | return next(); 6 | }; 7 | 8 | NONE.resetKey = () => undefined; 9 | NONE.getKey = (_key: string) => undefined; 10 | 11 | export const rateLimiter = ( 12 | type: "s2s" | "auth" | "nodeinfo" | "wellknown" | "global", 13 | ): RateLimitRequestHandler => { 14 | if (!config.http.rate) return NONE; 15 | 16 | return rateLimit({ 17 | windowMs: config.http.rate[type].window, 18 | limit: config.http.rate[type].limit, 19 | standardHeaders: "draft-7", 20 | message: () => ({ code: 429, message: "Too many requests" }), 21 | keyGenerator: (req) => { 22 | return req.user 23 | ? `${type}-${req.user.id}-${req.ip}` 24 | : `${type}-${req.ip}`; 25 | }, 26 | }); 27 | }; 28 | -------------------------------------------------------------------------------- /src/http/routes.ts: -------------------------------------------------------------------------------- 1 | import type { IncomingHttpHeaders } from "node:http"; 2 | import { Router } from "express"; 3 | import { ACTIVITY_JSON_ACCEPT } from "../util/activitypub/constants"; 4 | import api from "./api"; 5 | import s2s from "./s2s"; 6 | import wellknown from "./wellknown"; 7 | 8 | const router = Router(); 9 | 10 | export const isFederationRequest = (headers: IncomingHttpHeaders) => 11 | ACTIVITY_JSON_ACCEPT.some((v) => headers.accept?.includes(v)) || 12 | ACTIVITY_JSON_ACCEPT.some((v) => headers["content-type"]?.includes(v)); 13 | 14 | // Mount the s2s API on / based on the Accept header 15 | router.use("/", (req, res, next) => { 16 | if (isFederationRequest(req.headers)) { 17 | res.setHeader( 18 | "Content-Type", 19 | "application/activity+json; charset=utf-8", 20 | ); 21 | s2s(req, res, next); 22 | } else api(req, res, next); 23 | }); 24 | 25 | router.use("/", wellknown); 26 | 27 | export default router; 28 | -------------------------------------------------------------------------------- /src/http/s2s/actor.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { InstanceActor } from "../../util/activitypub/instanceActor"; 3 | import { buildAPActor } from "../../util/activitypub/transformers/actor"; 4 | import { addContext } from "../../util/activitypub/util"; 5 | import { route } from "../../util/route"; 6 | 7 | const router = Router(); 8 | 9 | router.get( 10 | "/", 11 | route({}, (_req, res) => { 12 | return res.json( 13 | addContext({ 14 | ...buildAPActor(InstanceActor), 15 | type: "Application", 16 | }), 17 | ); 18 | }), 19 | ); 20 | 21 | export default router; 22 | -------------------------------------------------------------------------------- /src/http/s2s/channel/#id/message/#id/index.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { z } from "zod"; 3 | import { Message } from "../../../../../../entity/message"; 4 | import { buildAPNote } from "../../../../../../util/activitypub/transformers/message"; 5 | import { config } from "../../../../../../util/config"; 6 | import { route } from "../../../../../../util/route"; 7 | 8 | const router = Router({ mergeParams: true }); 9 | 10 | router.get( 11 | "/", 12 | route( 13 | { 14 | params: z.object({ 15 | channel_id: z.string(), 16 | message_id: z.string(), 17 | }), 18 | }, 19 | async (req, res) => { 20 | const { channel_id, message_id } = req.params; 21 | 22 | const msg = await Message.findOneOrFail({ 23 | where: { 24 | id: message_id, 25 | channel: { 26 | id: channel_id, 27 | domain: config.federation.webapp_url.hostname, 28 | }, 29 | }, 30 | relations: { 31 | channel: true, 32 | author: true, 33 | }, 34 | }); 35 | 36 | return res.json(buildAPNote(msg)); 37 | }, 38 | ), 39 | ); 40 | 41 | export default router; 42 | -------------------------------------------------------------------------------- /src/http/s2s/inbox.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { z } from "zod"; 3 | import { APError } from "../../util/activitypub/error"; 4 | import { handleInbox } from "../../util/activitypub/inbox"; 5 | import { 6 | hasAPContext, 7 | splitQualifiedMention, 8 | } from "../../util/activitypub/util"; 9 | import { config } from "../../util/config"; 10 | import { findActorOfAnyType } from "../../util/entity/resolve"; 11 | import { route } from "../../util/route"; 12 | 13 | const router = Router(); 14 | 15 | router.post( 16 | "/", 17 | route( 18 | { 19 | body: z.any(), 20 | }, 21 | async (req, res) => { 22 | // TODO: addressed to multiple actors 23 | if (!hasAPContext(req.body)) 24 | throw new APError("Doesn't have context"); 25 | 26 | const activity = req.body; 27 | // TODO: multiple addressing 28 | // TODO: `to` field isn't always used in activities 29 | // (sometimes it's `object` or whatever). So should do that 30 | // Maybe have some sort of switch for which fields to use with each activity type 31 | const to = Array.isArray(activity.to) 32 | ? activity.to[0] 33 | : activity.to; 34 | 35 | if (typeof to !== "string") 36 | throw new APError("Don't know how to resolve to field"); 37 | const mention = splitQualifiedMention(to); 38 | if (mention.domain !== config.federation.webapp_url.hostname) 39 | throw new APError("Not addressed to a user we control"); 40 | 41 | const actor = await findActorOfAnyType( 42 | mention.id, 43 | config.federation.webapp_url.hostname, 44 | ); 45 | if (!actor) 46 | throw new APError( 47 | "Activity is addressed to actor we don't have", 48 | ); 49 | 50 | await handleInbox(activity, actor); 51 | return res.sendStatus(200); 52 | }, 53 | ), 54 | ); 55 | 56 | export default router; 57 | -------------------------------------------------------------------------------- /src/http/s2s/index.ts: -------------------------------------------------------------------------------- 1 | import bodyParser from "body-parser"; 2 | import { Router } from "express"; 3 | import { verifyHttpSig } from "../middleware/httpsig"; 4 | import { rateLimiter } from "../middleware/rate"; 5 | 6 | const router = Router(); 7 | 8 | // rate limit before http signature verification 9 | // so that we don't do unwanted work 10 | router.use(rateLimiter("s2s")); 11 | 12 | router.use(verifyHttpSig); 13 | 14 | // see comments in verifyHttpSig 15 | router.use((req, res, next) => { 16 | // we want to only run bodyParser.json if req.body doesn't exist 17 | if (req.body && Buffer.isBuffer(req.body)) { 18 | // bodyParser.raw was run above, we need to JSON.parse it for the rest of our handlers 19 | req.body = JSON.parse(req.body.toString()); 20 | return next(); 21 | } 22 | 23 | // we didn't run verifyHttpSig on this request 24 | // so we don't have raw body 25 | // need to run normal json handler now 26 | const parser = bodyParser.json({ 27 | type: ACTIVITY_JSON_ACCEPT, 28 | inflate: true, 29 | }); 30 | parser(req, res, next); 31 | }); 32 | 33 | import inbox from "./inbox"; 34 | 35 | router.use("/inbox", inbox); 36 | 37 | import users_id from "./users/#id"; 38 | 39 | router.use("/users/:user_id", users_id); 40 | 41 | import channel_id from "./channel/#id"; 42 | 43 | router.use("/channel/:channel_id", channel_id); 44 | 45 | import guilds_id from "./guild/#id"; 46 | 47 | router.use("/guild/:guild_id", guilds_id); 48 | 49 | import guild_id_role from "./guild/#id/role"; 50 | 51 | router.use("/guild/:guild_id/role", guild_id_role); 52 | 53 | import invite_id from "./invite/#id"; 54 | 55 | router.use("/invite/:invite_id", invite_id); 56 | 57 | import channel_id_message_id from "./channel/#id/message/#id"; 58 | 59 | router.use("/channel/:channel_id/message/:message_id", channel_id_message_id); 60 | 61 | import { ACTIVITY_JSON_ACCEPT } from "../../util/activitypub/constants"; 62 | import actor from "./actor"; 63 | 64 | router.use("/actor", actor); 65 | 66 | export default router; 67 | -------------------------------------------------------------------------------- /src/http/s2s/invite/#id/index.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { z } from "zod"; 3 | import { Invite } from "../../../../entity/invite"; 4 | import { buildAPGuildInvite } from "../../../../util/activitypub/transformers/invite"; 5 | import { addContext } from "../../../../util/activitypub/util"; 6 | import { route } from "../../../../util/route"; 7 | 8 | const router = Router({ mergeParams: true }); 9 | 10 | router.get( 11 | "/", 12 | route({ params: z.object({ invite_id: z.string() }) }, async (req, res) => { 13 | const { invite_id } = req.params; 14 | 15 | const invite = await Invite.findOneOrFail({ 16 | where: { 17 | code: invite_id, 18 | }, 19 | relations: ["guild", "guild.owner"], 20 | }); 21 | 22 | return res.json(addContext(buildAPGuildInvite(invite))); 23 | }), 24 | ); 25 | 26 | export default router; 27 | -------------------------------------------------------------------------------- /src/http/s2s/users/#id/index.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { z } from "zod"; 3 | import { Relationship } from "../../../../entity/relationship"; 4 | import { User } from "../../../../entity/user"; 5 | import { handleInbox } from "../../../../util/activitypub/inbox"; 6 | import { orderedCollectionHandler } from "../../../../util/activitypub/orderedCollection"; 7 | import { buildAPActor } from "../../../../util/activitypub/transformers/actor"; 8 | import { addContext } from "../../../../util/activitypub/util"; 9 | import { config } from "../../../../util/config"; 10 | import { getDatabase } from "../../../../util/database"; 11 | import { route } from "../../../../util/route"; 12 | import { makeInstanceUrl } from "../../../../util/url"; 13 | 14 | const router = Router({ mergeParams: true }); 15 | 16 | router.get( 17 | "/", 18 | route({ params: z.object({ user_id: z.string() }) }, async (req, res) => { 19 | const { user_id } = req.params; 20 | 21 | const user = await User.findOneOrFail({ 22 | where: { 23 | name: user_id, 24 | domain: config.federation.webapp_url.hostname, 25 | }, 26 | }); 27 | 28 | return res.json(addContext(buildAPActor(user))); 29 | }), 30 | ); 31 | 32 | router.post( 33 | "/inbox", 34 | route( 35 | { 36 | body: z.any(), 37 | params: z.object({ user_id: z.string() }), 38 | }, 39 | async (req, res) => { 40 | const target = await User.findOneOrFail({ 41 | where: { name: req.params.user_id }, 42 | }); 43 | 44 | await handleInbox(req.body, target); 45 | return res.sendStatus(200); 46 | }, 47 | ), 48 | ); 49 | 50 | const COLLECTION_PARAMS = { 51 | params: z.object({ 52 | user_id: z.string(), 53 | }), 54 | query: z.object({ 55 | before: z.string().optional(), 56 | after: z.string().optional(), 57 | }), 58 | }; 59 | 60 | router.get( 61 | "/followers", 62 | route(COLLECTION_PARAMS, async (req, res) => 63 | res.json( 64 | await orderedCollectionHandler({ 65 | id: makeInstanceUrl(`/users/${req.params.user_id}/followers`), 66 | ...req.query, 67 | convert: (x) => x.from.remote_address ?? buildAPActor(x.from), 68 | entity: Relationship, 69 | qb: getDatabase() 70 | .getRepository(Relationship) 71 | .createQueryBuilder("relationship") 72 | .leftJoinAndSelect("relationship.from", "from") 73 | .leftJoin("relationship.to", "to") 74 | .where("to.name = :name", { 75 | name: req.params.user_id, 76 | }) 77 | .andWhere("to.domain = :domain", { 78 | domain: config.federation.webapp_url.hostname, 79 | }), 80 | }), 81 | ), 82 | ), 83 | ); 84 | 85 | // TODO: outbox, followers 86 | 87 | export default router; 88 | -------------------------------------------------------------------------------- /src/http/server.ts: -------------------------------------------------------------------------------- 1 | import http from "node:http"; 2 | import cors from "cors"; 3 | import express from "express"; 4 | import morgan from "morgan"; 5 | 6 | import { config } from "../util/config"; 7 | import { initDatabase } from "../util/database"; 8 | import { initRabbitMQ } from "../util/events"; 9 | import { createLogger, createLogStream } from "../util/log"; 10 | import { errorHandler } from "./middleware/error"; 11 | import routes, { isFederationRequest } from "./routes"; 12 | 13 | const Log = createLogger("API"); 14 | 15 | export class APIServer { 16 | server: http.Server; 17 | app: express.Application; 18 | 19 | public constructor(server?: http.Server) { 20 | this.app = express(); 21 | 22 | this.app.use(cors()); 23 | 24 | this.app.set("trust proxy", config.security.trust_proxy); 25 | 26 | morgan.token("mode", (req) => 27 | isFederationRequest(req.headers) ? "fed" : "api", 28 | ); 29 | 30 | morgan.token("type", (req) => req.headers["content-type"]); 31 | morgan.token("accept", (req) => req.headers.accept); 32 | 33 | if (config.http.log) 34 | this.app.use( 35 | morgan(config.http.log_format, { 36 | stream: createLogStream("HTTP"), 37 | skip(_req, res) { 38 | const log = config.http.log; 39 | const skip = 40 | log?.includes(res.statusCode.toString()) ?? false; 41 | return log?.charAt(0) === "-" ? skip : !skip; 42 | }, 43 | }), 44 | ); 45 | 46 | this.app.use("/", routes); 47 | 48 | this.app.use(errorHandler); 49 | 50 | this.server = server ?? http.createServer(); 51 | this.server.on("request", this.app); 52 | } 53 | 54 | public async listen(port: number) { 55 | this.server.on("listening", () => { 56 | Log.msg(`Listening on port ${port}`); 57 | }); 58 | 59 | await initDatabase(); 60 | await initRabbitMQ(false); 61 | 62 | if (!this.server.listening) this.server.listen(port); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/http/wellknown/host-meta.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { config } from "../../util/config"; 3 | import { HttpError } from "../../util/httperror"; 4 | import { route } from "../../util/route"; 5 | 6 | const router = Router(); 7 | 8 | router.get( 9 | "/host-meta", 10 | route({}, async (_req, res) => { 11 | if (!config.federation.enabled) 12 | throw new HttpError("Federation is disabled", 400); 13 | 14 | res.setHeader("Content-Type", "application/xrd+xml"); 15 | 16 | const host = config.federation.instance_url.origin; 17 | 18 | const ret = ` 19 | 21 | 22 | `; 23 | 24 | return res.send(ret); 25 | }), 26 | ); 27 | 28 | export default router; 29 | -------------------------------------------------------------------------------- /src/http/wellknown/index.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { rateLimiter } from "../middleware/rate"; 3 | import hostMeta from "./host-meta"; 4 | import nodeInfo from "./nodeinfo"; 5 | import webfinger from "./webfinger"; 6 | 7 | const router = Router(); 8 | 9 | router.use("/.well-known/nodeinfo", rateLimiter("nodeinfo"), nodeInfo); 10 | router.use("/.well-known", rateLimiter("wellknown"), hostMeta, webfinger); 11 | 12 | export default router; 13 | -------------------------------------------------------------------------------- /src/media/bootstrap.ts: -------------------------------------------------------------------------------- 1 | import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi"; 2 | import { z } from "zod"; 3 | 4 | extendZodWithOpenApi(z); 5 | 6 | import "dotenv/config"; 7 | import { config } from "../util/config"; 8 | import { createLogger, setLogOptions } from "../util/log"; 9 | import { MediaGatewayServer } from "./server"; 10 | 11 | setLogOptions(config.log); 12 | const Log = createLogger("bootstrap"); 13 | 14 | const MEDIA_PORT = process.env.MEDIA_PORT 15 | ? Number.parseInt(process.env.MEDIA_PORT, 10) 16 | : 3003; 17 | 18 | process.on("uncaughtException", (error, origin) => { 19 | Log.error(`Caught ${origin}`, error); 20 | }); 21 | 22 | const media = new MediaGatewayServer(); 23 | 24 | media.listen(MEDIA_PORT); 25 | -------------------------------------------------------------------------------- /src/media/handlers/heartbeat.ts: -------------------------------------------------------------------------------- 1 | import { CLOSE_CODES } from "../../gateway/util/codes"; 2 | import { HEARTBEAT } from "../util/validation/receive"; 3 | import type { HEARTBEAT_ACK } from "../util/validation/send"; 4 | import type { MediaSocket } from "../util/websocket"; 5 | import { makeHandler } from "."; 6 | 7 | export const onHeartbeat = makeHandler(async function (payload) { 8 | if (payload.s !== this.sequence) 9 | // TODO: send them back the missing events 10 | throw new Error("Out of sync. Reconnect"); 11 | 12 | clearTimeout(this.heartbeat_timeout); 13 | startHeartbeatTimeout(this); 14 | 15 | const ret: HEARTBEAT_ACK = { 16 | type: "HEARTBEAT_ACK", 17 | }; 18 | 19 | return await this.send(ret); 20 | }, HEARTBEAT); 21 | 22 | export const heartbeatTimeout = (socket: MediaSocket) => { 23 | socket.close(CLOSE_CODES.HEARTBEAT_TIMEOUT); 24 | }; 25 | 26 | export const startHeartbeatTimeout = (socket: MediaSocket) => { 27 | socket.heartbeat_timeout = setTimeout( 28 | () => heartbeatTimeout(socket), 29 | 10_000, 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /src/media/handlers/identify.ts: -------------------------------------------------------------------------------- 1 | import type { Channel } from "../../entity/channel"; 2 | import type { User } from "../../entity/user"; 3 | import { CLOSE_CODES } from "../../gateway/util/codes"; 4 | import { validateMediaToken } from "../../util/voice"; 5 | import { emitMediaEvent, listenMediaEvent } from "../util/events"; 6 | import { getJanus } from "../util/janus"; 7 | import { getRoomId, setRoomId } from "../util/rooms"; 8 | import { IDENTIFY } from "../util/validation/receive"; 9 | import { makeHandler } from "."; 10 | import { startHeartbeatTimeout } from "./heartbeat"; 11 | 12 | export const onIdentify = makeHandler(async function (payload) { 13 | let user: User; 14 | let channel: Channel; 15 | try { 16 | const ret = await validateMediaToken(payload.token); 17 | user = ret.user; 18 | channel = ret.channel; 19 | } catch (_) { 20 | this.close(CLOSE_CODES.BAD_TOKEN); 21 | return; 22 | } 23 | 24 | this.user_id = user.mention; 25 | 26 | startHeartbeatTimeout(this); 27 | 28 | clearTimeout(this.auth_timeout); 29 | 30 | const janus = getJanus(); 31 | 32 | this.room_id = getRoomId(channel.id); 33 | if (!this.room_id) { 34 | // Room doesn't exist yet, make it 35 | const res = await janus.createRoom(); 36 | setRoomId(channel.id, res.room); 37 | this.room_id = res.room; 38 | } 39 | 40 | this.media_handle_id = (await janus.attachHandle()).id; 41 | 42 | await janus.joinRoom(this.media_handle_id, this.room_id, user.mention); 43 | 44 | const response = await janus.configure(this.media_handle_id, payload.offer); 45 | 46 | await janus.trickle(this.media_handle_id, payload.candidates); 47 | 48 | // Notify other users we arrived 49 | emitMediaEvent(this.room_id, { 50 | type: "PEER_JOINED", 51 | user_id: this.user_id, 52 | }); 53 | 54 | // Join the horde 55 | this.events = listenMediaEvent(this.room_id, (payload) => 56 | this.send(payload), 57 | ); 58 | 59 | this.send({ type: "READY", answer: { jsep: response.jsep } }); 60 | }, IDENTIFY); 61 | -------------------------------------------------------------------------------- /src/media/handlers/index.ts: -------------------------------------------------------------------------------- 1 | import type { ZodSchema } from "zod"; 2 | import { CLOSE_CODES } from "../../gateway/util/codes"; 3 | import { createLogger } from "../../util/log"; 4 | import type { MediaSocket } from "../util/websocket"; 5 | 6 | const Log = createLogger("gateway"); 7 | 8 | export type GatewayMessageHandler = ( 9 | this: MediaSocket, 10 | message: T, 11 | ) => Promise | unknown; 12 | 13 | export const makeHandler = ( 14 | handler: GatewayMessageHandler, 15 | schema: ZodSchema, 16 | ) => { 17 | return function func(this: MediaSocket, data: T) { 18 | const ret = schema.safeParse(data); 19 | if (!ret.success) { 20 | Log.verbose(`${this.ip_address} sent malformed data`); 21 | this.close(CLOSE_CODES.BAD_PAYLOAD); 22 | return; 23 | } 24 | 25 | return handler.call(this, data as T); 26 | }; 27 | }; 28 | 29 | export const handlers: Record> = { 30 | identify: require("./identify").onIdentify, 31 | heartbeat: require("./heartbeat").onHeartbeat, 32 | }; 33 | -------------------------------------------------------------------------------- /src/media/readme.md: -------------------------------------------------------------------------------- 1 | # WebRTC Signalling 2 | 3 | WebRTC signalling server for voice and video 4 | 5 | 1. Client requests voice token from host instance 6 | - For local users, this is just a POST request 7 | - For remote users, this is an activity to the voice channel inbox 8 | 2. Client identifies with signalling server of instance that owns VC 9 | - Provides above token, webrtc offer and candidates 10 | 3. Signalling sends answer and call can begin. 11 | 12 | Notes: 13 | 14 | - All channels are callable, which should simplify database and code as there is no distinction between 'voice channels' and text channels 15 | -------------------------------------------------------------------------------- /src/media/server.ts: -------------------------------------------------------------------------------- 1 | import type { EventEmitter } from "node:events"; 2 | import http from "node:http"; 3 | import ws from "ws"; 4 | import { initDatabase } from "../util/database"; 5 | import { createLogger } from "../util/log"; 6 | import { onConnection } from "./socket/connection"; 7 | import { initJanus } from "./util/janus"; 8 | 9 | const Log = createLogger("MEDIA"); 10 | 11 | export class MediaGatewayServer { 12 | server: http.Server; 13 | socket: ws.Server; 14 | janus: EventEmitter; 15 | 16 | public constructor(server?: http.Server) { 17 | this.server = server ?? http.createServer(); 18 | 19 | this.socket = new ws.Server({ 20 | server: this.server, 21 | }); 22 | 23 | this.socket.on("connection", onConnection); 24 | } 25 | 26 | public async listen(port: number) { 27 | this.server.on("listening", () => { 28 | Log.msg(`Listening on port ${port}`); 29 | }); 30 | 31 | await initDatabase(); 32 | 33 | try { 34 | await initJanus(); 35 | } catch (_) { 36 | Log.error("Failed to connect to Janus. Webrtc will be unavailable"); 37 | return; 38 | } 39 | 40 | if (!this.server.listening) this.server.listen(port); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/media/socket/close.ts: -------------------------------------------------------------------------------- 1 | import { createLogger } from "../../util/log"; 2 | import { emitMediaEvent } from "../util/events"; 3 | import { getJanus } from "../util/janus"; 4 | import type { MediaSocket } from "../util/websocket"; 5 | 6 | const Log = createLogger("media"); 7 | 8 | export async function onClose(this: MediaSocket, event: CloseEvent) { 9 | Log.verbose(`${this.ip_address} disconnected with code ${event.code}`); 10 | 11 | clearTimeout(this.auth_timeout); 12 | clearTimeout(this.heartbeat_timeout); 13 | 14 | const janus = getJanus(); 15 | 16 | // Leave the room 17 | if (this.media_handle_id) await janus.leaveRoom(this.media_handle_id); 18 | 19 | // Close our room listener if we have one 20 | this.events?.(); 21 | 22 | // Notify others 23 | if (this.room_id) 24 | emitMediaEvent(this.room_id, { 25 | type: "PEER_LEFT", 26 | user_id: this.user_id, 27 | }); 28 | } 29 | -------------------------------------------------------------------------------- /src/media/socket/connection.ts: -------------------------------------------------------------------------------- 1 | import type { IncomingMessage } from "node:http"; 2 | import type ws from "ws"; 3 | import { CLOSE_CODES } from "../../gateway/util/codes"; 4 | import { createLogger } from "../../util/log"; 5 | import { type MediaSocket, send } from "../util/websocket"; 6 | import { onClose } from "./close"; 7 | import { onMessage } from "./message"; 8 | 9 | const Log = createLogger("media"); 10 | 11 | export function onConnection( 12 | this: ws.Server, 13 | socket: MediaSocket, 14 | request: IncomingMessage, 15 | ) { 16 | socket.sequence = 0; 17 | 18 | //@ts-expect-error 19 | socket.raw_send = socket.send; 20 | socket.send = send.bind(socket); 21 | 22 | // TODO: trust proxy 23 | const ip = 24 | request.headers["x-forwarded-for"] || 25 | request.socket.remoteAddress || 26 | "unknown"; 27 | socket.ip_address = Array.isArray(ip) ? ip[0] : ip; 28 | 29 | Log.verbose(`New client from '${socket.ip_address}'`); 30 | 31 | socket.addEventListener("close", async (ev) => { 32 | try { 33 | await onClose.call(socket, ev); 34 | } catch (e) { 35 | Log.error("close handler failed with", e); 36 | } 37 | }); 38 | 39 | socket.addEventListener("message", async function (ev) { 40 | try { 41 | await onMessage.call(socket, ev); 42 | } catch (e) { 43 | this.close(CLOSE_CODES.SERVER_ERROR); 44 | Log.error("message handler failed with", e); 45 | } 46 | }); 47 | 48 | // Trigger auth timeout after 10 seconds 49 | socket.auth_timeout = setTimeout( 50 | () => socket.close(CLOSE_CODES.IDENTIFY_TIMEOUT), 51 | 10_000, 52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /src/media/socket/message.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { handlers } from "../handlers"; 3 | import type { MediaSocket } from "../util/websocket"; 4 | 5 | export async function onMessage( 6 | this: MediaSocket, 7 | event: MessageEvent, 8 | ) { 9 | const parsed = validate(event.data); 10 | 11 | const handler = handlers[parsed.t]; 12 | if (!handler) throw new Error("invalid opcode"); 13 | 14 | await handler.call(this, parsed); 15 | } 16 | 17 | const GatewayPayload = z 18 | .object({ 19 | t: z.string(), 20 | }) 21 | .passthrough(); 22 | 23 | const validate = (message: unknown) => { 24 | let ret: unknown; 25 | if (typeof message === "string") ret = JSON.parse(message); 26 | else throw new Error("unimplemented"); 27 | 28 | return GatewayPayload.parse(ret); 29 | }; 30 | -------------------------------------------------------------------------------- /src/media/util/events.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from "node:events"; 2 | import type { MEDIA_EVENT } from "./validation/send"; 3 | 4 | const events = new EventEmitter(); 5 | 6 | export const emitMediaEvent = (room_id: number, payload: MEDIA_EVENT) => 7 | events.emit(`${room_id}`, payload); 8 | 9 | export const listenMediaEvent = ( 10 | room_id: number, 11 | callback: (payload: MEDIA_EVENT) => unknown, 12 | ) => { 13 | events.setMaxListeners(events.getMaxListeners() + 1); 14 | events.addListener(`${room_id}`, callback); 15 | 16 | return () => { 17 | events.removeListener(`${room_id}`, callback); 18 | events.setMaxListeners(events.getMaxListeners() - 1); 19 | }; 20 | }; 21 | -------------------------------------------------------------------------------- /src/media/util/janus.ts: -------------------------------------------------------------------------------- 1 | import { config } from "../../util/config"; 2 | import { Janus } from "../janus"; 3 | 4 | let janus: Janus; 5 | 6 | export const initJanus = () => { 7 | janus = new Janus(); 8 | return janus.connect({ address: { url: config.webrtc.janus_url } }); 9 | }; 10 | 11 | export const getJanus = () => { 12 | if (!janus) throw new Error("Janus not initialised yet"); 13 | return janus; 14 | }; 15 | -------------------------------------------------------------------------------- /src/media/util/rooms.ts: -------------------------------------------------------------------------------- 1 | /** Channel ID -> Janus room ID */ 2 | const RoomIds: Map = new Map(); 3 | 4 | export const getRoomId = (channel_id: string) => RoomIds.get(channel_id); 5 | 6 | export const setRoomId = (channel_id: string, room_id: number) => 7 | RoomIds.set(channel_id, room_id); 8 | 9 | export const makeRoomId = () => RoomIds.size; 10 | 11 | /** Feed ID -> User mention */ 12 | const PeerIds: Map = new Map(); 13 | 14 | export const getPeerId = (feed: number) => PeerIds.get(feed); 15 | 16 | export const removePeerId = (feed: number) => PeerIds.delete(feed); 17 | 18 | export const setPeerId = (feed: number, user_mention: string) => 19 | PeerIds.set(feed, user_mention); 20 | -------------------------------------------------------------------------------- /src/media/util/validation/receive.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | const RTCIceCandidate: z.ZodType = z.object({ 4 | candidate: z.string(), 5 | sdpMLineIndex: z.number().nullable(), 6 | sdpMid: z.string().nullable(), 7 | usernameFragment: z.string().nullable(), 8 | }); 9 | 10 | export const IDENTIFY = z.object({ 11 | /** User token to use to login */ 12 | token: z.string(), 13 | 14 | /** webrtc offer */ 15 | offer: z.object({ 16 | type: z.string(), 17 | sdp: z.string(), 18 | }), 19 | 20 | candidates: RTCIceCandidate.array(), 21 | }); 22 | 23 | export const HEARTBEAT = z.object({ 24 | s: z.number(), 25 | }); 26 | -------------------------------------------------------------------------------- /src/media/util/validation/send.ts: -------------------------------------------------------------------------------- 1 | export type HEARTBEAT_ACK = { 2 | // The expected sequence number 3 | type: "HEARTBEAT_ACK"; 4 | }; 5 | 6 | export type READY = { 7 | type: "READY"; 8 | answer: { jsep: { type: "answer"; sdp: string } }; 9 | }; 10 | 11 | export type PEER_JOINED = { 12 | type: "PEER_JOINED"; 13 | user_id: string; 14 | }; 15 | 16 | export type PEER_LEFT = { 17 | type: "PEER_LEFT"; 18 | user_id: string; 19 | }; 20 | 21 | export type MEDIA_EVENT = HEARTBEAT_ACK | PEER_JOINED | PEER_LEFT | READY; 22 | -------------------------------------------------------------------------------- /src/media/util/websocket.ts: -------------------------------------------------------------------------------- 1 | import type { MEDIA_EVENT } from "./validation/send"; 2 | 3 | export interface MediaSocket extends Omit { 4 | media_handle_id?: number; 5 | 6 | room_id?: number; 7 | 8 | /** Below is copied from gateway src */ 9 | 10 | /** The source IP address of this socket */ 11 | ip_address: string; 12 | 13 | /** The user ID of this authenticated socket */ 14 | user_id: string; 15 | 16 | /** The current sequence/event number for this socket */ 17 | sequence: number; 18 | 19 | /** When triggered, disconnect this client. They have not authed in time */ 20 | auth_timeout?: NodeJS.Timeout; 21 | 22 | /** When triggered, disconnect this client. They have not sent a heartbeat in time */ 23 | heartbeat_timeout?: NodeJS.Timeout; 24 | 25 | /** 26 | * Event emitter UUID -> listener cancel function 27 | * We're only ever in a single room at a time, so we only have the 1 listener 28 | */ 29 | events: () => unknown; 30 | 31 | /** The original socket.send function */ 32 | raw_send: ( 33 | this: MediaSocket, 34 | data: string | ArrayBufferLike | Blob | ArrayBufferView, 35 | ) => unknown; 36 | 37 | /** The new socket.send function */ 38 | send: (this: MediaSocket, data: MEDIA_EVENT) => unknown; 39 | } 40 | 41 | export function send(this: MediaSocket, data: MEDIA_EVENT) { 42 | // TODO: zlib encoding? 43 | 44 | const { type, ...rest } = data; 45 | 46 | const ret = { t: type, d: rest, s: this.sequence++ }; 47 | 48 | return this.raw_send(JSON.stringify(ret)); 49 | } 50 | -------------------------------------------------------------------------------- /src/push/worker.ts: -------------------------------------------------------------------------------- 1 | import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi"; 2 | import { z } from "zod"; 3 | import { config } from "../util/config"; 4 | 5 | extendZodWithOpenApi(z); 6 | 7 | import { type Job, Worker } from "bullmq"; 8 | import webPush from "web-push"; 9 | import { PushSubscription } from "../entity/pushSubscription"; 10 | import type { ActorMention } from "../util/activitypub/constants"; 11 | import { splitQualifiedMention } from "../util/activitypub/util"; 12 | import { initDatabase } from "../util/database"; 13 | import { getOrFetchUser } from "../util/entity/user"; 14 | 15 | webPush.setVapidDetails( 16 | config.federation.instance_url.origin, 17 | config.notifications.publicKey, 18 | config.notifications.privateKey, 19 | ); 20 | 21 | export type PushNotificationJobData = { 22 | user: ActorMention; 23 | notification: { 24 | title: string; 25 | body: string; 26 | sent: number; // timestamp of when notification sent 27 | image?: string; // url of image to display 28 | 29 | channel?: ActorMention; 30 | guild?: ActorMention; 31 | author?: ActorMention; 32 | }; 33 | }; 34 | 35 | const jobHandler = async (job: Job) => { 36 | console.log(`${new Date()} ${job.data.user}`); 37 | 38 | if ( 39 | splitQualifiedMention(job.data.user).domain !== 40 | config.federation.webapp_url.hostname 41 | ) 42 | return; 43 | 44 | const user = await getOrFetchUser(job.data.user); 45 | 46 | const subscriptions = await PushSubscription.find({ 47 | where: { 48 | userId: user.id, 49 | }, 50 | }); 51 | 52 | for (const sub of subscriptions) { 53 | try { 54 | const res = await webPush.sendNotification( 55 | sub.asStandard(), 56 | JSON.stringify(job.data.notification), 57 | ); 58 | 59 | console.log(res); 60 | } catch (e) { 61 | await sub.remove(); 62 | } 63 | } 64 | }; 65 | 66 | const worker = new Worker("notifications", jobHandler, { 67 | connection: { 68 | host: config.redis.host, 69 | port: config.redis.port, 70 | }, 71 | autorun: false, 72 | }); 73 | 74 | worker.on("ready", () => { 75 | console.log("ready"); 76 | }); 77 | 78 | worker.on("failed", (job) => { 79 | console.warn(`${new Date()} Push notification failed ${job?.failedReason}`); 80 | }); 81 | 82 | worker.on("error", (e) => { 83 | if ("code" in e && e.code === "ECONNREFUSED") 84 | return console.error("Failed to connect to redis", e.message); 85 | 86 | console.error(e); 87 | }); 88 | 89 | (async () => { 90 | await initDatabase(); 91 | 92 | await worker.run(); 93 | })(); 94 | -------------------------------------------------------------------------------- /src/receiver/index.ts: -------------------------------------------------------------------------------- 1 | import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi"; 2 | import { z } from "zod"; 3 | 4 | extendZodWithOpenApi(z); 5 | 6 | import type { APActivity } from "activitypub-types"; 7 | import { type Job, Worker } from "bullmq"; 8 | import { ApCache } from "../entity/apcache"; 9 | import { Channel } from "../entity/channel"; 10 | import { Guild } from "../entity/guild"; 11 | import { User } from "../entity/user"; 12 | import { APError } from "../util/activitypub/error"; 13 | import { AP_ACTIVITY } from "../util/activitypub/inbox"; 14 | import { ActivityHandlers } from "../util/activitypub/inbox/handlers"; 15 | import { config } from "../util/config"; 16 | import { initDatabase } from "../util/database"; 17 | import { initRabbitMQ } from "../util/events"; 18 | 19 | export type APInboundJobData = { activity: APActivity; target_id: string }; 20 | 21 | const jobHandler = async (job: Job) => { 22 | const { activity, target_id } = job.data; 23 | 24 | console.log(`${new Date()} [${activity.type}] ${activity.id}`); 25 | 26 | activity["@context"] = undefined; 27 | 28 | const safe = AP_ACTIVITY.parse(activity); 29 | 30 | const [user, channel, guild] = await Promise.all([ 31 | await User.findOne({ where: { id: target_id } }), 32 | await Channel.findOne({ where: { id: target_id } }), 33 | await Guild.findOne({ where: { id: target_id } }), 34 | ]); 35 | const target = user ?? channel ?? guild; 36 | if (!target) throw new APError("Could not find target"); 37 | 38 | try { 39 | await ApCache.insert({ 40 | id: safe.id, 41 | raw: safe, 42 | }); 43 | } catch (_) { 44 | throw new APError(`Activity with id ${safe.id} already processed`); 45 | } 46 | 47 | await ActivityHandlers[safe.type.toLowerCase() as Lowercase]( 48 | activity, 49 | target, 50 | ); 51 | }; 52 | 53 | const worker = new Worker("inbound", jobHandler, { 54 | connection: { 55 | host: config.redis.host, 56 | port: config.redis.port, 57 | }, 58 | autorun: false, 59 | }); 60 | 61 | worker.on("failed", (job) => { 62 | console.warn( 63 | `${new Date()} Activity ${job?.data.activity.id} failed ${job?.failedReason}`, 64 | ); 65 | }); 66 | 67 | worker.on("error", (e) => { 68 | if ("code" in e && e.code === "ECONNREFUSED") 69 | return console.error("Failed to connect to redis", e.message); 70 | 71 | console.error(e); 72 | }); 73 | 74 | (async () => { 75 | await initDatabase(); 76 | await initRabbitMQ(false); 77 | 78 | if (!config.rabbitmq.enabled) 79 | console.error( 80 | "rabbitmq isn't configured. this worker won't be able to emit gateway events", 81 | ); 82 | 83 | await worker.run(); 84 | })(); 85 | -------------------------------------------------------------------------------- /src/scripts/stress/users.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * makes a whole bunch of users in a guild 3 | */ 4 | 5 | import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi"; 6 | import { z } from "zod"; 7 | 8 | extendZodWithOpenApi(z); 9 | 10 | import { Guild } from "../../entity/guild"; 11 | import { User } from "../../entity/user"; 12 | import { config } from "../../util/config"; 13 | import { closeDatabase, initDatabase } from "../../util/database"; 14 | import { joinGuild } from "../../util/entity/guild"; 15 | 16 | const GUILD_ID = "01977599-db47-7058-a177-97e6f3e1ea7c"; 17 | (async () => { 18 | await initDatabase(); 19 | 20 | const guild = await Guild.findOneOrFail({ where: { id: GUILD_ID } }); 21 | 22 | for (let i = 0; i < 1000; i++) { 23 | console.log(i); 24 | 25 | const name = `test_${i}_${Date.now()}`; 26 | 27 | const user = await User.create({ 28 | name, 29 | email: "", 30 | password_hash: "test", 31 | public_key: "", // The key has yet to be generated. 32 | 33 | display_name: name, 34 | valid_tokens_since: new Date(), 35 | domain: config.federation.webapp_url.hostname, 36 | }).save(); 37 | 38 | await joinGuild(user.mention, guild.mention); 39 | } 40 | 41 | closeDatabase(); 42 | })(); 43 | -------------------------------------------------------------------------------- /src/sender/index.ts: -------------------------------------------------------------------------------- 1 | import type { APActivity } from "activitypub-types"; 2 | import type { Actor } from "../entity/actor"; 3 | import { Channel } from "../entity/channel"; 4 | import { Guild } from "../entity/guild"; 5 | import { User } from "../entity/user"; 6 | import { APError } from "../util/activitypub/error"; 7 | import { signWithHttpSignature } from "../util/activitypub/httpsig"; 8 | import { InstanceActor } from "../util/activitypub/instanceActor"; 9 | import { createLogger } from "../util/log"; 10 | 11 | const Log = createLogger("ap:distribute"); 12 | 13 | export const sendActivity = async ( 14 | targets: Actor | Actor[], 15 | activity: APActivity, 16 | sender: Actor = InstanceActor, 17 | ) => { 18 | targets = Array.isArray(targets) ? targets : [targets]; 19 | 20 | const inboxes = targets.reduce>((ret, target) => { 21 | const shared_inbox = target.collections?.shared_inbox; 22 | const inbox = target.collections?.inbox; 23 | 24 | // If the shared inbox doesn't exist, add the inbox 25 | if (!shared_inbox && inbox) ret.add(inbox); 26 | // If we have a shared inbox, and this activity is going to another actor with the same shared inbox, use that 27 | else if ( 28 | shared_inbox && 29 | targets.filter((x) => x.collections?.shared_inbox === shared_inbox) 30 | .length > 1 31 | ) 32 | ret.add(shared_inbox); 33 | // otherwise, just add the inbox 34 | else if (inbox) ret.add(inbox); 35 | 36 | return ret; 37 | }, new Set()); 38 | 39 | for (const inbox of inboxes) { 40 | const signed = signWithHttpSignature( 41 | inbox, 42 | "POST", 43 | sender, 44 | JSON.stringify(activity), 45 | ); 46 | 47 | const res = await fetch(inbox, signed); 48 | if (!res.ok) { 49 | Log.error( 50 | `Sending activity ${activity.id} to ${inbox} failed : ${res.status} ${await res.text()}`, 51 | ); 52 | } else 53 | Log.verbose( 54 | `Sent activity ${activity.id} got response`, 55 | await res.text(), 56 | ); 57 | } 58 | }; 59 | 60 | export const getExternalPathFromActor = (actor: Actor) => { 61 | if (actor.id === InstanceActor.id) return "/actor"; 62 | if (actor instanceof Channel) return `/channel/${actor.id}`; 63 | if (actor instanceof User) return `/users/${actor.name}`; 64 | if (actor instanceof Guild) return `/guild/${actor.id}`; 65 | throw new APError("unknown actor type"); 66 | }; 67 | -------------------------------------------------------------------------------- /src/util/activitypub/constants.ts: -------------------------------------------------------------------------------- 1 | import z from "zod"; 2 | import { config } from "../config"; 3 | 4 | export const ACTIVITYSTREAMS_CONTEXT = "https://www.w3.org/ns/activitystreams"; 5 | 6 | export const ACTIVITY_JSON_ACCEPT = [ 7 | 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', 8 | "application/ld+json", // body parser doesn't like profile... bug? 9 | "application/activity+json", 10 | ]; 11 | 12 | export const USER_AGENT = `Shoot (https://github.com/maddyunderstars/shoot; +${config.federation.webapp_url.origin})`; 13 | 14 | export const ACTIVITYPUB_FETCH_OPTS: RequestInit = { 15 | headers: { 16 | Accept: "application/activity+json", 17 | "Content-Type": "application/activity+json", 18 | "User-Agent": USER_AGENT, 19 | }, 20 | 21 | redirect: "follow", 22 | }; 23 | 24 | export const ActorMentionRegex = /^.*@.*$/; 25 | 26 | export const ActorMention = z 27 | .custom<`${string}@${string}`>( 28 | (val) => typeof val === "string" && val.match(ActorMentionRegex), 29 | { 30 | message: "Invalid mention", 31 | }, 32 | ) 33 | .openapi("ActorMention", { 34 | type: "string", 35 | pattern: ActorMentionRegex.source, 36 | }); 37 | 38 | export type ActorMention = z.infer; 39 | 40 | interface WebfingerLink { 41 | rel: string; 42 | type?: string; 43 | href?: string; 44 | template?: string; 45 | } 46 | 47 | export interface WebfingerResponse { 48 | subject: string; 49 | aliases: string[]; 50 | links: WebfingerLink[]; 51 | } 52 | 53 | export const WebfingerResponse: z.ZodType = z.object({ 54 | subject: z.string(), 55 | aliases: z.string().array(), 56 | links: z 57 | .object({ 58 | rel: z.string(), 59 | type: z.string().optional(), 60 | href: z.string().optional(), 61 | template: z.string().optional(), 62 | }) 63 | .array(), 64 | }); 65 | -------------------------------------------------------------------------------- /src/util/activitypub/error.ts: -------------------------------------------------------------------------------- 1 | import { HttpError } from "../httperror"; 2 | 3 | export class APError extends HttpError { 4 | public remoteResponse: unknown; 5 | 6 | constructor(message: string, code = 400, remoteResponse?: unknown) { 7 | super(message, code); 8 | this.remoteResponse = remoteResponse; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/util/activitypub/inbox/handlers/announce.ts: -------------------------------------------------------------------------------- 1 | import { ObjectIsNote } from "activitypub-types"; 2 | import { User } from "../../../../entity/user"; 3 | import { getOrFetchChannel } from "../../../entity/channel"; 4 | import { handleMessage } from "../../../entity/message"; 5 | import { APError } from "../../error"; 6 | import { resolveAPObject, resolveId, resolveUrlOrObject } from "../../resolve"; 7 | import { buildMessageFromAPNote } from "../../transformers/message"; 8 | import type { ActivityHandler } from "."; 9 | 10 | /** 11 | * Channels Announce to Users to send notification of new messages 12 | * Saves these messages to db to the actor channel 13 | */ 14 | export const AnnounceActivityHandler: ActivityHandler = async ( 15 | activity, 16 | target, 17 | ) => { 18 | if (!(target instanceof User)) 19 | throw new APError("Cannot Announce to target other than User"); 20 | 21 | if (!activity.object) 22 | throw new APError( 23 | "Announce activity does not contain `object`. What are we announcing?", 24 | ); 25 | 26 | if (Array.isArray(activity.object)) 27 | throw new APError("Cannot accept Announce with multiple `object`s"); 28 | 29 | const inner = await resolveAPObject(resolveUrlOrObject(activity.object)); 30 | 31 | if (!ObjectIsNote(inner)) 32 | throw new APError(`Cannot accept Announce<${inner.type}>`); 33 | 34 | if (!activity.actor) 35 | throw new APError("Cannot accept Announce without `actor`."); 36 | 37 | if (Array.isArray(activity.actor)) 38 | throw new APError("Cannot accept Announce with multiple `actor`s"); 39 | 40 | if (typeof activity.actor !== "string") 41 | throw new APError("Cannot accept Announce with non-string actor"); 42 | 43 | const channel = await getOrFetchChannel(resolveId(activity.actor)); 44 | 45 | const message = await buildMessageFromAPNote(inner, channel); 46 | 47 | await handleMessage(message, false); 48 | }; 49 | -------------------------------------------------------------------------------- /src/util/activitypub/inbox/handlers/index.ts: -------------------------------------------------------------------------------- 1 | import type { APActivity } from "activitypub-types"; 2 | import type { Actor } from "../../../../entity/actor"; 3 | import { AcceptActivityHandler } from "./accept"; 4 | import { AnnounceActivityHandler } from "./announce"; 5 | import { CreateActivityHandler } from "./create"; 6 | import { FollowActivityHandler } from "./follow"; 7 | import { JoinActivityHandler } from "./join"; 8 | import { LikeActivityHandler } from "./like"; 9 | import { UndoActivityHandler } from "./undo"; 10 | 11 | export type ActivityHandler = ( 12 | activity: APActivity, 13 | target: Actor, 14 | ) => Promise; 15 | 16 | export const ActivityHandlers: { [key: Lowercase]: ActivityHandler } = { 17 | create: CreateActivityHandler, 18 | announce: AnnounceActivityHandler, 19 | follow: FollowActivityHandler, 20 | undo: UndoActivityHandler, 21 | accept: AcceptActivityHandler, 22 | join: JoinActivityHandler, 23 | 24 | // this one is only to pass verify.funfedi.dev 25 | like: LikeActivityHandler, 26 | // EchoRequest will not be implemented as the activity doesn't have an 27 | // ID, which means I'd have to change some of the validation logic just for a 28 | // test site. no 29 | }; 30 | -------------------------------------------------------------------------------- /src/util/activitypub/inbox/handlers/join.ts: -------------------------------------------------------------------------------- 1 | import type { APAccept } from "activitypub-types"; 2 | import { Channel } from "../../../../entity/channel"; 3 | import { getExternalPathFromActor, sendActivity } from "../../../../sender"; 4 | import { config } from "../../../config"; 5 | import { getOrFetchUser } from "../../../entity/user"; 6 | import { PERMISSION } from "../../../permission"; 7 | import { makeInstanceUrl } from "../../../url"; 8 | import { generateMediaToken } from "../../../voice"; 9 | import { APError } from "../../error"; 10 | import { resolveId } from "../../resolve"; 11 | import { addContext } from "../../util"; 12 | import type { ActivityHandler } from "."; 13 | 14 | export const JoinActivityHandler: ActivityHandler = async ( 15 | activity, 16 | target, 17 | ) => { 18 | if (!(target instanceof Channel)) 19 | throw new APError("Join at non-channel target is not supported"); 20 | 21 | if (!activity.actor) throw new APError("Who is actor?"); 22 | 23 | if (typeof activity.actor !== "string") 24 | throw new APError("Actor must be string"); 25 | 26 | if (Array.isArray(activity.actor)) 27 | throw new APError("Don't know how to handle multiple `actor`s"); 28 | 29 | if (!activity.object) throw new APError("What are you joining?"); 30 | 31 | if (Array.isArray(activity.object)) 32 | throw new APError("Cannot accept multiple objects"); 33 | 34 | if (makeInstanceUrl(getExternalPathFromActor(target)) !== activity.object) 35 | throw new APError("Object and target mismatch?"); 36 | 37 | const user = await getOrFetchUser(resolveId(activity.actor)); 38 | 39 | await target.throwPermission(user, [ 40 | PERMISSION.VIEW_CHANNEL, 41 | PERMISSION.CALL_CHANNEL, 42 | ]); 43 | 44 | const token = await generateMediaToken(user.mention, target.id); 45 | 46 | const accept: APAccept = addContext({ 47 | type: "Accept", 48 | result: token, 49 | target: config.webrtc.signal_address, 50 | actor: makeInstanceUrl(getExternalPathFromActor(target)), 51 | object: activity, 52 | }); 53 | 54 | await sendActivity(user, accept, target); 55 | }; 56 | -------------------------------------------------------------------------------- /src/util/activitypub/inbox/handlers/like.ts: -------------------------------------------------------------------------------- 1 | import type { ActivityHandler } from "."; 2 | 3 | // Only here to pass verify.funfedi.dev 4 | export const LikeActivityHandler: ActivityHandler = async ( 5 | _activity, 6 | _target, 7 | ) => {}; 8 | -------------------------------------------------------------------------------- /src/util/activitypub/inbox/handlers/undo.ts: -------------------------------------------------------------------------------- 1 | import { ActivityIsFollow } from "activitypub-types"; 2 | import { APError } from "../../error"; 3 | import { resolveAPObject, resolveUrlOrObject } from "../../resolve"; 4 | import type { ActivityHandler } from "."; 5 | 6 | export const UndoActivityHandler: ActivityHandler = async ( 7 | activity, 8 | _target, 9 | ) => { 10 | if (!activity.object) throw new APError("What are you undoing?"); 11 | if (Array.isArray(activity.object)) 12 | throw new APError("Don't know how to undo multiple objects"); 13 | 14 | const inner = await resolveAPObject(resolveUrlOrObject(activity.object)); 15 | 16 | if (!ActivityIsFollow(inner)) 17 | throw new APError("only know how to undo follow"); 18 | 19 | // TODO: undo the follow 20 | }; 21 | -------------------------------------------------------------------------------- /src/util/activitypub/inbox/index.ts: -------------------------------------------------------------------------------- 1 | import type { APActivity } from "activitypub-types"; 2 | import { Queue } from "bullmq"; 3 | import { z } from "zod"; 4 | import type { Actor } from "../../../entity/actor"; 5 | import { ApCache } from "../../../entity/apcache"; 6 | import type { APInboundJobData } from "../../../receiver"; 7 | import { config } from "../../config"; 8 | import { APError } from "../error"; 9 | import { ActivityHandlers } from "./handlers"; 10 | 11 | const getQueue = () => { 12 | return config.federation.queue.use_inbound 13 | ? new Queue("inbound", { 14 | connection: { 15 | host: config.redis.host, 16 | port: config.redis.port, 17 | }, 18 | }) 19 | : null; 20 | }; 21 | 22 | export const AP_ACTIVITY = z 23 | .object({ 24 | id: z.string().url(), 25 | type: z 26 | .string() 27 | .refine( 28 | (type) => 29 | !!ActivityHandlers[type.toLowerCase() as Lowercase], 30 | { message: "Activity of that type has no handler" }, 31 | ), 32 | }) 33 | .passthrough(); 34 | 35 | export const handleInbox = async (activity: APActivity, target: Actor) => { 36 | activity["@context"] = undefined; 37 | 38 | const safeActivity = AP_ACTIVITY.parse(activity); 39 | 40 | const queue = getQueue(); 41 | if (queue) 42 | await queue.add(`${safeActivity.type}-${target.id}-${Date.now()}`, { 43 | activity: safeActivity, 44 | target_id: target.id, 45 | }); 46 | else { 47 | try { 48 | await ApCache.insert({ 49 | id: safeActivity.id, 50 | raw: safeActivity, 51 | }); 52 | } catch (_) { 53 | throw new APError( 54 | `Activity with id ${safeActivity.id} already processed`, 55 | ); 56 | } 57 | 58 | await ActivityHandlers[ 59 | safeActivity.type.toLowerCase() as Lowercase 60 | ](activity, target); 61 | } 62 | }; 63 | -------------------------------------------------------------------------------- /src/util/activitypub/instanceActor.ts: -------------------------------------------------------------------------------- 1 | import type { User } from "../../entity/user"; 2 | import { config } from "../config"; 3 | import { makeInstanceUrl } from "../url"; 4 | 5 | export const InstanceActor = Object.freeze({ 6 | id: "actor", 7 | display_name: config.federation.webapp_url.hostname, 8 | name: config.federation.webapp_url.hostname, 9 | domain: config.federation.webapp_url.hostname, 10 | public_key: config.federation.public_key, 11 | private_key: config.federation.private_key, 12 | collections: { 13 | followers: makeInstanceUrl("/actor/followers"), 14 | following: makeInstanceUrl("/actor/following"), 15 | inbox: makeInstanceUrl("/actor/inbox"), 16 | outbox: makeInstanceUrl("/actor/outbox"), 17 | }, 18 | created_date: new Date(), 19 | }) as User; 20 | -------------------------------------------------------------------------------- /src/util/activitypub/instanceBehaviour.ts: -------------------------------------------------------------------------------- 1 | export enum InstanceBehaviour { 2 | /** allow all content */ 3 | ALLOW = "allow", 4 | 5 | /** 6 | * only allow content if the remote user has a relationship with a local user 7 | * force remote users to be approved by guild before joining (force-knockjoin) 8 | * 9 | * e.g. a limited remote user can only send us dms if we are friends. 10 | * they can send a friend request only if we share a guild. 11 | * they can only join a guild by being accepted by a user in the guild with permission (knocking) 12 | */ 13 | LIMIT = "limit", 14 | 15 | /** 16 | * block all content from this instance, 17 | * and block this instance from getting our (http sig required) content 18 | */ 19 | BLOCK = "block", 20 | } 21 | -------------------------------------------------------------------------------- /src/util/activitypub/instances.ts: -------------------------------------------------------------------------------- 1 | import { config } from "../config"; 2 | import { InstanceBehaviour } from "./instanceBehaviour"; 3 | 4 | export class InstanceBlockedError extends Error { 5 | name = "InstanceBlockedError"; 6 | } 7 | 8 | /** 9 | * Throw if instance is marked as blocked via config 10 | */ 11 | export const throwInstanceBlock = (instance: URL) => { 12 | if ( 13 | config.federation.instances[instance.hostname] === 14 | InstanceBehaviour.BLOCK || 15 | (config.federation.allowlist && 16 | config.federation.instances[instance.hostname] === 17 | InstanceBehaviour.ALLOW) 18 | ) 19 | // this is caught by our error handler and times the connection out 20 | throw new InstanceBlockedError(); 21 | }; 22 | 23 | /** Return whether or not this instance is limited */ 24 | export const instanceIsLimited = (instance: URL) => { 25 | return ( 26 | config.federation.instances[instance.hostname] === 27 | InstanceBehaviour.LIMIT 28 | ); 29 | }; 30 | 31 | // TODO: when an instance is blocked, should content that includes the blocked content be allowed? 32 | // e.g. someone boosts a post from a blocked instance, should I see the boost? 33 | // export const shouldAllowActivity = async ( 34 | // instance: URL, 35 | // activity: APActivity, 36 | // ) => { 37 | // throwInstanceBlock(instance); 38 | // if (!activity.id) throw new APError("Activity must have ID"); 39 | // throwInstanceBlock(new URL(activity.id)); 40 | // const checkBlock = (key: keyof APActivity) => { 41 | // if (!(key in activity && activity[key])) return true; 42 | // let id: string; 43 | // if (typeof activity[key] === "string") id = activity[key]; 44 | // else if ("id" in activity[key] && typeof activity[key].id === "string") 45 | // id = activity[key].id; 46 | // else return true; 47 | // throwInstanceBlock(new URL(id)); 48 | // }; 49 | // checkBlock("object"); 50 | // checkBlock("actor"); 51 | // checkBlock("") 52 | // }; 53 | -------------------------------------------------------------------------------- /src/util/activitypub/orderedCollection.ts: -------------------------------------------------------------------------------- 1 | import type { AnyAPObject, APOrderedCollectionPage } from "activitypub-types"; 2 | import type { ObjectType, SelectQueryBuilder } from "typeorm"; 3 | import { buildPaginator } from "typeorm-cursor-pagination"; 4 | import type { BaseModel } from "../../entity/basemodel"; 5 | import { addContext } from "./util"; 6 | 7 | type Props = { 8 | entity: ObjectType; 9 | qb: SelectQueryBuilder; 10 | keys?: Extract[]; 11 | id: string; 12 | convert: (data: T) => string | AnyAPObject; 13 | before?: string; 14 | after?: string; 15 | }; 16 | 17 | export const orderedCollectionHandler = async ( 18 | props: Props, 19 | ): Promise => { 20 | const { qb, entity, id, convert } = props; 21 | 22 | const paginator = buildPaginator({ 23 | entity, 24 | paginationKeys: props.keys 25 | ? props.keys 26 | : (["id"] as Extract), 27 | query: { 28 | limit: 50, 29 | order: "ASC", 30 | afterCursor: "after" in props ? props.after : undefined, 31 | beforeCursor: "before" in props ? props.before : undefined, 32 | }, 33 | }); 34 | 35 | const { data, cursor } = await paginator.paginate(qb); 36 | 37 | let next: URL | undefined; 38 | if (cursor.afterCursor) { 39 | next = new URL(id); 40 | next.searchParams.set("after", cursor.afterCursor); 41 | next.searchParams.delete("before"); 42 | } 43 | 44 | let prev: URL | undefined; 45 | if (cursor.beforeCursor) { 46 | prev = new URL(id); 47 | prev.searchParams.set("before", cursor.beforeCursor); 48 | prev.searchParams.delete("after"); 49 | } 50 | 51 | return addContext({ 52 | id: id.toString(), 53 | 54 | type: "OrderedCollection", 55 | next: next ? next.toString() : undefined, 56 | prev: prev ? prev.toString() : undefined, 57 | 58 | totalItems: await qb.getCount(), 59 | items: (data as T[]).map(convert), 60 | }); 61 | }; 62 | -------------------------------------------------------------------------------- /src/util/activitypub/transformers/invite.ts: -------------------------------------------------------------------------------- 1 | import type { APObject } from "activitypub-types"; 2 | import type { Invite } from "../../../entity/invite"; 3 | import { makeInstanceUrl } from "../../url"; 4 | import { buildAPActor } from "./actor"; 5 | 6 | export type APGuildInvite = APObject & { type: "GuildInvite" }; 7 | 8 | export const buildAPGuildInvite = (invite: Invite): APGuildInvite => { 9 | return { 10 | type: "GuildInvite", 11 | id: makeInstanceUrl(`/invite/${invite.code}`), 12 | attributedTo: buildAPActor(invite.guild), 13 | }; 14 | }; 15 | -------------------------------------------------------------------------------- /src/util/activitypub/transformers/message.ts: -------------------------------------------------------------------------------- 1 | import type { APAnnounce, APCreate, APNote } from "activitypub-types"; 2 | import { ApCache } from "../../../entity/apcache"; 3 | import type { Channel } from "../../../entity/channel"; 4 | import { Message } from "../../../entity/message"; 5 | import { getExternalPathFromActor } from "../../../sender"; 6 | import { getOrFetchAttributedUser } from "../../entity/user"; 7 | import { makeInstanceUrl, makeWebappUrl } from "../../url"; 8 | 9 | export const buildMessageFromAPNote = async ( 10 | note: APNote, 11 | channel: Channel, 12 | ): Promise => { 13 | const author = await getOrFetchAttributedUser(note.attributedTo); 14 | await author.save(); 15 | 16 | return Message.create({ 17 | author, 18 | channel, 19 | reference_object: ApCache.create({ id: note.id, raw: note }), 20 | 21 | content: note.content, 22 | }); 23 | }; 24 | 25 | export const buildAPNote = (message: Message): APNote => { 26 | const id = makeInstanceUrl( 27 | `/channel/${message.channel.id}/message/${message.id}`, 28 | ); 29 | const attributedTo = makeInstanceUrl( 30 | `${getExternalPathFromActor(message.author)}`, 31 | ); 32 | const to = makeInstanceUrl(`${getExternalPathFromActor(message.channel)}`); 33 | 34 | return { 35 | type: "Note", 36 | id, 37 | published: message.published, 38 | attributedTo, 39 | to: [to, `${attributedTo}/followers`], 40 | cc: [ 41 | // "https://www.w3.org/ns/activitystreams#Public" 42 | ], 43 | content: message.content ?? undefined, 44 | updated: message.updated ?? undefined, 45 | summary: "", 46 | url: makeWebappUrl( 47 | `/channel/${message.channel.id}/message/${message.id}`, 48 | ), 49 | audience: makeInstanceUrl(getExternalPathFromActor(message.channel)), 50 | }; 51 | }; 52 | 53 | export const buildAPCreateNote = (inner: APNote): APCreate => { 54 | return { 55 | type: "Create", 56 | id: `${inner.id}/create`, 57 | actor: inner.attributedTo, 58 | to: inner.to, 59 | cc: inner.cc, 60 | object: inner, 61 | }; 62 | }; 63 | 64 | export const buildAPAnnounceNote = ( 65 | inner: APNote, 66 | channel_id: string, 67 | ): APAnnounce => { 68 | const actor = makeInstanceUrl(`/channel/${channel_id}`); 69 | // TODO: this should be channel_id followers 70 | 71 | return { 72 | id: new URL( 73 | `/message/${inner.id?.split("/").reverse()[0]}/announce`, 74 | actor, 75 | ).toString(), // TODO 76 | type: "Announce", 77 | actor, 78 | published: inner.published, 79 | 80 | to: inner.to, 81 | cc: inner.cc, 82 | 83 | object: inner, 84 | }; 85 | }; 86 | -------------------------------------------------------------------------------- /src/util/activitypub/transformers/role.ts: -------------------------------------------------------------------------------- 1 | import type { APObject } from "activitypub-types"; 2 | import type { Role } from "../../../entity/role"; 3 | import { getExternalPathFromActor } from "../../../sender"; 4 | import type { PERMISSION } from "../../permission"; 5 | import { makeInstanceUrl } from "../../url"; 6 | 7 | export const ObjectIsRole = (role: APObject): role is APRole => 8 | role.type === "Role"; 9 | 10 | export type APRole = APObject & { 11 | type: "Role"; 12 | members: string; 13 | name: string; 14 | allow: PERMISSION[]; 15 | deny: PERMISSION[]; 16 | position: number; 17 | }; 18 | 19 | export const buildAPRole = (role: Role): APRole => { 20 | const id = makeInstanceUrl( 21 | `${getExternalPathFromActor(role.guild)}/role/${role.id}`, 22 | ); 23 | 24 | return { 25 | type: "Role", 26 | id, 27 | 28 | name: role.name, 29 | 30 | allow: role.allow, 31 | deny: role.deny, 32 | 33 | position: role.position, 34 | 35 | attributedTo: makeInstanceUrl(getExternalPathFromActor(role.guild)), 36 | members: `${id}/members`, 37 | }; 38 | }; 39 | -------------------------------------------------------------------------------- /src/util/activitypub/util.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type AnyAPObject, 3 | type APActivity, 4 | type APActor, 5 | type APObject, 6 | type ContextField, 7 | ObjectIsApplication, 8 | ObjectIsGroup, 9 | ObjectIsPerson, 10 | } from "activitypub-types"; 11 | import { tryParseUrl } from "../url"; 12 | import { ACTIVITYSTREAMS_CONTEXT } from "./constants"; 13 | import { APError } from "./error"; 14 | 15 | /** 16 | * Split a string or URL into the domain and user parts. For URLs, this is NOT the username auth part 17 | * @param lookup Either an ActorMention or URL string 18 | * @returns Domain and user parts of input 19 | */ 20 | export const splitQualifiedMention = (lookup: string | URL) => { 21 | let domain: string; 22 | let id: string; 23 | if (typeof lookup === "string" && lookup.includes("@")) { 24 | // lookup a @handle@domain 25 | if (lookup[0] === "@") lookup = lookup.slice(1); 26 | [id, domain] = lookup.split("@"); 27 | } else { 28 | // lookup was a URL ( hopefully ) 29 | 30 | const url = tryParseUrl(lookup); 31 | if (!url) { 32 | throw new APError("Lookup is not valid handle or URL"); 33 | } 34 | 35 | domain = url.hostname; 36 | id = url.pathname.split("/").reverse()[0]; // not great 37 | } 38 | 39 | return { 40 | domain, 41 | id, 42 | }; 43 | }; 44 | 45 | export const hasAPContext = (data: object): data is APObject => { 46 | if (!("@context" in data)) return false; 47 | const context = data["@context"] as ContextField | ContextField[]; 48 | if (Array.isArray(context)) 49 | return !!context.find((x) => x === ACTIVITYSTREAMS_CONTEXT); 50 | return context === ACTIVITYSTREAMS_CONTEXT; 51 | }; 52 | 53 | export const APObjectIsActor = (obj: AnyAPObject): obj is APActor => { 54 | return ( 55 | ObjectIsPerson(obj) || ObjectIsApplication(obj) || ObjectIsGroup(obj) 56 | ); 57 | }; 58 | 59 | export const addContext = ( 60 | obj: T, 61 | ): T & { "@context": ContextField[] } => { 62 | // For some reason if I move this into the return, it causes a type error 63 | // even though ContextField is string | Record ??? 64 | const context: ContextField[] = [ 65 | "https://www.w3.org/ns/activitystreams", 66 | "https://w3id.org/security/v1", 67 | "https://purl.archive.org/socialweb/webfinger", 68 | { 69 | manuallyApprovesFollowers: "as:manuallyApprovesFollowers", 70 | }, 71 | ]; 72 | 73 | return { 74 | "@context": context, 75 | ...obj, 76 | }; 77 | }; 78 | -------------------------------------------------------------------------------- /src/util/checkPermission.ts: -------------------------------------------------------------------------------- 1 | import type { Guild } from "../entity/guild"; 2 | import { Member } from "../entity/member"; 3 | import { Role } from "../entity/role"; 4 | import type { User } from "../entity/user"; 5 | import { getDatabase } from "./database"; 6 | import { PERMISSION } from "./permission"; 7 | 8 | export const checkPermission = async ( 9 | user: User, 10 | guild: Guild, 11 | permission: PERMISSION | PERMISSION[], 12 | ) => { 13 | permission = Array.isArray(permission) ? permission : [permission]; 14 | 15 | if (guild.owner.id === user.id) return true; // we're the owner, all perms 16 | if (permission.includes(PERMISSION.OWNER)) return false; // we're not owner, and requesting owner perms 17 | 18 | const roles = ( 19 | await getDatabase() 20 | .getRepository(Role) 21 | .createQueryBuilder("roles") 22 | .leftJoin("roles.members", "members") 23 | .where("roles.guildId = :guild_id", { guild_id: guild.id }) 24 | .andWhere((qb) => { 25 | const sub = qb 26 | .subQuery() 27 | .select("id") 28 | .from(Member, "members") 29 | .where("members.userId = :user_id", { user_id: user.id }) 30 | .getQuery(); 31 | 32 | qb.where(`roles_members.guildMembersId in ${sub}`); 33 | }) 34 | .getMany() 35 | ).sort((a, b) => a.position - b.position); 36 | 37 | let allowed = false; 38 | // for every role in order 39 | for (const role of roles) { 40 | // this role has admin, allow it 41 | if (role.allow.includes(PERMISSION.ADMIN)) return true; 42 | 43 | // if every requested permission is allowed in this role, we're good 44 | if (permission.every((x) => role.allow.includes(x))) allowed = true; 45 | // if one of them is denied, we're not good 46 | if (permission.find((x) => role.deny.includes(x))) allowed = false; 47 | // if it's neutral, we just use the last set value 48 | } 49 | 50 | return allowed; 51 | }; 52 | -------------------------------------------------------------------------------- /src/util/database.ts: -------------------------------------------------------------------------------- 1 | import type { DataSource } from "typeorm"; 2 | import { Migration } from "../entity/migrations"; 3 | import { config } from "./config"; 4 | import DATASOURCE_OPTIONS from "./datasource"; 5 | import { createLogger } from "./log"; 6 | 7 | const Log = createLogger("database"); 8 | 9 | const CONNECTION_STRING = config.database.url; 10 | 11 | let connection: DataSource | null = null; 12 | let initCalled: Promise | null = null; 13 | 14 | export const initDatabase = async () => { 15 | if (connection) return connection; 16 | if (initCalled) return await initCalled; 17 | 18 | Log.msg(`Connecting to ${CONNECTION_STRING}`); 19 | 20 | try { 21 | initCalled = DATASOURCE_OPTIONS.initialize(); 22 | connection = await initCalled; 23 | } catch (e) { 24 | Log.error(e instanceof Error ? e.message : e); 25 | process.exit(); 26 | } 27 | await doFirstSync(); 28 | 29 | Log.msg("Connected"); 30 | 31 | return connection; 32 | }; 33 | 34 | export const getDatabase = () => { 35 | if (!connection) throw Error("Tried accessing database before connecting"); 36 | return connection; 37 | }; 38 | 39 | export const closeDatabase = () => { 40 | connection?.destroy(); 41 | connection = null; 42 | initCalled = null; 43 | }; 44 | 45 | const doFirstSync = async () => { 46 | if (!connection) return; // not possible 47 | 48 | if (!(await dbExists())) { 49 | Log.msg("This appears to be a fresh database. Synchronising."); 50 | await connection.synchronize(); 51 | 52 | // On next start, typeorm will try to run all the migrations again from beginning. 53 | // Manually insert every current migration to prevent this: 54 | await Promise.all( 55 | connection.migrations.map((migration) => 56 | Migration.insert({ 57 | name: migration.name, 58 | timestamp: Date.now(), 59 | }), 60 | ), 61 | ); 62 | } else { 63 | Log.msg("Applying missing migrations, if any."); 64 | await connection.runMigrations(); 65 | } 66 | }; 67 | 68 | const dbExists = async () => { 69 | try { 70 | await Migration.count(); 71 | return true; 72 | } catch (_) { 73 | return false; 74 | } 75 | }; 76 | -------------------------------------------------------------------------------- /src/util/datasource.ts: -------------------------------------------------------------------------------- 1 | // have to call this here for typeorm cli 2 | import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi"; 3 | import { z } from "zod"; 4 | 5 | extendZodWithOpenApi(z); 6 | 7 | import path from "node:path"; 8 | import { DataSource } from "typeorm"; 9 | import { config } from "./config"; 10 | 11 | const CONNECTION_STRING = config.database.url; 12 | const CONNECTION_TYPE = CONNECTION_STRING.replace( 13 | // standardise so our migrations folder works 14 | "postgresql://", 15 | "postgres://", 16 | ) 17 | .split("://")?.[0] 18 | ?.replace("+src", ""); 19 | const IS_SQLITE = CONNECTION_TYPE === "sqlite"; 20 | 21 | const DATASOURCE_OPTIONS = new DataSource({ 22 | //@ts-expect-error 23 | type: CONNECTION_TYPE, 24 | url: IS_SQLITE ? undefined : CONNECTION_STRING, 25 | database: IS_SQLITE ? CONNECTION_STRING.split("://")[1] : undefined, 26 | supportBigNumbers: true, 27 | bigNumberStrings: false, 28 | synchronize: false, // TODO 29 | logging: config.database.log, 30 | 31 | // these reference js files because they are done at runtime, and we compile 32 | // it'll break if you run Shoot under ts-node or tsx or whatever 33 | entities: [path.join(__dirname, "..", "entity", "*.js")], 34 | migrations: [path.join(__dirname, "migration", CONNECTION_TYPE, "*.js")], 35 | }); 36 | 37 | export default DATASOURCE_OPTIONS; 38 | -------------------------------------------------------------------------------- /src/util/embeds/generators/generic.ts: -------------------------------------------------------------------------------- 1 | import { Embed, EmbedTypes as EmbedType } from "../../../entity/embed"; 2 | import { 3 | fetchDom, 4 | findDomTag, 5 | findMeta, 6 | getImageMetadata, 7 | getImageProxyUrl, 8 | tryParseNumber, 9 | } from ".."; 10 | import type { EMBED_GENERATOR } from "."; 11 | 12 | export const genericEmbedGenerator: EMBED_GENERATOR = async (url) => { 13 | const doc = await fetchDom(url); 14 | 15 | const image_url = 16 | findMeta(doc, "og:image") ?? 17 | findMeta(doc, "og:image:url") ?? 18 | findMeta(doc, "og:image:secure_url"); 19 | 20 | const width = tryParseNumber(findMeta(doc, "og:image:width")); 21 | const height = tryParseNumber(findMeta(doc, "og:image:height")); 22 | 23 | const imageMeta = image_url 24 | ? await getImageMetadata( 25 | new URL(image_url), 26 | Math.min(width ?? 1000, 1000), 27 | Math.min(height ?? 1000, 1000), 28 | ) 29 | : undefined; 30 | 31 | // also check oembed? 32 | 33 | return Embed.create({ 34 | target: url.href, 35 | type: EmbedType.rich, 36 | 37 | title: 38 | findMeta(doc, "og:title") ?? 39 | findMeta(doc, "twitter:title") ?? 40 | findDomTag(doc, "title"), 41 | description: 42 | findMeta(doc, "og:description") ?? findMeta(doc, "description"), 43 | 44 | author_name: 45 | findMeta(doc, "article:author") ?? 46 | findMeta(doc, "book:author") ?? 47 | findMeta(doc, "twitter:creator"), 48 | 49 | provider_name: findMeta(doc, "og:site_name") ?? url.hostname, 50 | provider_url: url.origin, 51 | 52 | images: image_url 53 | ? [ 54 | { 55 | url: getImageProxyUrl( 56 | new URL(image_url), 57 | Math.min(width ?? 1000, 1000), 58 | Math.min(height ?? 1000, 1000), 59 | ).href, 60 | 61 | // todo: use imagor to find these as fallback 62 | width: imageMeta?.width, 63 | height: imageMeta?.height, 64 | 65 | alt: findMeta(doc, "og:image:alt") ?? undefined, 66 | }, 67 | ] 68 | : [], 69 | }); 70 | }; 71 | -------------------------------------------------------------------------------- /src/util/embeds/generators/index.ts: -------------------------------------------------------------------------------- 1 | import type { Embed } from "../../../entity/embed"; 2 | import { genericEmbedGenerator } from "./generic"; 3 | import { simpleEmbedGenerator } from "./simple"; 4 | 5 | export type EMBED_GENERATOR = (url: URL, head: Response) => Promise; 6 | 7 | export const EMBED_GENERATORS = { 8 | simple: simpleEmbedGenerator, 9 | generic: genericEmbedGenerator, 10 | } as Record; 11 | -------------------------------------------------------------------------------- /src/util/embeds/generators/simple.ts: -------------------------------------------------------------------------------- 1 | import { Embed, EmbedTypes } from "../../../entity/embed"; 2 | import { getImageMetadata, getImageProxyUrl } from ".."; 3 | import type { EMBED_GENERATOR } from "."; 4 | 5 | export const simpleEmbedGenerator: EMBED_GENERATOR = async (url, head) => { 6 | const types = { 7 | "image/": EmbedTypes.photo, 8 | "video/": EmbedTypes.video, 9 | }; 10 | 11 | const type = 12 | Object.entries(types).find(([key]) => 13 | head.headers.get("Content-Type")?.startsWith(key), 14 | )?.[1] ?? EmbedTypes.link; 15 | 16 | const meta = await getImageMetadata(url, 1000, 1000); 17 | 18 | return Embed.create({ 19 | target: url.href, 20 | type, 21 | 22 | [type === EmbedTypes.photo ? "images" : "videos"]: [ 23 | { 24 | url: getImageProxyUrl(url, meta.width, meta.height).href, 25 | 26 | width: meta.width, 27 | height: meta.height, 28 | }, 29 | ], 30 | }); 31 | }; 32 | -------------------------------------------------------------------------------- /src/util/entity/actor.ts: -------------------------------------------------------------------------------- 1 | import crypto from "node:crypto"; 2 | import { promisify } from "node:util"; 3 | import type { Actor } from "../../entity/actor"; 4 | import { createLogger } from "../log"; 5 | import { KEY_OPTIONS } from "../rsa"; 6 | 7 | const generateKeyPair = promisify(crypto.generateKeyPair); 8 | 9 | const Log = createLogger("RSA"); 10 | 11 | export const generateSigningKeys = async (actor: Actor, force = false) => { 12 | // skip if we're in a test env unless we pass force = true 13 | if (process.env.NODE_ENV === "test" && !force) return actor; 14 | 15 | const start = Date.now(); 16 | const keys = await generateKeyPair("rsa", KEY_OPTIONS); 17 | 18 | actor.assign({ public_key: keys.publicKey, private_key: keys.privateKey }); 19 | await actor.save(); 20 | 21 | Log.verbose(`Generated keys for actor ${actor} in ${Date.now() - start}ms`); 22 | 23 | return actor; 24 | }; 25 | -------------------------------------------------------------------------------- /src/util/entity/invite.ts: -------------------------------------------------------------------------------- 1 | import crypto from "node:crypto"; 2 | import { Invite } from "../../entity/invite"; 3 | 4 | const CHARACTERS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; 5 | let INVITE_LENGTH = 5; 6 | 7 | const DEFAULT_INVITE_EXISTS_CHECK = async (code: string) => { 8 | return (await Invite.count({ where: { code } })) !== 0; 9 | }; 10 | 11 | export const generateInviteCode = async ( 12 | exists = DEFAULT_INVITE_EXISTS_CHECK, 13 | ) => { 14 | let code: string | undefined; 15 | 16 | while (!code) { 17 | const tryCode = Array.from( 18 | crypto.randomFillSync(new Uint32Array(INVITE_LENGTH)), 19 | ) 20 | .map((x) => CHARACTERS[x % CHARACTERS.length]) 21 | .join(""); 22 | 23 | if (await exists(tryCode)) { 24 | // failed 25 | INVITE_LENGTH++; 26 | continue; 27 | } 28 | 29 | code = tryCode; 30 | } 31 | 32 | return code; 33 | }; 34 | -------------------------------------------------------------------------------- /src/util/entity/member.ts: -------------------------------------------------------------------------------- 1 | import type { APPerson } from "activitypub-types"; 2 | import { Member } from "../../entity/member"; 3 | import type { User } from "../../entity/user"; 4 | import type { ActorMention } from "../activitypub/constants"; 5 | import { resolveId } from "../activitypub/resolve"; 6 | import { splitQualifiedMention } from "../activitypub/util"; 7 | import { HttpError } from "../httperror"; 8 | import { getOrFetchUser } from "./user"; 9 | 10 | export const getOrFetchMember = async ( 11 | lookup: ActorMention | URL | APPerson, 12 | ) => { 13 | const mention = splitQualifiedMention(resolveId(lookup)); 14 | 15 | const member = await Member.findOne({ 16 | where: { 17 | user: { 18 | name: mention.id, 19 | domain: mention.domain, 20 | }, 21 | }, 22 | }); 23 | 24 | if (!member) { 25 | const user = await getOrFetchUser(lookup); 26 | await user.save(); 27 | return Member.create({ 28 | user, 29 | roles: [], // assigned later 30 | }); 31 | } 32 | 33 | return member; 34 | }; 35 | 36 | export const isMemberOfGuildThrow = async (guild_id: string, user: User) => { 37 | if (!(await isMemberOfGuild(guild_id, user))) 38 | throw new HttpError("Missing permission", 404); 39 | }; 40 | 41 | export const isMemberOfGuild = async (guild_id: string, user: User) => { 42 | const guild = splitQualifiedMention(guild_id); 43 | 44 | return ( 45 | (await Member.count({ 46 | where: { 47 | roles: { 48 | guild: [ 49 | { remote_address: guild_id }, 50 | { id: guild.id, domain: guild.domain }, 51 | ], 52 | }, 53 | user: { id: user.id }, 54 | }, 55 | })) !== 0 56 | ); 57 | }; 58 | -------------------------------------------------------------------------------- /src/util/entity/resolve.ts: -------------------------------------------------------------------------------- 1 | import { validate as uuidValidate } from "uuid"; 2 | import { Channel } from "../../entity/channel"; 3 | import { Guild } from "../../entity/guild"; 4 | import { User } from "../../entity/user"; 5 | import { InstanceActor } from "../activitypub/instanceActor"; 6 | 7 | export const findActorOfAnyType = async (id: string, domain: string) => { 8 | if (id === InstanceActor.id && domain === InstanceActor.domain) 9 | return InstanceActor; 10 | 11 | const isUuid = uuidValidate(id); 12 | 13 | const [user, channel, guild] = await Promise.all([ 14 | isUuid 15 | ? null 16 | : User.findOne({ 17 | where: [ 18 | { 19 | name: id, 20 | domain: domain, 21 | }, 22 | ], 23 | }), 24 | isUuid 25 | ? Channel.findOne({ 26 | where: [ 27 | { 28 | id, 29 | domain, 30 | }, 31 | { 32 | remote_id: id, 33 | domain, 34 | }, 35 | ], 36 | }) 37 | : null, 38 | 39 | isUuid 40 | ? Guild.findOne({ 41 | where: [ 42 | { 43 | id, 44 | domain, 45 | }, 46 | { 47 | remote_id: id, 48 | domain, 49 | }, 50 | ], 51 | }) 52 | : null, 53 | ]); 54 | 55 | return user ?? channel ?? guild; 56 | }; 57 | -------------------------------------------------------------------------------- /src/util/entity/role.ts: -------------------------------------------------------------------------------- 1 | import { ObjectIsPerson } from "activitypub-types"; 2 | import type { Member } from "../../entity/member"; 3 | import { Role } from "../../entity/role"; 4 | import { APError } from "../activitypub/error"; 5 | import { 6 | resolveAPObject, 7 | resolveCollectionEntries, 8 | resolveId, 9 | resolveWebfinger, 10 | } from "../activitypub/resolve"; 11 | import { type APRole, ObjectIsRole } from "../activitypub/transformers/role"; 12 | import { splitQualifiedMention } from "../activitypub/util"; 13 | import { tryParseUrl } from "../url"; 14 | import { getOrFetchGuild } from "./guild"; 15 | import { getOrFetchMember } from "./member"; 16 | 17 | export const createRoleFromRemote = async (lookup: string | APRole) => { 18 | const id = resolveId(lookup); 19 | const mention = splitQualifiedMention(id); 20 | 21 | const obj = 22 | id instanceof URL 23 | ? await resolveAPObject(id) 24 | : await resolveWebfinger(id); 25 | 26 | if (!ObjectIsRole(obj)) 27 | throw new APError(`Expected role but found ${obj.type}`); 28 | 29 | if (!obj.attributedTo || typeof obj.attributedTo !== "string") 30 | throw new APError("role requires attributedTo guild"); 31 | 32 | const role = Role.create({ 33 | remote_id: mention.id, 34 | name: obj.name, 35 | allow: obj.allow, 36 | deny: obj.deny, 37 | position: obj.position, 38 | guild: await getOrFetchGuild(resolveId(obj.attributedTo)), 39 | members: [], // to be fetched later 40 | }); 41 | 42 | const members = await Promise.all([ 43 | ...(await resolveCollectionEntries(new URL(obj.members))).reduce( 44 | (prev, curr) => { 45 | if (typeof curr === "string") { 46 | const url = tryParseUrl(curr); 47 | if (!url) return prev; 48 | prev.push(getOrFetchMember(url)); 49 | } else if (ObjectIsPerson(curr)) { 50 | prev.push(getOrFetchMember(curr)); 51 | } 52 | return prev; 53 | }, 54 | [] as Array>, 55 | ), 56 | ]); 57 | 58 | role.members = members; 59 | 60 | return role; 61 | }; 62 | -------------------------------------------------------------------------------- /src/util/httperror.ts: -------------------------------------------------------------------------------- 1 | export class HttpError extends Error { 2 | constructor( 3 | public message: string, 4 | public code = 404, 5 | ) { 6 | super(message); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/util/log.ts: -------------------------------------------------------------------------------- 1 | import type internal from "node:stream"; 2 | import { Writable } from "node:stream"; 3 | 4 | export const createLogger = (context: string) => { 5 | context = context.toUpperCase(); 6 | const doLog = (level: LogLevel, ...args: unknown[]) => { 7 | if (options.level > level) return; 8 | 9 | levelConsoleMap[level]( 10 | `[${context}${options.include_date ? ` ${new Date().toISOString()}` : ""}]`, 11 | ...args, 12 | ); 13 | return args.join(" "); 14 | }; 15 | 16 | return { 17 | error: (...args: unknown[]) => doLog(LogLevel.error, ...args), 18 | warn: (...args: unknown[]) => doLog(LogLevel.warn, ...args), 19 | msg: (...args: unknown[]) => doLog(LogLevel.msg, ...args), 20 | verbose: (...args: unknown[]) => doLog(LogLevel.verbose, ...args), 21 | }; 22 | }; 23 | 24 | class LogStream extends Writable { 25 | private logger; 26 | 27 | constructor(context: string, opts?: internal.WritableOptions) { 28 | super(opts); 29 | this.logger = createLogger(context); 30 | } 31 | 32 | _write( 33 | chunk: unknown, 34 | _encoding: BufferEncoding, 35 | callback: (error?: Error | null) => void, 36 | ): void { 37 | this.logger.msg(String(chunk).trimEnd()); 38 | callback(); 39 | } 40 | } 41 | 42 | export const createLogStream = (context: string) => new LogStream(context); 43 | 44 | export enum LogLevel { 45 | verbose = 0, 46 | msg = 1, 47 | warn = 2, 48 | error = 3, 49 | none = 4, 50 | } 51 | 52 | const levelConsoleMap = { 53 | [LogLevel.verbose]: console.log, 54 | [LogLevel.msg]: console.log, 55 | [LogLevel.warn]: console.warn, 56 | [LogLevel.error]: console.error, 57 | [LogLevel.none]: () => {}, 58 | } satisfies Record void>; 59 | 60 | // we can't use config here because importing config ends up parsing it 61 | // bit of an oversight... but we can work around that by exposing a setter 62 | const options = { 63 | level: LogLevel.verbose, 64 | include_date: true, 65 | }; 66 | 67 | export const setLogOptions = (opts: typeof options) => { 68 | Object.assign(options, opts); 69 | }; 70 | -------------------------------------------------------------------------------- /src/util/migration/postgres/1749945089530-local_upload.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type MigrationInterface, 3 | type QueryRunner, 4 | Table, 5 | TableForeignKey, 6 | } from "typeorm"; 7 | 8 | export class LocalUpload1749945089530 implements MigrationInterface { 9 | name = "LocalUpload1749945089530"; 10 | 11 | public async up(queryRunner: QueryRunner): Promise { 12 | await queryRunner.createTable( 13 | new Table({ 14 | name: "local_uploads", 15 | columns: [ 16 | { 17 | name: "id", 18 | type: "uuid", 19 | isPrimary: true, 20 | }, 21 | { 22 | name: "hash", 23 | type: "varchar", 24 | }, 25 | { 26 | name: "size", 27 | type: "int4", 28 | }, 29 | { 30 | name: "mime", 31 | type: "varchar", 32 | }, 33 | { 34 | name: "md5", 35 | type: "varchar", 36 | }, 37 | { 38 | name: "width", 39 | type: "int4", 40 | isNullable: true, 41 | }, 42 | { 43 | name: "height", 44 | type: "int4", 45 | isNullable: true, 46 | }, 47 | { 48 | name: "channelId", 49 | type: "uuid", 50 | isNullable: true, 51 | }, 52 | ], 53 | }), 54 | ); 55 | 56 | await queryRunner.createForeignKey( 57 | "local_uploads", 58 | new TableForeignKey({ 59 | columnNames: ["channelId"], 60 | referencedColumnNames: ["id"], 61 | referencedTableName: "channels", 62 | }), 63 | ); 64 | } 65 | 66 | public async down(queryRunner: QueryRunner): Promise { 67 | await queryRunner.dropTable("local_uploads"); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/util/migration/postgres/1750057984384-channelOrdering.ts: -------------------------------------------------------------------------------- 1 | import type { MigrationInterface, QueryRunner } from "typeorm"; 2 | 3 | export class ChannelOrdering1750057984384 implements MigrationInterface { 4 | name = "ChannelOrdering1750057984384"; 5 | 6 | public async up(queryRunner: QueryRunner): Promise { 7 | const channels = await queryRunner.getTable("channels"); 8 | 9 | if (!channels) throw new Error("failed to find channels table?"); 10 | 11 | const position = channels.findColumnByName("position"); 12 | if (!position) throw new Error("failed to find position column?"); 13 | 14 | const positionIdx = channels.findColumnIndices(position); 15 | 16 | if (!positionIdx.length) 17 | throw new Error("failed to find (position, guildId) index?"); 18 | 19 | await queryRunner.dropIndex(channels, positionIdx[0]); 20 | 21 | await queryRunner.query( 22 | `alter table channels add constraint channel_ordering unique (position, "guildId") deferrable initially deferred;`, 23 | ); 24 | } 25 | 26 | public async down(queryRunner: QueryRunner): Promise { 27 | await queryRunner.query( 28 | "alter table channels drop constraint channel_ordering;", 29 | ); 30 | await queryRunner.query( 31 | `CREATE UNIQUE INDEX "channel_position" ON "channels" ("position", "guildId")`, 32 | ); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/util/migration/postgres/1751438079643-register-invites.ts: -------------------------------------------------------------------------------- 1 | import type { MigrationInterface, QueryRunner } from "typeorm"; 2 | 3 | export class RegisterInvites1751438079643 implements MigrationInterface { 4 | name = "RegisterInvites1751438079643"; 5 | 6 | public async up(queryRunner: QueryRunner): Promise { 7 | await queryRunner.query( 8 | `CREATE TABLE "instance_invites" ("code" character varying NOT NULL, "expires" TIMESTAMP, "maxUses" integer, CONSTRAINT "PK_bd613d455be90064ce6cbaea14e" PRIMARY KEY ("code"))`, 9 | ); 10 | await queryRunner.query( 11 | `ALTER TABLE "users" ADD "inviteCode" character varying`, 12 | ); 13 | await queryRunner.query( 14 | `ALTER TABLE "users" ADD CONSTRAINT "FK_bbb81d30f7992d6cb1e328e7dd9" FOREIGN KEY ("inviteCode") REFERENCES "instance_invites"("code") ON DELETE CASCADE ON UPDATE NO ACTION`, 15 | ); 16 | } 17 | 18 | public async down(queryRunner: QueryRunner): Promise { 19 | await queryRunner.query( 20 | `ALTER TABLE "users" DROP CONSTRAINT "FK_bbb81d30f7992d6cb1e328e7dd9"`, 21 | ); 22 | await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "inviteCode"`); 23 | await queryRunner.query(`DROP TABLE "instance_invites"`); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/util/migration/postgres/1757219628590-embeds.ts: -------------------------------------------------------------------------------- 1 | import type { MigrationInterface, QueryRunner } from "typeorm"; 2 | 3 | export class Embeds1757219628590 implements MigrationInterface { 4 | name = "Embeds1757219628590"; 5 | 6 | public async up(queryRunner: QueryRunner): Promise { 7 | await queryRunner.query( 8 | `CREATE TABLE "embeds" ("target" character varying NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "type" integer NOT NULL, "title" character varying, "description" character varying, "author_name" character varying, "author_url" character varying, "footer" character varying, "footer_icon" character varying, "provider_name" character varying, "provider_url" character varying, "images" text, "videos" text, CONSTRAINT "PK_7ea0a44ffa579ad9271f36e6847" PRIMARY KEY ("target"))`, 9 | ); 10 | await queryRunner.query( 11 | `CREATE TABLE "messages_embeds_embeds" ("messagesId" uuid NOT NULL, "embedsTarget" character varying NOT NULL, CONSTRAINT "PK_6e18c315ff8cf84108c3616a970" PRIMARY KEY ("messagesId", "embedsTarget"))`, 12 | ); 13 | await queryRunner.query( 14 | `CREATE INDEX "IDX_4ecd06cb6bbe71bf0bfc138dcb" ON "messages_embeds_embeds" ("messagesId") `, 15 | ); 16 | await queryRunner.query( 17 | `CREATE INDEX "IDX_cbbe0ec3f729857ef44c372234" ON "messages_embeds_embeds" ("embedsTarget") `, 18 | ); 19 | await queryRunner.query( 20 | `ALTER TABLE "messages_embeds_embeds" ADD CONSTRAINT "FK_4ecd06cb6bbe71bf0bfc138dcb7" FOREIGN KEY ("messagesId") REFERENCES "messages"("id") ON DELETE CASCADE ON UPDATE CASCADE`, 21 | ); 22 | await queryRunner.query( 23 | `ALTER TABLE "messages_embeds_embeds" ADD CONSTRAINT "FK_cbbe0ec3f729857ef44c3722344" FOREIGN KEY ("embedsTarget") REFERENCES "embeds"("target") ON DELETE CASCADE ON UPDATE CASCADE`, 24 | ); 25 | } 26 | 27 | public async down(queryRunner: QueryRunner): Promise { 28 | await queryRunner.query( 29 | `ALTER TABLE "messages_embeds_embeds" DROP CONSTRAINT "FK_cbbe0ec3f729857ef44c3722344"`, 30 | ); 31 | await queryRunner.query( 32 | `ALTER TABLE "messages_embeds_embeds" DROP CONSTRAINT "FK_4ecd06cb6bbe71bf0bfc138dcb7"`, 33 | ); 34 | await queryRunner.query( 35 | `DROP INDEX "public"."IDX_cbbe0ec3f729857ef44c372234"`, 36 | ); 37 | await queryRunner.query( 38 | `DROP INDEX "public"."IDX_4ecd06cb6bbe71bf0bfc138dcb"`, 39 | ); 40 | await queryRunner.query(`DROP TABLE "messages_embeds_embeds"`); 41 | await queryRunner.query(`DROP TABLE "embeds"`); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/util/migration/postgres/1757588526875-uploadsCascade.ts: -------------------------------------------------------------------------------- 1 | import type { MigrationInterface, QueryRunner } from "typeorm"; 2 | 3 | export class UploadsCascade1757588526875 implements MigrationInterface { 4 | name = "UploadsCascade1757588526875"; 5 | 6 | public async up(queryRunner: QueryRunner): Promise { 7 | await queryRunner.query( 8 | `ALTER TABLE "local_uploads" DROP CONSTRAINT "FK_d878b7ffbc96108478828734cd6"`, 9 | ); 10 | await queryRunner.query( 11 | `ALTER TABLE "local_uploads" ADD CONSTRAINT "FK_d878b7ffbc96108478828734cd6" FOREIGN KEY ("channelId") REFERENCES "channels"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, 12 | ); 13 | } 14 | 15 | public async down(queryRunner: QueryRunner): Promise { 16 | await queryRunner.query( 17 | `ALTER TABLE "local_uploads" DROP CONSTRAINT "FK_d878b7ffbc96108478828734cd6"`, 18 | ); 19 | await queryRunner.query( 20 | `ALTER TABLE "local_uploads" ADD CONSTRAINT "FK_d878b7ffbc96108478828734cd6" FOREIGN KEY ("channelId") REFERENCES "channels"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, 21 | ); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/util/migration/postgres/1757745204938-inviteIndex.ts: -------------------------------------------------------------------------------- 1 | import type { MigrationInterface, QueryRunner } from "typeorm"; 2 | 3 | export class InviteIndex1757745204938 implements MigrationInterface { 4 | name = "InviteIndex1757745204938"; 5 | 6 | public async up(queryRunner: QueryRunner): Promise { 7 | await queryRunner.query( 8 | `CREATE UNIQUE INDEX "IDX_d93b870a3ddf61f5279dae3969" ON "invites" ("code", "guildId") `, 9 | ); 10 | } 11 | 12 | public async down(queryRunner: QueryRunner): Promise { 13 | await queryRunner.query( 14 | `DROP INDEX "public"."IDX_d93b870a3ddf61f5279dae3969"`, 15 | ); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/util/migration/postgres/1758865221251-pushSubscription.ts: -------------------------------------------------------------------------------- 1 | import type { MigrationInterface, QueryRunner } from "typeorm"; 2 | 3 | export class PushSubscription1758865221251 implements MigrationInterface { 4 | name = "PushSubscription1758865221251"; 5 | 6 | public async up(queryRunner: QueryRunner): Promise { 7 | await queryRunner.query( 8 | `CREATE TABLE "push_subscriptions" ("userId" uuid NOT NULL, "name" character varying NOT NULL, "endpoint" character varying NOT NULL, "p256dh" character varying NOT NULL, "auth" character varying NOT NULL, "created" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_267923180f80df311f801d88441" PRIMARY KEY ("userId", "name"))`, 9 | ); 10 | await queryRunner.query( 11 | `ALTER TABLE "push_subscriptions" ADD CONSTRAINT "FK_4cc061875e9eecc311a94b3e431" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, 12 | ); 13 | } 14 | 15 | public async down(queryRunner: QueryRunner): Promise { 16 | await queryRunner.query( 17 | `ALTER TABLE "push_subscriptions" DROP CONSTRAINT "FK_4cc061875e9eecc311a94b3e431"`, 18 | ); 19 | await queryRunner.query(`DROP TABLE "push_subscriptions"`); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/util/object.ts: -------------------------------------------------------------------------------- 1 | type ReplaceNulls = T extends null ? undefined : T; 2 | type Truthy = { 3 | [K in keyof T]: ReplaceNulls; 4 | }; 5 | 6 | export const onlyTruthy = (obj: T): Truthy | undefined => { 7 | const ret = Object.fromEntries( 8 | Object.entries(obj).filter(([_, value]) => !!value), 9 | ) as Truthy; 10 | 11 | if (Object.keys(ret).length === 0) return undefined; 12 | 13 | return ret; 14 | }; 15 | -------------------------------------------------------------------------------- /src/util/permission.ts: -------------------------------------------------------------------------------- 1 | // Permissions regarding actions within a channel. 2 | 3 | // Stored within the role or channel overwrites 4 | export enum PERMISSION { 5 | /** no permissions */ 6 | NONE = 0, 7 | 8 | /** all permissions + delete */ 9 | OWNER = 1, 10 | 11 | /** all permissions */ 12 | ADMIN = 2, 13 | 14 | /** can send messages in this channel */ 15 | SEND_MESSAGES = 3, 16 | 17 | /** can modify, delete, add channels in this guild */ 18 | MANAGE_CHANNELS = 4, 19 | 20 | /** can view this channel. */ 21 | VIEW_CHANNEL = 5, 22 | 23 | /** can start or join this channel's voice call */ 24 | CALL_CHANNEL = 6, 25 | 26 | /** can modify this guild */ 27 | MANAGE_GUILD = 7, 28 | 29 | /** can modify or delete invites */ 30 | MANAGE_INVITES = 8, 31 | 32 | /** can invite people to this channel */ 33 | CREATE_INVITE = 9, 34 | 35 | /** can attach files to this channel */ 36 | UPLOAD = 10, 37 | 38 | /** can delete any message in this channel */ 39 | MANAGE_MESSAGES = 11, 40 | } 41 | 42 | export const DefaultPermissions: PERMISSION[] = [ 43 | PERMISSION.SEND_MESSAGES, 44 | PERMISSION.VIEW_CHANNEL, 45 | PERMISSION.CALL_CHANNEL, 46 | PERMISSION.CREATE_INVITE, 47 | PERMISSION.CALL_CHANNEL, 48 | PERMISSION.UPLOAD, 49 | ]; 50 | -------------------------------------------------------------------------------- /src/util/rsa.ts: -------------------------------------------------------------------------------- 1 | export const KEY_OPTIONS = { 2 | modulusLength: 4096, 3 | publicKeyEncoding: { 4 | type: "spki", 5 | format: "pem", 6 | }, 7 | privateKeyEncoding: { 8 | type: "pkcs8", 9 | format: "pem", 10 | }, 11 | } as const; 12 | -------------------------------------------------------------------------------- /src/util/storage/index.ts: -------------------------------------------------------------------------------- 1 | import { config } from "../config"; 2 | 3 | import local from "./local"; 4 | import s3 from "./s3"; 5 | 6 | const api = config.storage.s3.enabled ? s3 : local; 7 | 8 | export type PutFileRequest = { 9 | channel_id: string; 10 | name: string; 11 | size: number; 12 | mime: string; 13 | md5: string; 14 | width?: number; 15 | height?: number; 16 | }; 17 | 18 | export const createUploadEndpoint = (file: PutFileRequest) => 19 | api.createEndpoint(file); 20 | 21 | export const checkFileExists = (channel_id: string, hash: string) => 22 | api.checkFileExists(channel_id, hash); 23 | 24 | export const getFileStream = (channel_id: string, hash: string) => 25 | api.getFileStream(channel_id, hash); 26 | 27 | export const deleteFile = (channel_id: string, hash: string) => 28 | api.deleteFile(channel_id, hash); 29 | -------------------------------------------------------------------------------- /src/util/storage/local.ts: -------------------------------------------------------------------------------- 1 | import crypto from "node:crypto"; 2 | import type { Stats } from "node:fs"; 3 | import { createReadStream } from "node:fs"; 4 | import fs from "node:fs/promises"; 5 | import path from "node:path"; 6 | import { Readable } from "node:stream"; 7 | import jwt from "jsonwebtoken"; 8 | import { LocalUpload } from "../../entity/upload"; 9 | import { config } from "../config"; 10 | import { makeInstanceUrl } from "../url"; 11 | import type { PutFileRequest } from "."; 12 | 13 | export type localFileJwt = PutFileRequest & { key: string }; 14 | 15 | const createEndpoint = async (file: PutFileRequest) => { 16 | // TODO: if federation is disabled, this defaults to localhost 17 | // which is obviously wrong 18 | 19 | const endpoint = makeInstanceUrl("/upload"); 20 | 21 | const hash = crypto 22 | .createHash("md5") 23 | .update(file.name) 24 | .update(file.mime) 25 | .update(Date.now().toString()) 26 | .digest("hex"); 27 | 28 | const token = await new Promise((resolve, reject) => { 29 | jwt.sign( 30 | { 31 | ...file, 32 | key: `${file.channel_id}/${hash}`, 33 | } as localFileJwt, 34 | config.security.jwt_secret, 35 | { expiresIn: 300 }, 36 | (err, encoded) => { 37 | if (err || !encoded) return reject(err); 38 | return resolve(encoded); 39 | }, 40 | ); 41 | }); 42 | 43 | return { 44 | endpoint: `${endpoint}?t=${token}`, 45 | hash, 46 | }; 47 | }; 48 | 49 | const checkFileExists = async (channel_id: string, hash: string) => { 50 | const p = path.join(config.storage.directory, channel_id, hash); 51 | let file: Stats; 52 | try { 53 | file = await fs.stat(p); 54 | } catch (_) { 55 | return false; 56 | } 57 | 58 | const upload = await LocalUpload.findOne({ 59 | where: { 60 | hash: hash, 61 | channel: { id: channel_id }, 62 | }, 63 | }); 64 | 65 | if (!upload) { 66 | // TODO: do some cleanup? 67 | // or perhaps, we can just package a cleanup script 68 | return false; 69 | } 70 | 71 | return { 72 | length: file.size, 73 | type: upload.mime, 74 | width: upload.width, 75 | height: upload.height, 76 | }; 77 | }; 78 | 79 | const getFileStream = async (channel_id: string, hash: string) => { 80 | const p = path.join(config.storage.directory, channel_id, hash); 81 | try { 82 | return Readable.from(createReadStream(p)); 83 | } catch (_) { 84 | return false; 85 | } 86 | }; 87 | 88 | const deleteFile = async (channel_id: string, hash: string) => { 89 | const p = path.join(config.storage.directory, channel_id, hash); 90 | await fs.rm(p); 91 | }; 92 | 93 | const api = { createEndpoint, checkFileExists, getFileStream, deleteFile }; 94 | 95 | export default api; 96 | -------------------------------------------------------------------------------- /src/util/token.ts: -------------------------------------------------------------------------------- 1 | import jwt from "jsonwebtoken"; 2 | import { User } from "../entity/user"; 3 | import { config } from "./config"; 4 | import { HttpError } from "./httperror"; 5 | 6 | const INVALID_TOKEN = new HttpError("Invalid token", 401); 7 | 8 | export type UserTokenData = { 9 | id: string; 10 | iat: number; 11 | }; 12 | 13 | export const getUserFromToken = (token: string): Promise => 14 | new Promise((resolve, reject) => { 15 | token = token.replace("Bearer ", ""); 16 | 17 | jwt.verify( 18 | token, 19 | config.security.jwt_secret, 20 | { 21 | algorithms: ["HS256"], 22 | }, 23 | async (err, out) => { 24 | const decoded = out as UserTokenData; 25 | if (err || !decoded) return reject(INVALID_TOKEN); 26 | 27 | const user = await User.findOne({ 28 | where: { id: decoded.id }, 29 | }); 30 | 31 | if (!user || !user.valid_tokens_since) 32 | return reject(INVALID_TOKEN); 33 | 34 | if ( 35 | decoded.iat * 1000 < 36 | new Date(user.valid_tokens_since).setSeconds(0, 0) 37 | ) 38 | return reject(INVALID_TOKEN); 39 | 40 | return resolve(user); 41 | }, 42 | ); 43 | }); 44 | 45 | export const generateToken = (id: string): Promise => { 46 | const iat = Math.floor(Date.now() / 1000); 47 | const algorithm = "HS256"; 48 | 49 | return new Promise((res, rej) => 50 | jwt.sign( 51 | { id, iat } as UserTokenData, 52 | config.security.jwt_secret, 53 | { algorithm }, 54 | (err, token) => { 55 | if (err || !token) return rej(err); 56 | return res(token); 57 | }, 58 | ), 59 | ); 60 | }; 61 | -------------------------------------------------------------------------------- /src/util/types.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2023 Peertube Framasoft https://github.com/Chocobozzz/PeerTube/blob/edc695263f5a33ee012d50fa914ee10a385c9433/packages/typescript-utils/src/types.ts 2 | 3 | export type AttributesOnly = { 4 | [K in keyof T]: T[K] extends (...rest: unknown[]) => unknown ? never : T[K]; 5 | }; 6 | -------------------------------------------------------------------------------- /src/util/url.ts: -------------------------------------------------------------------------------- 1 | import { config } from "./config"; 2 | 3 | export const tryParseUrl = (input: string | URL) => { 4 | if (input instanceof URL) return input; 5 | 6 | try { 7 | return new URL(input); 8 | } catch (_) { 9 | return null; 10 | } 11 | }; 12 | 13 | export const makeUrl = (path: string, base: URL) => { 14 | const url = new URL(base); 15 | 16 | if (path.startsWith("/")) path = path.slice(1); 17 | 18 | url.pathname = `${url.pathname}${url.pathname.endsWith("/") ? "" : "/"}${path}`; 19 | 20 | return url; 21 | }; 22 | 23 | /** 24 | * Appends a path to the instance URL 25 | */ 26 | export const makeInstanceUrl = (path: string) => { 27 | return makeUrl(path, config.federation.instance_url).href; 28 | }; 29 | 30 | /** 31 | * Appends a path to the webapp URL 32 | */ 33 | export const makeWebappUrl = (path: string) => { 34 | return makeUrl(path, config.federation.webapp_url).href; 35 | }; 36 | -------------------------------------------------------------------------------- /test/cli/adduser.ts: -------------------------------------------------------------------------------- 1 | import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi"; 2 | import { z } from "zod"; 3 | extendZodWithOpenApi(z); 4 | 5 | import test from "ava"; 6 | import { setupTests } from "../helpers/env"; 7 | setupTests(test); 8 | 9 | test("Can create user", async (t) => { 10 | const { handleCli } = await import("../../src/cli/cli"); 11 | const { User } = await import("../../src/entity/user"); 12 | const databaseUtils = await import("../../src/util/database"); 13 | const closeDatabase = databaseUtils.closeDatabase; 14 | 15 | Object.assign(databaseUtils, { closeDatabase: () => {} }); 16 | 17 | process.env.NODE_ENV = "not-test"; 18 | 19 | await handleCli([ 20 | "node", 21 | process.cwd(), 22 | "add-user", 23 | "testUser", 24 | "test@localhost", 25 | ]); 26 | 27 | process.env.NODE_ENV = "test"; 28 | 29 | Object.assign(databaseUtils, { closeDatabase }); 30 | 31 | const user = await User.findOneOrFail({ 32 | where: { name: "testUser", email: "test@localhost" }, 33 | }); 34 | t.assert(user.private_key); 35 | t.assert(user.public_key); 36 | t.assert(user.password_hash); 37 | }); 38 | -------------------------------------------------------------------------------- /test/cli/config.ts: -------------------------------------------------------------------------------- 1 | import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi"; 2 | import { z } from "zod"; 3 | 4 | extendZodWithOpenApi(z); 5 | 6 | import test from "ava"; 7 | 8 | test("CLI does not require config", async (t) => { 9 | process.env.NODE_CONFIG_DIR = `${process.cwd()}/test/helpers/config`; 10 | process.env.NODE_CONFIG = "{}"; 11 | process.env.SUPPRESS_NO_CONFIG_WARNING = "1"; 12 | global.console.warn = () => {}; 13 | 14 | const { handleCli } = await import("../../src/cli/cli"); 15 | 16 | await handleCli(["node", process.cwd()]); 17 | 18 | t.pass(); 19 | }); 20 | -------------------------------------------------------------------------------- /test/helpers/channel.ts: -------------------------------------------------------------------------------- 1 | export const createTestDm = async ( 2 | name: string, 3 | owner: string, 4 | recipients: string[], 5 | ) => { 6 | const { createDmChannel } = await import("../../src/util/entity/channel"); 7 | const { getOrFetchUser } = await import("../../src/util/entity/user"); 8 | const { resolveId } = await import("../../src/util/activitypub/resolve"); 9 | const o = await getOrFetchUser(resolveId(owner)); 10 | const r = await Promise.all( 11 | recipients.map((x) => getOrFetchUser(resolveId(x))), 12 | ); 13 | 14 | const channel = await createDmChannel(name, o, r); 15 | 16 | return channel; 17 | }; 18 | -------------------------------------------------------------------------------- /test/helpers/config.ts: -------------------------------------------------------------------------------- 1 | import crypto from "node:crypto"; 2 | import { KEY_OPTIONS } from "../../src/util/rsa"; 3 | 4 | // export const RANDOM_PORT = Math.round(Math.random() * 4) + 1000; 5 | 6 | // process.env.PORT = RANDOM_PORT.toString(); 7 | 8 | export const proxyConfig = () => { 9 | const keys = crypto.generateKeyPairSync("rsa", KEY_OPTIONS); 10 | 11 | process.env.NODE_CONFIG_DIR = `${process.cwd()}/test/helpers/config`; 12 | 13 | process.env.NODE_CONFIG = JSON.stringify({ 14 | http: { 15 | log: "-200", 16 | }, 17 | database: { 18 | log: false, 19 | }, 20 | security: { 21 | jwt_secret: crypto.randomBytes(256).toString("base64"), 22 | }, 23 | federation: { 24 | enabled: true, 25 | webapp_url: new URL("http://localhost"), 26 | instance_url: new URL("http://localhost"), 27 | require_http_signatures: true, 28 | public_key: keys.publicKey, 29 | private_key: keys.privateKey, 30 | }, 31 | registration: { 32 | enabled: true, 33 | }, 34 | }); 35 | }; 36 | -------------------------------------------------------------------------------- /test/helpers/database.ts: -------------------------------------------------------------------------------- 1 | import pg from "pg"; 2 | 3 | export const createDatabase = async (name: string, host: string) => { 4 | const client = new pg.Client({ 5 | connectionString: host, 6 | }); 7 | 8 | await client.connect(); 9 | 10 | // it's just a test, whatever 11 | const res = await client.query(`CREATE DATABASE ${name}`); 12 | 13 | await client.end(); 14 | }; 15 | 16 | export const createRandomDatabase = async (host: string) => { 17 | const name = `shoot_test_${(Math.random() + 1).toString(36).substring(7)}`; 18 | 19 | await createDatabase(name, host); 20 | 21 | return name; 22 | }; 23 | 24 | export const connectToRandomDb = async (host: string) => { 25 | const name = await createRandomDatabase(host); 26 | 27 | const config = JSON.parse(process.env.NODE_CONFIG as string); 28 | 29 | process.env.NODE_CONFIG = JSON.stringify({ 30 | ...config, 31 | database: { 32 | // log: true, 33 | url: `${host}${name}`, 34 | }, 35 | }); 36 | 37 | const { initDatabase } = await import("../../src/util/database"); 38 | await initDatabase(); 39 | 40 | return name; 41 | }; 42 | 43 | export const deleteDatabase = async (host: string, name: string) => { 44 | const client = new pg.Client({ 45 | connectionString: host, 46 | }); 47 | 48 | await client.connect(); 49 | 50 | // it's just a test, whatever 51 | const res = await client.query(`DROP DATABASE ${name}`); 52 | 53 | await client.end(); 54 | }; 55 | -------------------------------------------------------------------------------- /test/helpers/env.ts: -------------------------------------------------------------------------------- 1 | import type { TestFn } from "ava"; 2 | import Sinon from "sinon"; 3 | import { proxyConfig } from "./config"; 4 | import { connectToRandomDb, deleteDatabase } from "./database"; 5 | 6 | export const setupTests = (test: TestFn) => { 7 | proxyConfig(); 8 | test.before("setup", async (t) => { 9 | //@ts-ignore 10 | t.context.clock = Sinon.useFakeTimers({ 11 | now: new Date(2024, 1, 1), 12 | shouldClearNativeTimers: true, 13 | shouldAdvanceTime: true, 14 | }); 15 | 16 | global.console.log = () => {}; 17 | // biome-ignore lint/performance/noDelete: 18 | delete process.env.NODE_ENV; 19 | process.env.SUPPRESS_NO_CONFIG_WARNING = "1"; 20 | const db = await connectToRandomDb( 21 | "postgres://postgres:postgres@127.0.0.1/", 22 | ); 23 | process.env.NODE_ENV = "test"; 24 | 25 | //@ts-ignore 26 | t.context.database_name = db; 27 | global.console.log = t.log; 28 | }); 29 | 30 | test.after.always("teardown", async (t) => { 31 | // delete temp db? 32 | 33 | //@ts-ignore 34 | await t.context.clock.runAllAsync(); 35 | //@ts-ignore 36 | await t.context.clock.restore(); 37 | 38 | const { closeDatabase } = await import("../../src/util/database"); 39 | closeDatabase(); 40 | 41 | await deleteDatabase( 42 | "postgres://postgres:postgres@127.0.0.1/", 43 | //@ts-ignore 44 | t.context.database_name, 45 | ); 46 | }); 47 | }; 48 | -------------------------------------------------------------------------------- /test/helpers/force_exit.mjs: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import { registerCompletionHandler } from 'ava'; 3 | 4 | registerCompletionHandler(() => { 5 | process.exit(); 6 | }); -------------------------------------------------------------------------------- /test/helpers/guild.ts: -------------------------------------------------------------------------------- 1 | /** owner: mention, members: user ids */ 2 | export const createTestGuild = async ( 3 | name: string, 4 | owner: string, 5 | members: string[], 6 | ) => { 7 | const { createGuild, joinGuild } = await import( 8 | "../../src/util/entity/guild" 9 | ); 10 | const { getOrFetchUser } = await import("../../src/util/entity/user"); 11 | 12 | const { resolveId } = await import("../../src/util/activitypub/resolve"); 13 | 14 | const o = await getOrFetchUser(resolveId(owner)); 15 | const guild = await createGuild(name, o); 16 | 17 | await Promise.all( 18 | members.map(async (x) => 19 | joinGuild( 20 | (await getOrFetchUser(resolveId(x))).mention, 21 | guild.mention, 22 | ), 23 | ), 24 | ); 25 | 26 | return guild; 27 | }; 28 | -------------------------------------------------------------------------------- /test/helpers/types.ts: -------------------------------------------------------------------------------- 1 | export type Context = { 2 | port: number; 3 | }; 4 | -------------------------------------------------------------------------------- /test/http/auth.ts: -------------------------------------------------------------------------------- 1 | import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi"; 2 | import { z } from "zod"; 3 | extendZodWithOpenApi(z); 4 | 5 | import test from "ava"; 6 | import { setupTests } from "../helpers/env"; 7 | setupTests(test); 8 | 9 | import request from "supertest"; 10 | 11 | test("Register and login", async (t) => { 12 | const { APIServer } = await import("../../src/http/server"); 13 | const api = new APIServer(); 14 | 15 | await request(api.app) 16 | .post("/auth/register") 17 | .send({ 18 | username: "registerTest", 19 | password: "test", 20 | email: "test@localhost.com", 21 | }) 22 | .set("Accept", "application/json") 23 | .expect("Content-Type", /json/) 24 | .expect(200) 25 | .then((response) => t.assert(response.body.token)); 26 | 27 | await request(api.app) 28 | .post("/auth/login") 29 | .send({ 30 | username: "registerTest", 31 | password: "test", 32 | }) 33 | .set("Accept", "application/json") 34 | .expect("Content-Type", /json/) 35 | .expect(200) 36 | .then((response) => t.assert(response.body.token)); 37 | }); 38 | -------------------------------------------------------------------------------- /test/http/channels.ts: -------------------------------------------------------------------------------- 1 | import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi"; 2 | import { z } from "zod"; 3 | extendZodWithOpenApi(z); 4 | 5 | import test from "ava"; 6 | import { setupTests } from "../helpers/env"; 7 | setupTests(test); 8 | 9 | import request from "supertest"; 10 | import { createTestDm } from "../helpers/channel"; 11 | import { createTestUser } from "../helpers/users"; 12 | 13 | test("Get local", async (t) => { 14 | const { APIServer } = await import("../../src/http/server"); 15 | const api = new APIServer(); 16 | 17 | const [user1] = await Promise.all([ 18 | createTestUser("local1"), 19 | createTestUser("local2"), 20 | ]); 21 | const dm = await createTestDm("localDm", "local1@localhost", [ 22 | "local2@localhost", 23 | ]); 24 | 25 | const ret = await request(api.app) 26 | .get(`/channel/${dm.mention}`) 27 | .auth(user1, { type: "bearer" }) 28 | .expect(200); 29 | 30 | t.deepEqual(ret.body, dm.toPublic()); 31 | }); 32 | 33 | test("Get local as non-member", async (t) => { 34 | const { APIServer } = await import("../../src/http/server"); 35 | const api = new APIServer(); 36 | 37 | const [, , user3] = await Promise.all([ 38 | createTestUser("local3"), 39 | createTestUser("local4"), 40 | createTestUser("local5"), 41 | ]); 42 | const dm = await createTestDm("localDm", "local3@localhost", [ 43 | "local4@localhost", 44 | ]); 45 | 46 | const ret = await request(api.app) 47 | .get(`/channel/${dm.mention}`) 48 | .auth(user3, { type: "bearer" }) 49 | .expect(400); 50 | 51 | t.pass(); 52 | }); 53 | -------------------------------------------------------------------------------- /test/http/crud/channels.ts: -------------------------------------------------------------------------------- 1 | import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi"; 2 | import { z } from "zod"; 3 | 4 | extendZodWithOpenApi(z); 5 | 6 | import test from "ava"; 7 | import { setupTests } from "../../helpers/env"; 8 | 9 | setupTests(test); 10 | 11 | import request from "supertest"; 12 | import { createTestGuild } from "../../helpers/guild"; 13 | import { createTestUser } from "../../helpers/users"; 14 | 15 | test("edit guild channel as owner", async (t) => { 16 | const { APIServer } = await import("../../../src/http/server"); 17 | const api = new APIServer(); 18 | 19 | const token = await createTestUser("me"); 20 | 21 | const guild = await createTestGuild( 22 | "channel_edit_test", 23 | "me@localhost", 24 | [], 25 | ); 26 | 27 | const general = guild.channels[0]; 28 | 29 | await request(api.app) 30 | .patch(`/channel/${general.mention}`) 31 | .auth(token, { type: "bearer" }) 32 | .send({ 33 | name: "edited", 34 | }) 35 | .expect(204); 36 | 37 | await request(api.app) 38 | .get(`/channel/${general.mention}`) 39 | .auth(token, { type: "bearer" }) 40 | .expect(200) 41 | .then((response) => { 42 | t.is(response.body.name, "edited"); 43 | }); 44 | 45 | // TODO: need to test gateway events, too... 46 | }); 47 | -------------------------------------------------------------------------------- /test/http/crud/guilds.ts: -------------------------------------------------------------------------------- 1 | import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi"; 2 | import { z } from "zod"; 3 | 4 | extendZodWithOpenApi(z); 5 | 6 | import test from "ava"; 7 | import { setupTests } from "../../helpers/env"; 8 | 9 | setupTests(test); 10 | 11 | import request from "supertest"; 12 | import { createTestGuild } from "../../helpers/guild"; 13 | import { createTestUser } from "../../helpers/users"; 14 | 15 | test("edit guild as owner", async (t) => { 16 | const { APIServer } = await import("../../../src/http/server"); 17 | const api = new APIServer(); 18 | 19 | const token = await createTestUser("me"); 20 | 21 | const guild = await createTestGuild( 22 | "channel_edit_test", 23 | "me@localhost", 24 | [], 25 | ); 26 | 27 | await request(api.app) 28 | .patch(`/guild/${guild.mention}`) 29 | .auth(token, { type: "bearer" }) 30 | .send({ 31 | name: "edited", 32 | }) 33 | .expect(204); 34 | 35 | await request(api.app) 36 | .get(`/guild/${guild.mention}`) 37 | .auth(token, { type: "bearer" }) 38 | .expect(200) 39 | .then((response) => { 40 | t.is(response.body.name, "edited"); 41 | }); 42 | 43 | // TODO: need to test gateway events, too... 44 | }); 45 | -------------------------------------------------------------------------------- /test/http/dm.ts: -------------------------------------------------------------------------------- 1 | import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi"; 2 | import { z } from "zod"; 3 | extendZodWithOpenApi(z); 4 | 5 | import test from "ava"; 6 | import { setupTests } from "../helpers/env"; 7 | setupTests(test); 8 | 9 | import request from "supertest"; 10 | import { createTestDm } from "../helpers/channel"; 11 | import { createTestUser } from "../helpers/users"; 12 | 13 | test("Can create DM channels", async (t) => { 14 | const { APIServer } = await import("../../src/http/server"); 15 | const api = new APIServer(); 16 | 17 | const user1 = await createTestUser("create_user1"); 18 | const user2 = await createTestUser("create_user2"); 19 | 20 | const res = await request(api.app) 21 | .post("/users/create_user2@localhost/channels") 22 | .auth(user1, { type: "bearer" }) 23 | .send({ name: "dm channel" }); 24 | 25 | t.is(res.status, 200); 26 | 27 | const channel_id = res.body.id; 28 | 29 | await request(api.app) 30 | .get("/users/@me/channels") 31 | .auth(user1, { type: "bearer" }) 32 | .then((x) => { 33 | t.is(x.status, 200); 34 | t.assert(x.body.find((i: { id: string }) => i.id === channel_id)); 35 | }); 36 | 37 | await request(api.app) 38 | .get("/users/@me/channels") 39 | .auth(user2, { type: "bearer" }) 40 | .then((x) => { 41 | t.is(x.status, 200); 42 | t.assert(x.body.find((i: { id: string }) => i.id === channel_id)); 43 | }); 44 | 45 | t.pass(); 46 | }); 47 | 48 | test("Can send messages to dm", async (t) => { 49 | const { APIServer } = await import("../../src/http/server"); 50 | const api = new APIServer(); 51 | 52 | const user1 = await createTestUser("messages_user1"); 53 | const user2 = await createTestUser("messages_user2"); 54 | 55 | const channel = await createTestDm("dm", "messages_user1@localhost", [ 56 | "messages_user2@localhost", 57 | ]); 58 | 59 | const response = await request(api.app) 60 | .post(`/channel/${channel.mention}/messages`) 61 | .auth(user1, { type: "bearer" }) 62 | .send({ content: "test message" }); 63 | 64 | t.is(response.status, 200); 65 | 66 | const message_id = response.body.id; 67 | 68 | await request(api.app) 69 | .get(`/channel/${channel.mention}/messages`) 70 | .auth(user1, { type: "bearer" }) 71 | .then((x) => { 72 | t.is(x.status, 200); 73 | t.assert(x.body.find((i: { id: string }) => i.id === message_id)); 74 | }); 75 | 76 | await request(api.app) 77 | .get(`/channel/${channel.mention}/messages`) 78 | .auth(user2, { type: "bearer" }) 79 | .then((x) => { 80 | t.is(x.status, 200); 81 | t.assert(x.body.find((i: { id: string }) => i.id === message_id)); 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /test/http/users/@me.ts: -------------------------------------------------------------------------------- 1 | import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi"; 2 | import { z } from "zod"; 3 | extendZodWithOpenApi(z); 4 | 5 | import test from "ava"; 6 | import { setupTests } from "../../helpers/env"; 7 | setupTests(test); 8 | 9 | import request from "supertest"; 10 | import { createTestUser } from "../../helpers/users"; 11 | 12 | test("Can edit own user", async (t) => { 13 | const token = await createTestUser("me"); 14 | 15 | const { APIServer } = await import("../../../src/http/server"); 16 | const api = new APIServer(); 17 | 18 | await request(api.app) 19 | .patch("/users/@me") 20 | .auth(token, { type: "bearer" }) 21 | .send({ 22 | display_name: "me edited", 23 | summary: "this is a test", 24 | }) 25 | .expect(200); 26 | 27 | await request(api.app) 28 | .get("/users/@me") 29 | .auth(token, { type: "bearer" }) 30 | .expect(200) 31 | .then((response) => { 32 | t.is(response.body.display_name, "me edited"); 33 | t.is(response.body.summary, "this is a test"); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /test/http/users/relationship.ts: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | import { setupTests } from "../../helpers/env"; 3 | setupTests(test); 4 | 5 | import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi"; 6 | import { z } from "zod"; 7 | extendZodWithOpenApi(z); 8 | 9 | import request from "supertest"; 10 | import { createTestUser } from "../../helpers/users"; 11 | 12 | test("Create and delete", async (t) => { 13 | const { APIServer } = await import("../../../src/http/server"); 14 | const { RelationshipType } = await import( 15 | "../../../src/entity/relationship" 16 | ); 17 | 18 | const api = new APIServer(); 19 | 20 | const user1 = await createTestUser("create1"); 21 | 22 | const user2 = await createTestUser("create2"); 23 | 24 | const res1 = await request(api.app) 25 | .post("/users/create2@localhost/relationship") 26 | .auth(user1, { type: "bearer" }) 27 | .expect(200); 28 | 29 | const res2 = await request(api.app) 30 | .post("/users/create1@localhost/relationship") 31 | .auth(user2, { type: "bearer" }) 32 | .expect(200); 33 | 34 | t.deepEqual( 35 | ( 36 | await request(api.app) 37 | .get("/users/create2@localhost/relationship") 38 | .auth(user1, { type: "bearer" }) 39 | .expect(200) 40 | ).body, 41 | { ...res1.body, type: RelationshipType.accepted }, 42 | ); 43 | 44 | t.deepEqual( 45 | ( 46 | await request(api.app) 47 | .get("/users/create1@localhost/relationship") 48 | .auth(user2, { type: "bearer" }) 49 | .expect(200) 50 | ).body, 51 | { ...res2.body, type: RelationshipType.accepted }, 52 | ); 53 | 54 | await request(api.app) 55 | .delete("/users/create1@localhost/relationship") 56 | .auth(user2, { type: "bearer" }) 57 | .expect(200); 58 | 59 | await request(api.app) 60 | .get("/users/create1@localhost/relationship") 61 | .auth(user2, { type: "bearer" }) 62 | .expect(404); 63 | 64 | await request(api.app) 65 | .get("/users/create2@localhost/relationship") 66 | .auth(user1, { type: "bearer" }) 67 | .expect(404); 68 | }); 69 | -------------------------------------------------------------------------------- /test/http/wellknown/hostmeta.ts: -------------------------------------------------------------------------------- 1 | import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi"; 2 | import { z } from "zod"; 3 | extendZodWithOpenApi(z); 4 | 5 | import test from "ava"; 6 | import { setupTests } from "../../helpers/env"; 7 | setupTests(test); 8 | 9 | import request from "supertest"; 10 | 11 | test("Hostmeta", async (t) => { 12 | const { APIServer } = await import("../../../src/http/server"); 13 | const api = new APIServer(); 14 | 15 | const res = await request(api.app) 16 | .get("/.well-known/host-meta") 17 | .expect(200); 18 | 19 | t.snapshot(res.text); 20 | }); 21 | -------------------------------------------------------------------------------- /test/http/wellknown/snapshots/hostmeta.ts.md: -------------------------------------------------------------------------------- 1 | # Snapshot report for `test/http/wellknown/hostmeta.ts` 2 | 3 | The actual snapshot is saved in `hostmeta.ts.snap`. 4 | 5 | Generated by [AVA](https://avajs.dev). 6 | 7 | ## Hostmeta 8 | 9 | > Snapshot 1 10 | 11 | `␊ 12 | ␊ 14 | ␊ 15 | ` 16 | -------------------------------------------------------------------------------- /test/http/wellknown/snapshots/hostmeta.ts.snap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaddyUnderStars/shoot/4f18d2329dfc97620b2de8a7db7b73bcc6bc54cf/test/http/wellknown/snapshots/hostmeta.ts.snap -------------------------------------------------------------------------------- /test/http/wellknown/snapshots/webfinger.ts.md: -------------------------------------------------------------------------------- 1 | # Snapshot report for `test/http/wellknown/webfinger.ts` 2 | 3 | The actual snapshot is saved in `webfinger.ts.snap`. 4 | 5 | Generated by [AVA](https://avajs.dev). 6 | 7 | ## finds users 8 | 9 | > Snapshot 1 10 | 11 | { 12 | aliases: [ 13 | 'http://localhost/users/wf_user1', 14 | ], 15 | links: [ 16 | { 17 | href: 'http://localhost/users/wf_user1', 18 | rel: 'self', 19 | type: 'application/activity+json', 20 | }, 21 | ], 22 | subject: 'acct:wf_user1@localhost', 23 | } 24 | 25 | ## finds channels 26 | 27 | > Snapshot 1 28 | 29 | { 30 | aliases: [ 31 | 'http://localhost/channel/d9a9e2ac-e077-4f4d-8f30-6844a87e2686', 32 | ], 33 | links: [ 34 | { 35 | href: 'http://localhost/channel/d9a9e2ac-e077-4f4d-8f30-6844a87e2686', 36 | rel: 'self', 37 | type: 'application/activity+json', 38 | }, 39 | ], 40 | subject: 'acct:d9a9e2ac-e077-4f4d-8f30-6844a87e2686@localhost', 41 | } 42 | -------------------------------------------------------------------------------- /test/http/wellknown/snapshots/webfinger.ts.snap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MaddyUnderStars/shoot/4f18d2329dfc97620b2de8a7db7b73bcc6bc54cf/test/http/wellknown/snapshots/webfinger.ts.snap -------------------------------------------------------------------------------- /test/http/wellknown/webfinger.ts: -------------------------------------------------------------------------------- 1 | import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi"; 2 | import { z } from "zod"; 3 | extendZodWithOpenApi(z); 4 | 5 | import test from "ava"; 6 | import { setupTests } from "../../helpers/env"; 7 | setupTests(test); 8 | 9 | import request from "supertest"; 10 | import { createTestDm } from "../../helpers/channel"; 11 | import { createTestUser } from "../../helpers/users"; 12 | 13 | test("finds users", async (t) => { 14 | const { APIServer } = await import("../../../src/http/server"); 15 | const api = new APIServer(); 16 | 17 | await createTestUser("wf_user1"); 18 | 19 | const res = await request(api.app) 20 | .get("/.well-known/webfinger?resource=wf_user1@localhost") 21 | .expect(200); 22 | 23 | t.snapshot(res.body); 24 | }); 25 | 26 | // TODO: Finding channels and guilds via webfinger 27 | // should require a http sig for a user with access to the resource 28 | test("finds channels", async (t) => { 29 | const { APIServer } = await import("../../../src/http/server"); 30 | const api = new APIServer(); 31 | 32 | await createTestUser("wf_c_user1"); 33 | await createTestUser("wf_c_user2"); 34 | 35 | const dm = await createTestDm("dm", "wf_c_user1@localhost", [ 36 | "wf_c_user2@localhost", 37 | ]); 38 | 39 | dm.id = "d9a9e2ac-e077-4f4d-8f30-6844a87e2686"; // known id for snapshot 40 | await dm.save(); 41 | 42 | const res = await request(api.app) 43 | .get(`/.well-known/webfinger?resource=${dm.id}@localhost`) 44 | .expect(200); 45 | 46 | t.snapshot(res.body); 47 | }); 48 | -------------------------------------------------------------------------------- /test/template.ts.disabled: -------------------------------------------------------------------------------- 1 | import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi"; 2 | import { z } from "zod"; 3 | extendZodWithOpenApi(z); 4 | 5 | import test from "ava"; 6 | import { setupTests } from "./helpers"; 7 | setupTests(test); 8 | 9 | import request from "supertest"; 10 | 11 | test("Template", async (t) => { 12 | const { APIServer } = await import("../src/http/server"); 13 | const api = new APIServer(); 14 | 15 | request... 16 | }); 17 | -------------------------------------------------------------------------------- /test/unit/httpsig.ts: -------------------------------------------------------------------------------- 1 | import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi"; 2 | import { z } from "zod"; 3 | 4 | extendZodWithOpenApi(z); 5 | 6 | import type { APActivity } from "activitypub-types"; 7 | import test from "ava"; 8 | import { setupTests } from "../helpers/env"; 9 | 10 | setupTests(test); 11 | 12 | test("Using Instance Actor", async (t) => { 13 | const { signWithHttpSignature, validateHttpSignature } = await import( 14 | "../../src/util/activitypub/httpsig" 15 | ); 16 | const { User } = await import("../../src/entity/user"); 17 | const { InstanceActor } = await import( 18 | "../../src/util/activitypub/instanceActor" 19 | ); 20 | 21 | const actor = await User.create({ 22 | ...InstanceActor, 23 | name: "remote_user", 24 | remote_address: "http://localhost/users/remote_user", 25 | id: undefined, 26 | }).save(); 27 | 28 | const signed = signWithHttpSignature( 29 | "https://chat.understars.dev/inbox", 30 | "GET", 31 | actor, 32 | ); 33 | 34 | await validateHttpSignature( 35 | "/inbox", 36 | "GET", 37 | //@ts-ignore 38 | signed.headers, 39 | ); 40 | 41 | t.pass(); 42 | }); 43 | 44 | test("Using Instance Actor with Activity", async (t) => { 45 | const { signWithHttpSignature, validateHttpSignature } = await import( 46 | "../../src/util/activitypub/httpsig" 47 | ); 48 | const { User } = await import("../../src/entity/user"); 49 | const { InstanceActor } = await import( 50 | "../../src/util/activitypub/instanceActor" 51 | ); 52 | 53 | const actor = await User.create({ 54 | ...InstanceActor, 55 | name: "remote_user2", 56 | remote_address: "http://localhost/users/remote_user2", 57 | id: undefined, 58 | }).save(); 59 | 60 | const activity: APActivity = { 61 | id: "http://localhost/test_activity", 62 | attributedTo: "http://localhost/users/remote_user2", 63 | type: "Test", 64 | }; 65 | 66 | const signed = signWithHttpSignature( 67 | "https://chat.understars.dev/inbox", 68 | "GET", 69 | actor, 70 | JSON.stringify(activity), 71 | ); 72 | 73 | await validateHttpSignature( 74 | "/inbox", 75 | "GET", 76 | //@ts-ignore 77 | signed.headers, 78 | JSON.stringify(activity), 79 | ); 80 | 81 | t.pass(); 82 | }); 83 | -------------------------------------------------------------------------------- /test/unit/sender.ts: -------------------------------------------------------------------------------- 1 | import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi"; 2 | import { z } from "zod"; 3 | extendZodWithOpenApi(z); 4 | 5 | import Sinon from "sinon"; 6 | 7 | import test from "ava"; 8 | import { setupTests } from "../helpers/env"; 9 | setupTests(test); 10 | 11 | import { createTestRemoteUser } from "../helpers/users"; 12 | 13 | test("Sends to shared inboxes", async (t) => { 14 | const { sendActivity } = await import("../../src/sender"); 15 | 16 | const actors = await Promise.all([ 17 | createTestRemoteUser("remote", "https://example.com"), 18 | createTestRemoteUser("remote2", "https://example.com"), 19 | createTestRemoteUser("other", "https://other.example.com"), 20 | createTestRemoteUser("another", "https://another.example.com"), 21 | ]); 22 | 23 | const expected = [ 24 | "https://example.com/inbox", 25 | "https://other.example.com/users/other/inbox", 26 | "https://another.example.com/users/another/inbox", 27 | ]; 28 | 29 | t.plan(expected.length); 30 | 31 | Sinon.stub(globalThis, "fetch").callsFake((url) => { 32 | t.assert(expected.includes(url.toString())); 33 | return Promise.resolve(new Response(null, { status: 202 })); 34 | }); 35 | 36 | await sendActivity(actors, { type: "ping" }); 37 | }); 38 | -------------------------------------------------------------------------------- /test/webrtc/call.ts: -------------------------------------------------------------------------------- 1 | import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi"; 2 | import { z } from "zod"; 3 | extendZodWithOpenApi(z); 4 | 5 | import test from "ava"; 6 | import { setupTests } from "../helpers/env"; 7 | setupTests(test); 8 | 9 | import request from "supertest"; 10 | import { createTestDm } from "../helpers/channel"; 11 | import { createTestUser } from "../helpers/users"; 12 | 13 | test("Get valid media token", async (t) => { 14 | const { APIServer } = await import("../../src/http/server"); 15 | const api = new APIServer(); 16 | 17 | const user1 = await createTestUser("user1"); 18 | const user2 = await createTestUser("user2"); 19 | const dm = await createTestDm("dm", "user1@localhost", ["user2@localhost"]); 20 | 21 | const { body } = await request(api.app) 22 | .post(`/channel/${dm.mention}/call`) 23 | .auth(user1, { type: "bearer" }) 24 | .expect(200); 25 | 26 | const { validateMediaToken } = await import("../../src/util/voice"); 27 | 28 | await validateMediaToken(body.token); 29 | 30 | t.pass(); 31 | }); 32 | -------------------------------------------------------------------------------- /todo.md: -------------------------------------------------------------------------------- 1 | - move dm channels to be just normal guild channels, but hide the guilds from the user --------------------------------------------------------------------------------