Bladeren bron

Initial mirror from https://github.com/stoatchat/self-hosted.git

This repository was automatically mirrored.
mitch donaberger 3 maanden geleden
commit
8edb6ef619

BIN
.github/guide/cloudflare-dns.webp


BIN
.github/guide/hostinger-1.location.webp


BIN
.github/guide/hostinger-2.os.webp


BIN
.github/guide/hostinger-3.malware.webp


BIN
.github/guide/hostinger-4.configuration.webp


BIN
.github/guide/hostinger-5.configuration.webp


BIN
.github/guide/hostinger-6.complete.webp


BIN
.github/guide/hostinger-7.wait.webp


BIN
.github/guide/hostinger-8.connect.webp


BIN
.github/guide/hostinger-9.panel.webp


+ 54 - 0
.github/workflows/triage_issue.yml

@@ -0,0 +1,54 @@
+name: Add Issue to Board
+
+on:
+  issues:
+    types: [opened]
+
+jobs:
+  track_issue:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Get project data
+        env:
+          GITHUB_TOKEN: ${{ secrets.PAT }}
+        run: |
+          gh api graphql -f query='
+            query {
+              organization(login: "revoltchat"){
+                projectV2(number: 3) {
+                  id
+                  fields(first:20) {
+                    nodes {
+                      ... on ProjectV2SingleSelectField {
+                        id
+                        name
+                        options {
+                          id
+                          name
+                        }
+                      }
+                    }
+                  }
+                }
+              }
+            }' > project_data.json
+
+          echo 'PROJECT_ID='$(jq '.data.organization.projectV2.id' project_data.json) >> $GITHUB_ENV
+          echo 'STATUS_FIELD_ID='$(jq '.data.organization.projectV2.fields.nodes[] | select(.name== "Status") | .id' project_data.json) >> $GITHUB_ENV
+          echo 'TODO_OPTION_ID='$(jq '.data.organization.projectV2.fields.nodes[] | select(.name== "Status") | .options[] | select(.name=="Todo") |.id' project_data.json) >> $GITHUB_ENV
+
+      - name: Add issue to project
+        env:
+          GITHUB_TOKEN: ${{ secrets.PAT }}
+          ISSUE_ID: ${{ github.event.issue.node_id }}
+        run: |
+          item_id="$( gh api graphql -f query='
+            mutation($project:ID!, $issue:ID!) {
+              addProjectV2ItemById(input: {projectId: $project, contentId: $issue}) {
+                item {
+                  id
+                }
+              }
+            }' -f project=$PROJECT_ID -f issue=$ISSUE_ID --jq '.data.addProjectV2ItemById.item.id')"
+
+          echo 'ITEM_ID='$item_id >> $GITHUB_ENV

+ 79 - 0
.github/workflows/triage_pr.yml

@@ -0,0 +1,79 @@
+name: Add PR to Board
+
+on:
+  pull_request_target:
+    types: [opened, synchronize, ready_for_review, review_requested]
+
+jobs:
+  track_pr:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Get project data
+        env:
+          GITHUB_TOKEN: ${{ secrets.PAT }}
+        run: |
+          gh api graphql -f query='
+            query {
+              organization(login: "revoltchat"){
+                projectV2(number: 5) {
+                  id
+                  fields(first:20) {
+                    nodes {
+                      ... on ProjectV2SingleSelectField {
+                        id
+                        name
+                        options {
+                          id
+                          name
+                        }
+                      }
+                    }
+                  }
+                }
+              }
+            }' > project_data.json
+
+          echo 'PROJECT_ID='$(jq '.data.organization.projectV2.id' project_data.json) >> $GITHUB_ENV
+          echo 'STATUS_FIELD_ID='$(jq '.data.organization.projectV2.fields.nodes[] | select(.name== "Status") | .id' project_data.json) >> $GITHUB_ENV
+          echo 'INCOMING_OPTION_ID='$(jq '.data.organization.projectV2.fields.nodes[] | select(.name== "Status") | .options[] | select(.name=="🆕 Untriaged") |.id' project_data.json) >> $GITHUB_ENV
+
+      - name: Add PR to project
+        env:
+          GITHUB_TOKEN: ${{ secrets.PAT }}
+          PR_ID: ${{ github.event.pull_request.node_id }}
+        run: |
+          item_id="$( gh api graphql -f query='
+            mutation($project:ID!, $pr:ID!) {
+              addProjectV2ItemById(input: {projectId: $project, contentId: $pr}) {
+                item {
+                  id
+                }
+              }
+            }' -f project=$PROJECT_ID -f pr=$PR_ID --jq '.data.addProjectV2ItemById.item.id')"
+
+          echo 'ITEM_ID='$item_id >> $GITHUB_ENV
+
+      - name: Set fields
+        env:
+          GITHUB_TOKEN: ${{ secrets.PAT }}
+        run: |
+          gh api graphql -f query='
+            mutation (
+              $project: ID!
+              $item: ID!
+              $status_field: ID!
+              $status_value: String!
+            ) {
+              set_status: updateProjectV2ItemFieldValue(input: {
+                projectId: $project
+                itemId: $item
+                fieldId: $status_field
+                value: {
+                  singleSelectOptionId: $status_value
+                }
+              }) {
+                projectV2Item {
+                  id
+                }
+              }
+            }' -f project=$PROJECT_ID -f item=$ITEM_ID -f status_field=$STATUS_FIELD_ID -f status_value=${{ env.INCOMING_OPTION_ID }} --silent

+ 7 - 0
.gitignore

@@ -0,0 +1,7 @@
+data*
+
+.env
+.env.web
+Revolt.toml
+
+compose.override.yml

+ 38 - 0
Caddyfile

@@ -0,0 +1,38 @@
+{$HOSTNAME} {
+	route /api* {
+		uri strip_prefix /api
+		reverse_proxy http://api:14702 {
+			header_down Location "^/" "/api/"
+		}
+	}
+
+	route /ws {
+		uri strip_prefix /ws
+		reverse_proxy http://events:14703 {
+			header_down Location "^/" "/ws/"
+		}
+	}
+
+	route /autumn* {
+		uri strip_prefix /autumn
+		reverse_proxy http://autumn:14704 {
+			header_down Location "^/" "/autumn/"
+		}
+	}
+
+	route /january* {
+		uri strip_prefix /january
+		reverse_proxy http://january:14705 {
+			header_down Location "^/" "/january/"
+		}
+	}
+
+	route /gifbox* {
+		uri strip_prefix /gifbox
+		reverse_proxy http://gifbox:14706 {
+			header_down Location "^/" "/gifbox/"
+		}
+	}
+
+	reverse_proxy http://web:5000
+}

+ 426 - 0
README.md

