Files
self-hosted/migrations/20240929-autumn-rewrite---prod-migration.mjs
Jacob Schlecht 54a88cf6cb feat: Add voice and new web app to self hosted files (#196)
* chore: Use v0.11.1 for now as v0.11.x>1 contain emergency prod-only fix

This commit was made without the use of generative AI.

Signed-off-by: Jacob Schlecht <dadadah@echoha.us>

* feat: add voice to the compose, caddyfile, and livekit config

Web section of the compose is commented out for now

Added section to readme about the name of the project changing

This commit was made without the use of generative AI.

Signed-off-by: Jacob Schlecht <dadadah@echoha.us>

* chore: update many references to Revolt to reference Stoat

Signed-off-by: Jacob Schlecht <dadadah@echoha.us>

* feat: Add new dockerized web container

This commit was made without the use of generative AI.

Signed-off-by: Jacob Schlecht <dadadah@echoha.us>

* feat: Confirm reconfiguration if Revolt.toml exists

Also fix  not outputing new env vars to .env.web

This commit was made without the use of generative AI.

Signed-off-by: Jacob Schlecht <dadadah@echoha.us>

* feat: Add a migration script to make upgrading to voice easier

This commit was made without the use of generative AI.

Signed-off-by: Jacob Schlecht <dadadah@echoha.us>

* fix: Use old referral code

This commit was made without the use of generative AI.

Signed-off-by: Jacob Schlecht <dadadah@echoha.us>

---------

Signed-off-by: Jacob Schlecht <dadadah@echoha.us>
Co-authored-by: Declan Chidlow <accounts@vale.rocks>
2026-02-18 20:38:03 -07:00

664 lines
16 KiB
JavaScript

// THIS FILE IS TAILORED TO STOAT 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);