@@ -0,0 +1,426 @@
+![Notice, this repository was mirrored to here from Github](https://m1s5.c20.e2-5.dev/files/images/mirror-notice.svg)
+
+<div align="center">
+<h1>
+  Stoat Self-Hosted
+  
+  [![Stars](https://img.shields.io/github/stars/revoltchat/self-hosted?style=flat-square&logoColor=white)](https://github.com/revoltchat/self-hosted/stargazers)
+  [![Forks](https://img.shields.io/github/forks/revoltchat/self-hosted?style=flat-square&logoColor=white)](https://github.com/revoltchat/self-hosted/network/members)
+  [![Pull Requests](https://img.shields.io/github/issues-pr/revoltchat/self-hosted?style=flat-square&logoColor=white)](https://github.com/revoltchat/self-hosted/pulls)
+  [![Issues](https://img.shields.io/github/issues/revoltchat/self-hosted?style=flat-square&logoColor=white)](https://github.com/revoltchat/self-hosted/issues)
+  [![Contributors](https://img.shields.io/github/contributors/revoltchat/self-hosted?style=flat-square&logoColor=white)](https://github.com/revoltchat/self-hosted/graphs/contributors)
+  [![License](https://img.shields.io/github/license/revoltchat/self-hosted?style=flat-square&logoColor=white)](https://github.com/revoltchat/self-hosted/blob/main/LICENSE)
+</h1>
+Self-hosting Stoat using Docker
+</div>
+<br/>
+
+This repository contains configurations and instructions that can be used for deploying a full instance of Stoat, including the back-end, web front-end, file server, and metadata and image proxy.
+
+> [!WARNING]
+> If you are updating an instance from before November 28, 2024, please consult the [notices section](#notices) at the bottom.
+
+> [!IMPORTANT]
+> A list of security advisories is [provided at the bottom](#security-advisories).
+
+> [!NOTE]
+> Please consult _[What can I do with Stoat, and how do I self-host?](https://developers.revolt.chat/faq.html#admonition-what-can-i-do-with-revolt-and-how-do-i-self-host)_ on our developer site for information about licensing and brand use.
+
+> [!NOTE]
+> amd64 builds are not currently available for the web client.
+
+> [!NOTE]
+> This guide does not include working voice channels ([#138](https://github.com/revoltchat/self-hosted/pull/138#issuecomment-2762682655)). A [rework](https://github.com/revoltchat/backend/issues/313) is currently in progress.
+
+## Table of Contents
+
+- [Deployment](#deployment)
+- [Updating](#updating)
+- [Advanced Deployment](#advanced-deployment)
+- [Additional Notes](#additional-notes)
+  - [Custom Domain](#custom-domain)
+  - [Placing Behind Another Reverse-Proxy or Another Port](#placing-behind-another-reverse-proxy-or-another-port)
+  - [Insecurely Expose the Database](#insecurely-expose-the-database)
+  - [Mongo Compatibility](#mongo-compatibility)
+  - [Making Your Instance Invite-only](#making-your-instance-invite-only)
+- [Notices](#notices)
+- [Security Advisories](#security-advisories)
+
+## Deployment
+
+To get started, find yourself a suitable server to deploy onto, we recommend starting with at least 2 vCPUs and 2 GB of memory.
+
+> [!TIP]
+>
+> **We've partnered with Hostinger to bring you a 20% discount off VPS hosting!**
+>
+> 👉 https://www.hostinger.com/vps-hosting?REFERRALCODE=REVOLTCHAT
+>
+> We recommend using the _KVM 2_ plan at minimum!\
+> Our testing environment for self-hosted currently sits on a KVM 2 instance, and we are happy to assist with issues.
+
+The instructions going forward will use Hostinger as an example hosting platform, but you should be able to adapt these to other platforms as necessary. There are important details throughout.
+
+![Select the location](.github/guide/hostinger-1.location.webp)
+
+When asked, choose **Ubuntu Server** as your operating system; this is used by us in production, and we recommend its use.
+
+![Select the operating system](.github/guide/hostinger-2.os.webp)
+
+If you've chosen to go with Hostinger, they include integrated malware scanning, which may be of interest:
+
+![Consider malware scanning](.github/guide/hostinger-3.malware.webp)
+
+You should set a secure root password for login (_or disable password login after setup, which is explained later! but you shouldn't make the password trivial until after this is secured at least!_) and we recommend that you configure an SSH key:
+
+![Configuration unfilled](.github/guide/hostinger-4.configuration.webp)
+![Configuration filled](.github/guide/hostinger-5.configuration.webp)
+
+Make sure to confirm everything is correct!
+
+![Confirmation](.github/guide/hostinger-6.complete.webp)
+
+Wait for your VPS to be created...
+
+| ![Wait for creation](.github/guide/hostinger-7.wait.webp) | ![Wait for creation](.github/guide/hostinger-8.connect.webp) |
+| --------------------------------------------------------- | ------------------------------------------------------------ |
+
+After installation, SSH into the machine:
+
+```bash
+# use the provided IP address to connect:
+ssh root@<ip address>
+# .. if you have a SSH key configured
+ssh root@<ip address> -i path/to/id_rsa
+```
+
+And now we can proceed with some basic configuration and securing the system:
+
+```bash
+# update the system
+apt-get update && apt-get upgrade -y
+
+# configure firewall
+ufw allow ssh
+ufw allow http
+ufw allow https
+ufw default deny
+ufw enable
+
+# if you have configured an SSH key, disable password authentication:
+sudo sed -E -i 's|^#?(PasswordAuthentication)\s.*|\1 no|' /etc/ssh/sshd_config
+if ! grep '^PasswordAuthentication\s' /etc/ssh/sshd_config; then echo 'PasswordAuthentication no' |sudo tee -a /etc/ssh/sshd_config; fi
+
+# reboot to apply changes
+reboot
+```
+
+Your system is now ready to proceed with installation, but before we continue, you should configure your domain.
+
+![Cloudflare DNS configuration](.github/guide/cloudflare-dns.webp)
+
+Your domain (or a subdomain) should point to the server's IP (A and AAAA records) or CNAME to the hostname provided.
+
+Next, we must install the required dependencies:
+
+```bash
+# ensure Git and Docker are installed
+apt-get update
+apt-get install ca-certificates curl git micro
+install -m 0755 -d /etc/apt/keyrings
+curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
+chmod a+r /etc/apt/keyrings/docker.asc
+
+echo \
+  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
+  $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
+  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
+
+apt-get update
+apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
+```
+
+Now, we can pull in the configuration for Stoat:
+
+```bash
+git clone https://github.com/revoltchat/self-hosted stoat
+cd stoat
+```
+
+Generate a configuration file by running:
+
+```bash
+chmod +x ./generate_config.sh
+./generate_config.sh your.domain
+```
+
+You can find [more options here](https://github.com/revoltchat/backend/blob/stable/crates/core/config/Revolt.toml), some noteworthy configuration options:
+
+- Email verification
+- Captcha
+- A custom S3 server
+- iOS & Android notifications (Requires Apple/Google developer accounts)
+
+If you'd like to edit the configuration, just run:
+
+```bash
+micro Revolt.toml
+```
+
+Finally, we can start up Stoat. First, run it in the foreground with:
+
+```bash
+docker compose up
+```
+
+If it runs without any critical errors, you can stop it with <kbd>Ctrl</kbd> + <kbd>C</kbd> and run it detached (in the background) by appending `-d`.
+
+```bash
+docker compose up -d
+```
+
+## Updating
+
+Before updating, ensure you consult the notices at the top of this README, **as well as** [the notices](#notices) at the bottom, to check if there are any important changes to be aware of.
+
+Pull the latest version of this repository:
+
+```bash
+git pull
+```
+
+Check if your configuration file is correct by opening [the reference config file](https://github.com/revoltchat/backend/blob/df074260196f5ed246e6360d8e81ece84d8d9549/crates/core/config/Revolt.toml) and your `Revolt.toml` to compare changes.
+
+Then pull all the latest images:
+
+```bash
+docker compose pull
+```
+
+Then restart the services:
+
+```bash
+docker compose up -d
+```
+
+## Advanced Deployment
+
+This guide assumes you know your way around a Linux terminal and Docker.
+
+Prerequisites before continuing:
+
+- [Git](https://git-scm.com)
+- [Docker](https://www.docker.com)
+
+Clone this repository.
+
+```bash
+git clone https://github.com/revoltchat/self-hosted stoat
+cd stoat
+```
+
+Create `.env.web` and download `Revolt.toml`, then modify them according to your requirements.
+
+> [!WARNING]
+> The default configurations are intended exclusively for testing and will only work locally. If you wish to deploy to a remote server, you **must** edit the URLs in `.env.web` and `Revolt.toml`. Please reference the section below on [configuring a custom domain](#custom-domain).
+
+```bash
+echo "HOSTNAME=http://local.stoat.chat" > .env.web
+echo "REVOLT_PUBLIC_URL=http://local.stoat.chat/api" >> .env.web
+wget -O Revolt.toml https://raw.githubusercontent.com/revoltchat/backend/main/crates/core/config/Revolt.toml
+```
+
+Then start Stoat:
+
+```bash
+docker compose up -d
+```
+
+## Additional Notes
+
+### Custom Domain
+
+To configure a custom domain, you can either generate a config for https by running:
+
+```
+chmod +x ./generate_config.sh
+./generate_config.sh your.domain
+```
+
+Or alternatively do it manually, you will need to replace _all_ instances of `local.stoat.chat` in `Revolt.toml` and `.env.web` to your chosen domain (here represented as `example.com`), like so:
+
+```diff
+# .env.web
+- REVOLT_PUBLIC_URL=http://local.stoat.chat/api
++ REVOLT_PUBLIC_URL=http://example.com/api
+```
+
+```diff
+# Revolt.toml
+- app = "http://local.stoat.chat"
++ app = "http://example.com"
+```
+
+In the case of `HOSTNAME`, you must strip the protocol prefix:
+
+```diff
+# .env.web
+- HOSTNAME=http://example.com
++ HOSTNAME=example.com
+```
+
+You will likely also want to change the protocols to enable HTTPS:
+
+```diff
+# .env.web
+- REVOLT_PUBLIC_URL=http://example.com/api
++ REVOLT_PUBLIC_URL=https://example.com/api
+```
+
+```diff
+# Revolt.toml
+- app = "http://example.com"
++ app = "https://example.com"
+
+- events = "ws://example.com/ws"
++ events = "wss://example.com/ws"
+```
+
+### Placing Behind Another Reverse-Proxy or Another Port
+
+If you'd like to place Stoat behind another reverse proxy or on a non-standard port, you'll need to edit `compose.yml`.
+
+Override the port definitions on `caddy`:
+
+```yml
+# compose.yml
+services:
+  caddy:
+    ports:
+      - "1234:80"
+```
+
+> [!WARNING]
+> This file is not included in `.gitignore`. It may be sufficient to use an override file, but that will not remove port `80` / `443` allocations.
+
+Update the hostname used by the web server:
+
+```diff
+# .env.web
+- HOSTNAME=http://example.com
++ HOSTNAME=:80
+```
+
+You can now reverse proxy to <http://localhost:1234>.
+
+### Insecurely Expose the Database
+
+You can insecurely expose the database by adding a port definition:
+
+```yml
+# compose.override.yml
+services:
+  database:
+    ports:
+      - "27017:27017"
+```
+
+For obvious reasons, be careful doing this.
+
+### Mongo Compatibility
+
+Older processors may not support the latest MongoDB version; you may pin to MongoDB 4.4 as such:
+
+```yml
+# compose.override.yml
+services:
+  database:
+    image: mongo:4.4
+```
+
+### Making Your Instance Invite-only
+
+Enable invite-only mode by setting `invite_only` in `Revolt.toml` to `true`.
+
+Create an invite:
+
+```bash
+# drop into mongo shell
+docker compose exec database mongosh
+
+# create the invite
+use revolt
+db.invites.insertOne({ _id: "enter_an_invite_code_here" })
+```
+
+## Notices
+
+> [!IMPORTANT]
+> If you deployed Stoat before [2022-10-29](https://github.com/minio/docs/issues/624#issuecomment-1296608406), you may have to tag the `minio` image release if it's configured in "fs" mode.
+>
+> ```yml
+> image: minio/minio:RELEASE.2022-10-24T18-35-07Z
+> ```
+
+> [!IMPORTANT]
+> If you deployed Stoat before [2023-04-21](https://github.com/revoltchat/backend/commit/32542a822e3de0fc8cc7b29af46c54a9284ee2de), you may have to flush your Redis database.
+>
+> ```bash
+> # for stock Redis and older KeyDB images:
+> docker compose exec redis redis-cli
+> # ...or for newer KeyDB images:
+> docker compose exec redis keydb-cli
+>
+> # then run:
+> FLUSHDB
+> ```
+
+> [!IMPORTANT]
+> As of 30th September 2024, Autumn has undergone a major refactor, which requires a manual migration.
+>
+> To begin, add a temporary container that we can work from:
+>
+> ```yml
+> # compose.override.yml
+> services:
+>   migration:
+>     image: node:21
+>     volumes:
+>       - ./migrations:/cwd
+>     command: "bash -c 'while true; do sleep 86400; done'"
+> ```
+>
+> Then switch to the shell:
+>
+> ```bash
+> docker compose up -d database migration
+> docker compose exec migration bash
+> ```
+>
+> Now we can run the migration:
+>
+> ```bash
+> cd /cwd
+> npm i mongodb
+> node ./20240929-autumn-rewrite.mjs
+> ```
+
+> [!IMPORTANT]
+> As of November 28, 2024, the following breaking changes have been applied:
+>
+> - Rename config section `api.vapid` -> `pushd.vapid`
+> - Rename config section `api.fcm` -> `pushd.fcm`
+> - Rename config section `api.apn` -> `pushd.apn`
+>
+> These will NOT automatically be applied to your config and must be changed/added manually.
+>
+> The following components have been added to the compose file:
+>
+> - Added `rabbit` (RabbitMQ) and `pushd` (Stoat push daemon)
+
+## Security Advisories
+
+- (`2024-06-21`) [GHSA-f26h-rqjq-qqjq revoltchat/backend: Unrestricted account creation.](https://github.com/revoltchat/backend/security/advisories/GHSA-f26h-rqjq-qqjq)
+- (`2024-12-17`) [GHSA-7f9x-pm3g-j7p4 revoltchat/january: January service can call itself recursively, causing heavy load.](https://github.com/revoltchat/january/security/advisories/GHSA-7f9x-pm3g-j7p4)
+- (`2025-02-10`) [GHSA-8684-rvfj-v3jq revoltchat/backend: Webhook tokens are freely accessible for users with read permissions.](https://github.com/revoltchat/backend/security/advisories/GHSA-8684-rvfj-v3jq)
+- (`2025-02-10`) [GHSA-h7h6-7pxm-mc66 revoltchat/backend: Nearby message fetch requests can be crafted to fetch entire message history.](https://github.com/revoltchat/backend/security/advisories/GHSA-h7h6-7pxm-mc66)

+ 185 - 0
compose.yml

@@ -0,0 +1,185 @@
+name: stoat
+
+services:
+  # MongoDB: Database
+  database:
+    image: docker.io/mongo
+    restart: always
+    volumes:
+      - ./data/db:/data/db
+    healthcheck:
+      test: echo 'db.runCommand("ping").ok' | mongosh localhost:27017/test --quiet
+      interval: 10s
+      timeout: 10s
+      retries: 5
+      start_period: 10s
+
+  # Redis: Event message broker & KV store
+  redis:
+    image: docker.io/eqalpha/keydb
+    restart: always
+
+  # RabbitMQ: Internal message broker
+  rabbit:
+    image: docker.io/rabbitmq:4
+    restart: always
+    environment:
+      RABBITMQ_DEFAULT_USER: rabbituser
+      RABBITMQ_DEFAULT_PASS: rabbitpass
+    volumes:
+      - ./data/rabbit:/var/lib/rabbitmq
+    healthcheck:
+      test: rabbitmq-diagnostics -q ping
+      interval: 10s
+      timeout: 10s
+      retries: 3
+      start_period: 20s
+
+  # MinIO: S3-compatible storage server
+  minio:
+    image: docker.io/minio/minio
+    command: server /data
+    volumes:
+      - ./data/minio:/data
+    environment:
+      MINIO_ROOT_USER: minioautumn
+      MINIO_ROOT_PASSWORD: minioautumn
+      MINIO_DOMAIN: minio
+    networks:
+      default:
+        aliases:
+          - revolt-uploads.minio
+          # legacy support:
+          - attachments.minio
+          - avatars.minio
+          - backgrounds.minio
+          - icons.minio
+          - banners.minio
+          - emojis.minio
+    restart: always
+
+  # Caddy: Web server
+  caddy:
+    image: docker.io/caddy
+    restart: always
+    env_file: .env.web
+    ports:
+      - "80:80"
+      - "443:443"
+    volumes:
+      - ./Caddyfile:/etc/caddy/Caddyfile
+      - ./data/caddy-data:/data
+      - ./data/caddy-config:/config
+
+  # API server
+  api:
+    image: ghcr.io/revoltchat/server:20250930-2
+    depends_on:
+      database:
+        condition: service_healthy
+      redis:
+        condition: service_started
+      rabbit:
+        condition: service_healthy
+    volumes:
+      - type: bind
+        source: ./Revolt.toml
+        target: /Revolt.toml
+    restart: always
+
+  # Events service
+  events:
+    image: ghcr.io/revoltchat/bonfire:20250930-2
+    depends_on:
+      database:
+        condition: service_healthy
+      redis:
+        condition: service_started
+    volumes:
+      - type: bind
+        source: ./Revolt.toml
+        target: /Revolt.toml
+    restart: always
+
+  # Web App
+  web:
+    image: ghcr.io/revoltchat/client:master
+    restart: always
+    env_file: .env.web
+
+  # File server
+  autumn:
+    image: ghcr.io/revoltchat/autumn:20250930-2
+    depends_on:
+      database:
+        condition: service_healthy
+      createbuckets:
+        condition: service_started
+    volumes:
+      - type: bind
+        source: ./Revolt.toml
+        target: /Revolt.toml
+    restart: always
+
+  # Metadata and image proxy
+  january:
+    image: ghcr.io/revoltchat/january:20250930-2
+    volumes:
+      - type: bind
+        source: ./Revolt.toml
+        target: /Revolt.toml
+    restart: always
+
+  # Tenor proxy
+  gifbox:
+    image: ghcr.io/revoltchat/gifbox:20250930-2
+    volumes:
+      - type: bind
+        source: ./Revolt.toml
+        target: /Revolt.toml
+    restart: always
+
+  # Regular task daemon
+  crond:
+    image: ghcr.io/revoltchat/crond:20250930-2
+    depends_on:
+      database:
+        condition: service_healthy
+      minio:
+        condition: service_started
+    volumes:
+      - type: bind
+        source: ./Revolt.toml
+        target: /Revolt.toml
+    restart: always
+
+  # Push notification daemon
+  pushd:
+    image: ghcr.io/revoltchat/pushd:20250930-2
+    depends_on:
+      database:
+        condition: service_healthy
+      redis:
+        condition: service_started
+      rabbit:
+        condition: service_healthy
+    volumes:
+      - type: bind
+        source: ./Revolt.toml
+        target: /Revolt.toml
+    restart: always
+
+  # Create buckets for minio.
+  createbuckets:
+    image: docker.io/minio/mc
+    depends_on:
+      - minio
+    entrypoint: >
+      /bin/sh -c "
+      while ! /usr/bin/mc ready minio; do
+        /usr/bin/mc alias set minio http://minio:9000 minioautumn minioautumn;
+        echo 'Waiting minio...' && sleep 1;
+      done;
+      /usr/bin/mc mb minio/revolt-uploads;
+      exit 0;
+      "

+ 26 - 0
generate_config.sh

@@ -0,0 +1,26 @@
+#!/usr/bin/env bash
+
+# set hostname for Caddy
+echo "HOSTNAME=https://$1" > .env.web
+echo "REVOLT_PUBLIC_URL=https://$1/api" >> .env.web
+
+# hostnames
+echo "[hosts]" >> Revolt.toml
+echo "app = \"https://$1\"" >> Revolt.toml
+echo "api = \"https://$1/api\"" >> Revolt.toml
+echo "events = \"wss://$1/ws\"" >> Revolt.toml
+echo "autumn = \"https://$1/autumn\"" >> Revolt.toml
+echo "january = \"https://$1/january\"" >> Revolt.toml
+
+# VAPID keys
+echo "" >> Revolt.toml
+echo "[pushd.vapid]" >> Revolt.toml
+openssl ecparam -name prime256v1 -genkey -noout -out vapid_private.pem
+echo "private_key = \"$(base64 -i vapid_private.pem | tr -d '\n' | tr -d '=')\"" >> Revolt.toml
+echo "public_key = \"$(openssl ec -in vapid_private.pem -outform DER|tail --bytes 65|base64|tr '/+' '_-'|tr -d '\n'|tr -d '=')\"" >> Revolt.toml
+rm vapid_private.pem
+
+# encryption key for files
+echo "" >> Revolt.toml
+echo "[files]" >> Revolt.toml
+echo "encryption_key = \"$(openssl rand -base64 32)\"" >> Revolt.toml

+ 2 - 0
migrations/.gitignore

@@ -0,0 +1,2 @@
+*.json
+node_modules

+ 663 - 0
migrations/20240929-autumn-rewrite---prod-migration.mjs

@@ -0,0 +1,663 @@
+// THIS FILE IS TAILORED TO REVOLT PRODUCTION
+// MIGRATING FROM A BACKUP & EXISTING CDN NODE
+// INTO BACKBLAZE B2
+//
+// THIS IS ONLY INCLUDED FOR REFERENCE PURPOSES
+
+// NODE_EXTRA_CA_CERTS=~/projects/revolt-admin-panel/revolt.crt node index.mjs
+// NODE_EXTRA_CA_CERTS=/cwd/revolt.crt node /cwd/index.mjs
+
+import { readdir, readFile, writeFile } from "node:fs/promises";
+import { createCipheriv, createHash, randomBytes } from "node:crypto";
+import { resolve } from "node:path";
+import { MongoClient } from "mongodb";
+import { config } from "dotenv";
+import assert from "node:assert";
+import bfj from "bfj";
+config();
+config({ path: "/cwd/.env" });
+
+import BackBlazeB2 from "backblaze-b2";
+import axiosRetry from "axios-retry";
+import { decodeTime } from "ulid";
+
+// .env:
+// ENCRYPTION_KEY=
+// MONGODB=
+// B2_APP_KEYID=
+// B2_APP_KEY=
+
+/**
+ * @type {string | null}
+ */
+const USE_CACHE = "/cwd/cache.json";
+let processed_ids = new Set();
+
+async function dumpCache() {
+  if (USE_CACHE) await bfj.write(USE_CACHE, [...processed_ids]);
+}
+
+if (USE_CACHE) {
+  try {
+    processed_ids = new Set(await bfj.read(USE_CACHE));
+  } catch (err) {
+    console.error(err);
+  }
+}
+
+const b2 = new BackBlazeB2({
+  applicationKeyId: process.env.B2_APP_KEYID,
+  applicationKey: process.env.B2_APP_KEY,
+  retry: {
+    retryDelay: axiosRetry.exponentialDelay,
+  },
+});
+
+await b2.authorize();
+
+//const encKey = Buffer.from(randomBytes(32), "utf8");
+//console.info(encKey.toString("base64"));
+const encKey = Buffer.from(process.env.ENCRYPTION_KEY, "base64");
+
+const mongo = new MongoClient(process.env.MONGODB);
+await mongo.connect();
+
+// TODO: set all existing files to current timestamp
+const dirs = [
+  // "banners",
+  // "emojis", // TODO: timestamps
+  // "avatars",
+  // "backgrounds",
+  // "icons",
+  "attachments", // https://stackoverflow.com/a/18777877
+];
+
+async function encryptFile(data) {
+  const iv = Buffer.from(randomBytes(12), "utf8");
+  const cipher = createCipheriv("aes-256-gcm", encKey, iv);
+
+  let enc = cipher.update(data, "utf8", "base64");
+  enc += cipher.final("base64");
+  //   enc += cipher.getAuthTag();
+
+  enc = Buffer.from(enc, "base64");
+
+  return {
+    iv,
+    data: Buffer.concat([enc, cipher.getAuthTag()]),
+  };
+}
+
+const cache = {};
+
+const objectLookup = {};
+
+/**
+ * aaa
+ */
+async function determineUploaderIdAndUse(f, v, i) {
+  if (f.tag === "attachments" && v === "attachments") {
+    if (typeof f.message_id !== "string") {
+      console.warn(i, "No message id specified.");
+      return null;
+    }
+
+    if (!objectLookup[f.message_id]) {
+      objectLookup[f.message_id] = await mongo
+        .db("revolt")
+        .collection("messages")
+        .findOne({
+          _id: f.message_id,
+        });
+    }
+
+    if (!objectLookup[f.message_id]) {
+      console.warn(i, "Message", f.message_id, "doesn't exist anymore!");
+      return null;
+    }
+
+    return {
+      uploaded_at: new Date(decodeTime(f.message_id)),
+      uploader_id: objectLookup[f.message_id].author,
+      used_for: {
+        type: "message",
+        id: f.message_id,
+      },
+    };
+  } else if (f.tag === "banners" && v === "banners") {
+    if (typeof f.server_id !== "string") {
+      console.warn(i, "No server id specified.");
+      return null;
+    }
+
+    if (!objectLookup[f.server_id]) {
+      objectLookup[f.server_id] = await mongo
+        .db("revolt")
+        .collection("servers")
+        .findOne({
+          _id: f.server_id,
+        });
+    }
+
+    if (!objectLookup[f.server_id]) {
+      console.warn(i, "Server", f.server_id, "doesn't exist anymore!");
+      return null;
+    }
+
+    return {
+      uploaded_at: new Date(),
+      uploader_id: objectLookup[f.server_id].owner,
+      used_for: {
+        type: "serverBanner",
+        id: f.server_id,
+      },
+    };
+  } else if (f.tag === "emojis" && v === "emojis") {
+    if (typeof f.object_id !== "string") {
+      return null;
+    }
+
+    if (!objectLookup[f.object_id]) {
+      objectLookup[f.object_id] = await mongo
+        .db("revolt")
+        .collection("emojis")
+        .findOne({
+          _id: f.object_id,
+        });
+    }
+
+    if (!objectLookup[f.object_id]) {
+      console.warn(i, "Emoji", f.object_id, "doesn't exist anymore!");
+      return null;
+    }
+
+    return {
+      uploaded_at: new Date(decodeTime(f.object_id)),
+      uploader_id: objectLookup[f.object_id].creator_id,
+      used_for: {
+        type: "emoji",
+        id: f.object_id,
+      },
+    };
+  } else if (f.tag === "avatars" && v === "avatars") {
+    if (typeof f.user_id !== "string") {
+      return null;
+    }
+
+    if (!objectLookup[f.user_id]) {
+      objectLookup[f.user_id] = await mongo
+        .db("revolt")
+        .collection("users")
+        .findOne({
+          _id: f.user_id,
+        });
+    }
+
+    if (!objectLookup[f.user_id]) {
+      console.warn(i, "User", f.user_id, "doesn't exist anymore!");
+      return null;
+    }
+
+    if (objectLookup[f.user_id].avatar?._id !== f._id) {
+      console.warn(
+        i,
+        "Attachment no longer in use.",
+        f._id,
+        "for",
+        f.user_id,
+        "current:",
+        objectLookup[f.user_id].avatar?._id
+      );
+      return null;
+    }
+
+    return {
+      uploaded_at: new Date(),
+      uploader_id: f.user_id,
+      used_for: {
+        type: "userAvatar",
+        id: f.user_id,
+      },
+    };
+  } else if (f.tag === "backgrounds" && v === "backgrounds") {
+    if (typeof f.user_id !== "string") {
+      return null;
+    }
+
+    if (!objectLookup[f.user_id]) {
+      objectLookup[f.user_id] = await mongo
+        .db("revolt")
+        .collection("users")
+        .findOne({
+          _id: f.user_id,
+        });
+    }
+
+    if (!objectLookup[f.user_id]) {
+      console.warn(i, "User", f.user_id, "doesn't exist anymore!");
+      return null;
+    }
+
+    if (objectLookup[f.user_id].profile?.background?._id !== f._id) {
+      console.warn(
+        i,
+        "Attachment no longer in use.",
+        f._id,
+        "for",
+        f.user_id,
+        "current:",
+        objectLookup[f.user_id].profile?.background?._id
+      );
+      return null;
+    }
+
+    return {
+      uploaded_at: new Date(),
+      uploader_id: f.user_id,
+      used_for: {
+        type: "userProfileBackground",
+        id: f.user_id,
+      },
+    };
+  } else if (f.tag === "icons" && v === "icons") {
+    if (typeof f.object_id !== "string") {
+      return null;
+    }
+
+    // some bugged files at start
+    // ... expensive to compute at worst case =(
+    // so instead we can just disable it until everything is processed
+    // then re-run on these!
+    if (false) {
+      objectLookup[f.object_id] = await mongo
+        .db("revolt")
+        .collection("users")
+        .findOne({
+          _id: f.object_id,
+        });
+
+      if (!objectLookup[f.object_id]) {
+        console.warn(i, "No legacy match!");
+        return null;
+      }
+
+      return {
+        uploaded_at: new Date(),
+        uploader_id: f.object_id,
+        used_for: {
+          type: "legacyGroupIcon",
+          id: f.object_id,
+        },
+      };
+    }
+
+    if (!objectLookup[f.object_id]) {
+      objectLookup[f.object_id] = await mongo
+        .db("revolt")
+        .collection("servers")
+        .findOne({
+          _id: f.object_id,
+        });
+    }
+
+    if (
+      !objectLookup[f.object_id] ||
+      // heuristic for not server
+      !objectLookup[f.object_id].channels
+    ) {
+      console.warn(i, "Server", f.object_id, "doesn't exist!");
+
+      if (!objectLookup[f.object_id]) {
+        objectLookup[f.object_id] = await mongo
+          .db("revolt")
+          .collection("channels")
+          .findOne({
+            _id: f.object_id,
+          });
+      }
+
+      if (!objectLookup[f.object_id]) {
+        console.warn(i, "Channel", f.object_id, "doesn't exist!");
+        return null;
+      }
+
+      let server;
+      const serverId = objectLookup[f.object_id].server;
+      if (serverId) {
+        server = objectLookup[serverId];
+
+        if (!server) {
+          server = await mongo.db("revolt").collection("servers").findOne({
+            _id: serverId,
+          });
+
+          console.info(
+            i,
+            "Couldn't find matching server for channel " + f.object_id + "!"
+          );
+          if (!server) return null;
+
+          objectLookup[serverId] = server;
+        }
+      }
+
+      return {
+        uploaded_at: new Date(),
+        uploader_id: (server ?? objectLookup[f.object_id]).owner,
+        used_for: {
+          type: "channelIcon",
+          id: f.object_id,
+        },
+      };
+    }
+
+    return {
+      uploaded_at: new Date(),
+      uploader_id: objectLookup[f.object_id].owner,
+      used_for: {
+        type: "serverIcon",
+        id: f.object_id,
+      },
+    };
+  } else {
+    throw (
+      "couldn't find uploader id for " +
+      f._id +
+      " expected " +
+      v +
+      " but got " +
+      f.tag
+    );
+  }
+}
+
+const workerCount = 8;
+let workingOnHashes = [];
+
+for (const dir of dirs) {
+  console.info(dir);
+
+  // const RESUME = 869000 + 283000 + 772000;
+
+  // UPLOAD FROM LOCAL FILE LISTING:
+  // const RESUME = 0;
+  // const files = (await readdir(dir)).slice(RESUME);
+  // const total = files.length;
+
+  // UPLOAD FROM DATABASE FILE LISTING:
+  const files = await mongo
+    .db("revolt")
+    .collection("attachments")
+    .find(
+      {
+        tag: dir,
+        // don't upload delete files
+        deleted: {
+          $ne: true,
+        },
+        // don't upload already processed files
+        hash: {
+          $exists: false,
+        },
+      },
+      {
+        projection: { _id: 1 },
+      }
+    )
+    .toArray()
+    .then((arr) => arr.map((x) => x._id));
+  const total = files.length;
+
+  let i = 0;
+  let skipsA = 0,
+    skipsB = 0;
+
+  await Promise.all(
+    new Array(workerCount).fill(0).map(async (_) => {
+      while (true) {
+        const file = files.shift();
+        if (!file) return;
+
+        i++;
+        console.info(i, files.length, file);
+        // if (i < 869000) continue; // TODO
+        // if (i > 3000) break;
+
+        if (USE_CACHE) {
+          if (processed_ids.has(file)) {
+            console.info(i, "Skip, known file.");
+            continue;
+          }
+        }
+
+        const doc = await mongo
+          .db("revolt")
+          .collection("attachments")
+          .findOne({
+            _id: file,
+            // don't upload delete files
+            deleted: {
+              $ne: true,
+            },
+            // don't upload already processed files
+            hash: {
+              $exists: false,
+            },
+          });
+
+        if (!doc) {
+          console.info(
+            i,
+            "Skipping as it does not exist in DB, is queued for deletion, or has already been processed!"
+          );
+          skipsA += 1;
+          continue;
+        }
+
+        const metaUseInfo = await determineUploaderIdAndUse(doc, dir, i);
+        if (!metaUseInfo) {
+          if (USE_CACHE) {
+            processed_ids.add(file);
+          }
+          console.info(i, "Skipping as it hasn't been attached to anything!");
+          skipsB += 1;
+          continue;
+        }
+
+        const start = +new Date();
+
+        let buff;
+        try {
+          buff = await readFile(resolve(dir, file));
+        } catch (err) {
+          if (err.code === "ENOENT") {
+            if (USE_CACHE) {
+              processed_ids.add(file);
+            }
+            console.log(i, "File not found!");
+            await mongo.db("revolt").collection("logs").insertOne({
+              type: "missingFile",
+              desc: "File doesn't exist!",
+              file,
+            });
+            continue;
+          } else {
+            throw err;
+          }
+        }
+
+        const hash = createHash("sha256").update(buff).digest("hex");
+
+        while (workingOnHashes.includes(hash)) {
+          console.log(
+            "Waiting to avoid race condition... hash is already being processed..."
+          );
+
+          await new Promise((r) => setTimeout(r, 1000));
+        }
+
+        workingOnHashes.push(hash);
+
+        // merge existing
+        const existingHash = await mongo
+          .db("revolt")
+          .collection("attachment_hashes")
+          .findOne({
+            _id: hash,
+          });
+
+        if (existingHash) {
+          console.info(i, "Hash already uploaded, merging!");
+
+          await mongo
+            .db("revolt")
+            .collection("attachments")
+            .updateOne(
+              {
+                _id: file,
+              },
+              {
+                $set: {
+                  size: existingHash.size,
+                  hash,
+                  ...metaUseInfo,
+                },
+              }
+            );
+
+          await mongo.db("revolt").collection("logs").insertOne({
+            type: "mergeHash",
+            desc: "Merged an existing file!",
+            hash: existingHash._id,
+            size: existingHash.size,
+          });
+
+          workingOnHashes = workingOnHashes.filter((x) => x !== hash);
+          continue;
+        }
+
+        // encrypt
+        const { iv, data } = await encryptFile(buff);
+        const end = +new Date();
+
+        console.info(metaUseInfo); // + write hash
+        console.info(
+          file,
+          hash,
+          iv,
+          `${end - start}ms`,
+          buff.byteLength,
+          "bytes"
+        );
+
+        let retry = true;
+        while (retry) {
+          try {
+            const urlResp = await b2.getUploadUrl({
+              bucketId: "---", // revolt-uploads
+            });
+
+            await b2.uploadFile({
+              uploadUrl: urlResp.data.uploadUrl,
+              uploadAuthToken: urlResp.data.authorizationToken,
+              fileName: hash,
+              data,
+              onUploadProgress: (event) => console.info(event),
+            });
+
+            await mongo
+              .db("revolt")
+              .collection("attachment_hashes")
+              .insertOne({
+                _id: hash,
+                processed_hash: hash,
+
+                created_at: new Date(), // TODO on all
+
+                bucket_id: "revolt-uploads",
+                path: hash,
+                iv: iv.toString("base64"),
+
+                metadata: doc.metadata,
+                content_type: doc.content_type,
+                size: data.byteLength,
+              });
+
+            await mongo
+              .db("revolt")
+              .collection("attachments")
+              .updateOne(
+                {
+                  _id: file,
+                },
+                {
+                  $set: {
+                    size: data.byteLength,
+                    hash,
+                    ...metaUseInfo,
+                  },
+                }
+              );
+
+            retry = false;
+          } catch (err) {
+            if (
+              (err.isAxiosError &&
+                (err.response?.status === 503 ||
+                  err.response?.status === 500)) ||
+              (err?.code === "ENOTFOUND" && err?.syscall === "getaddrinfo") ||
+              (err?.code === "ETIMEDOUT" && err?.syscall === "connect") ||
+              (err?.code === "ECONNREFUSED" && err?.syscall === "connect")
+            ) {
+              console.error(i, err.response.status, "ERROR RETRYING");
+
+              await mongo
+                .db("revolt")
+                .collection("logs")
+                .insertOne({
+                  type: "upload503",
+                  desc:
+                    "Hit status " +
+                    (err?.code === "ETIMEDOUT" && err?.syscall === "connect"
+                      ? "Network issue (ETIMEDOUT connect)"
+                      : err?.code === "ECONNREFUSED" &&
+                        err?.syscall === "connect"
+                      ? "Network issue (ECONNREFUSED connect)"
+                      : err?.code === "ENOTFOUND" &&
+                        err?.syscall === "getaddrinfo"
+                      ? "DNS issue (ENOTFOUND getaddrinfo)"
+                      : err.response?.status) +
+                    ", trying a new URL!",
+                  hash,
+                });
+
+              await new Promise((r) => setTimeout(() => r(), 1500));
+            } else {
+              await dumpCache().catch(console.error);
+              throw err;
+            }
+          }
+        }
+
+        console.info(i, "Successfully uploaded", file, "to S3!");
+        console.info(
+          "*** ➡️  Processed",
+          i,
+          "out of",
+          total,
+          "files",
+          ((i / total) * 100).toFixed(2),
+          "%"
+        );
+
+        workingOnHashes = workingOnHashes.filter((x) => x !== hash);
+      }
+    })
+  );
+
+  console.info("Skips (A):", skipsA, "(B):", skipsB);
+  break;
+}
+
+await dumpCache().catch(console.error);
+process.exit(0);

+ 406 - 0
migrations/20240929-autumn-rewrite.mjs

@@ -0,0 +1,406 @@
+// This script is intended for migrating to the new Autumn release.
+// Please read all TODOs in this file as they will help guide you
+// to migrate your data properly. Please do Ctrl + F "TODO".
+
+import { MongoClient } from "mongodb";
+
+/**
+ * Map of tags to S3 bucket names
+ *
+ * TODO: if you've used AUTUMN_S3_BUCKET_PREFIX in the past
+ *       update the bucket names below to include the prefix
+ *
+ *       NOTE: update `files.s3.default_bucket` in Revolt.toml!
+ */
+const BUCKET_MAP = {
+  attachments: "attachments",
+  avatars: "avatars",
+  backgrounds: "backgrounds",
+  icons: "icons",
+  banners: "banners",
+  emojis: "emojis",
+};
+
+/**
+ * Connection URL for MongoDB instance
+ *
+ * TODO: change if necessary
+ */
+const CONNECTION_URL = "mongodb://database";
+
+const objectLookup = {};
+const mongo = new MongoClient(CONNECTION_URL);
+await mongo.connect();
+
+async function determineUploaderIdAndUse(f, v, i) {
+  if (f.tag === "attachments" && v === "attachments") {
+    if (typeof f.message_id !== "string") {
+      console.warn(i, "No message id specified.");
+      return null;
+    }
+
+    if (!objectLookup[f.message_id]) {
+      objectLookup[f.message_id] = await mongo
+        .db("revolt")
+        .collection("messages")
+        .findOne({
+          _id: f.message_id,
+        });
+    }
+
+    if (!objectLookup[f.message_id]) {
+      console.warn(i, "Message", f.message_id, "doesn't exist anymore!");
+      return null;
+    }
+
+    return {
+      uploaded_at: new Date(decodeTime(f.message_id)),
+      uploader_id: objectLookup[f.message_id].author,
+      used_for: {
+        type: "Message",
+        id: f.message_id,
+      },
+    };
+  } else if (f.tag === "banners" && v === "banners") {
+    if (typeof f.server_id !== "string") {
+      console.warn(i, "No server id specified.");
+      return null;
+    }
+
+    if (!objectLookup[f.server_id]) {
+      objectLookup[f.server_id] = await mongo
+        .db("revolt")
+        .collection("servers")
+        .findOne({
+          _id: f.server_id,
+        });
+    }
+
+    if (!objectLookup[f.server_id]) {
+      console.warn(i, "Server", f.server_id, "doesn't exist anymore!");
+      return null;
+    }
+
+    return {
+      uploaded_at: new Date(),
+      uploader_id: objectLookup[f.server_id].owner,
+      used_for: {
+        type: "ServerBanner",
+        id: f.server_id,
+      },
+    };
+  } else if (f.tag === "emojis" && v === "emojis") {
+    if (typeof f.object_id !== "string") {
+      return null;
+    }
+
+    if (!objectLookup[f.object_id]) {
+      objectLookup[f.object_id] = await mongo
+        .db("revolt")
+        .collection("emojis")
+        .findOne({
+          _id: f.object_id,
+        });
+    }
+
+    if (!objectLookup[f.object_id]) {
+      console.warn(i, "Emoji", f.object_id, "doesn't exist anymore!");
+      return null;
+    }
+
+    return {
+      uploaded_at: new Date(decodeTime(f.object_id)),
+      uploader_id: objectLookup[f.object_id].creator_id,
+      used_for: {
+        type: "Emoji",
+        id: f.object_id,
+      },
+    };
+  } else if (f.tag === "avatars" && v === "avatars") {
+    if (typeof f.user_id !== "string") {
+      return null;
+    }
+
+    if (!objectLookup[f.user_id]) {
+      objectLookup[f.user_id] = await mongo
+        .db("revolt")
+        .collection("users")
+        .findOne({
+          _id: f.user_id,
+        });
+    }
+
+    if (!objectLookup[f.user_id]) {
+      console.warn(i, "User", f.user_id, "doesn't exist anymore!");
+      return null;
+    }
+
+    if (objectLookup[f.user_id].avatar?._id !== f._id) {
+      console.warn(
+        i,
+        "Attachment no longer in use.",
+        f._id,
+        "for",
+        f.user_id,
+        "current:",
+        objectLookup[f.user_id].avatar?._id
+      );
+      return null;
+    }
+
+    return {
+      uploaded_at: new Date(),
+      uploader_id: f.user_id,
+      used_for: {
+        type: "UserAvatar",
+        id: f.user_id,
+      },
+    };
+  } else if (f.tag === "backgrounds" && v === "backgrounds") {
+    if (typeof f.user_id !== "string") {
+      return null;
+    }
+
+    if (!objectLookup[f.user_id]) {
+      objectLookup[f.user_id] = await mongo
+        .db("revolt")
+        .collection("users")
+        .findOne({
+          _id: f.user_id,
+        });
+    }
+
+    if (!objectLookup[f.user_id]) {
+      console.warn(i, "User", f.user_id, "doesn't exist anymore!");
+      return null;
+    }
+
+    if (objectLookup[f.user_id].profile?.background?._id !== f._id) {
+      console.warn(
+        i,
+        "Attachment no longer in use.",
+        f._id,
+        "for",
+        f.user_id,
+        "current:",
+        objectLookup[f.user_id].profile?.background?._id
+      );
+      return null;
+    }
+
+    return {
+      uploaded_at: new Date(),
+      uploader_id: f.user_id,
+      used_for: {
+        type: "UserProfileBackground",
+        id: f.user_id,
+      },
+    };
+  } else if (f.tag === "icons" && v === "icons") {
+    if (typeof f.object_id !== "string") {
+      return null;
+    }
+
+    // some bugged files at start
+    // ... expensive to compute at worst case =(
+    // so instead we can just disable it until everything is processed
+    // then re-run on these!
+    if (false) {
+      objectLookup[f.object_id] = await mongo
+        .db("revolt")
+        .collection("users")
+        .findOne({
+          _id: f.object_id,
+        });
+
+      if (!objectLookup[f.object_id]) {
+        console.warn(i, "No legacy match!");
+        return null;
+      }
+
+      return {
+        uploaded_at: new Date(),
+        uploader_id: f.object_id,
+        used_for: {
+          type: "LegacyGroupIcon",
+          id: f.object_id,
+        },
+      };
+    }
+
+    if (!objectLookup[f.object_id]) {
+      objectLookup[f.object_id] = await mongo
+        .db("revolt")
+        .collection("servers")
+        .findOne({
+          _id: f.object_id,
+        });
+    }
+
+    if (
+      !objectLookup[f.object_id] ||
+      // heuristic for not server
+      !objectLookup[f.object_id].channels
+    ) {
+      console.warn(i, "Server", f.object_id, "doesn't exist!");
+
+      if (!objectLookup[f.object_id]) {
+        objectLookup[f.object_id] = await mongo
+          .db("revolt")
+          .collection("channels")
+          .findOne({
+            _id: f.object_id,
+          });
+      }
+
+      if (!objectLookup[f.object_id]) {
+        console.warn(i, "Channel", f.object_id, "doesn't exist!");
+        return null;
+      }
+
+      let server;
+      const serverId = objectLookup[f.object_id].server;
+      if (serverId) {
+        server = objectLookup[serverId];
+
+        if (!server) {
+          server = await mongo.db("revolt").collection("servers").findOne({
+            _id: serverId,
+          });
+
+          console.info(
+            i,
+            "Couldn't find matching server for channel " + f.object_id + "!"
+          );
+          if (!server) return null;
+
+          objectLookup[serverId] = server;
+        }
+      }
+
+      return {
+        uploaded_at: new Date(),
+        uploader_id: (server ?? objectLookup[f.object_id]).owner,
+        used_for: {
+          type: "ChannelIcon",
+          id: f.object_id,
+        },
+      };
+    }
+
+    return {
+      uploaded_at: new Date(),
+      uploader_id: objectLookup[f.object_id].owner,
+      used_for: {
+        type: "ServerIcon",
+        id: f.object_id,
+      },
+    };
+  } else {
+    throw (
+      "couldn't find uploader id for " +
+      f._id +
+      " expected " +
+      v +
+      " but got " +
+      f.tag
+    );
+  }
+}
+
+const dirs = [
+  "banners",
+  "emojis",
+  "avatars",
+  "backgrounds",
+  "icons",
+  "attachments", // https://stackoverflow.com/a/18777877
+];
+
+// === add `used_for` field to files
+const files_pt1 = await mongo
+  .db("revolt")
+  .collection("attachments")
+  .find({
+    $or: [
+      {
+        used_for: {
+          $exists: false,
+        },
+      },
+      {
+        uploader_id: {
+          $exists: false,
+        },
+      },
+      {
+        uploader_at: {
+          $exists: false,
+        },
+      },
+    ],
+  })
+  .toArray();
+
+let i = 1;
+for (const file of files_pt1) {
+  console.info(i++, files_pt1.length, file);
+  const meta = determineUploaderIdAndUse(file, file.tag, i);
+  if (meta) {
+    await mongo.db("revolt").collection("attachments").updateOne(
+      {
+        _id: file._id,
+      },
+      {
+        $set: meta,
+      }
+    );
+  }
+}
+
+// === set hash to id and create relevant objects
+const files_pt2 = await mongo
+  .db("revolt")
+  .collection("attachments")
+  .find({
+    hash: {
+      $exists: false,
+    },
+  })
+  .toArray();
+
+await mongo
+  .db("revolt")
+  .collection("attachment_hashes")
+  .insertMany(
+    files_pt2.map((file) => ({
+      _id: file._id,
+      processed_hash: file._id,
+
+      created_at: new Date(),
+
+      bucket_id: BUCKET_MAP[file.tag],
+      path: file._id,
+      iv: "", // disable encryption for file
+
+      metadata: file.metadata,
+      content_type: file.content_type,
+      size: file.size,
+    }))
+  );
+
+for (const file of files_pt2) {
+  await mongo
+    .db("revolt")
+    .collection("attachments")
+    .updateOne(
+      {
+        _id: file._id,
+      },
+      {
+        $set: {
+          hash: file._id,
+        },
+      }
+    );
+}