diff --git a/.idea/data_source_mapping.xml b/.idea/data_source_mapping.xml
deleted file mode 100644
index c2a7ad0fd..000000000
--- a/.idea/data_source_mapping.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/workspace.xml b/.idea/workspace.xml
index 11e173ef7..f43b99864 100644
--- a/.idea/workspace.xml
+++ b/.idea/workspace.xml
@@ -43,46 +43,46 @@
- {
- "keyToString": {
- "NIXITCH_NIXPKGS_CONFIG": "/etc/nix/nixpkgs-config.nix",
- "NIXITCH_NIX_CONF_DIR": "",
- "NIXITCH_NIX_OTHER_STORES": "",
- "NIXITCH_NIX_PATH": "/home/Rory/.nix-defexpr/channels:nixpkgs=/nix/store/wb6agba4kfsxpbnb5hzlq58vkjzvbsk6-source",
- "NIXITCH_NIX_PROFILES": "/run/current-system/sw /nix/var/nix/profiles/default /etc/profiles/per-user/Rory /home/Rory/.local/state/nix/profile /nix/profile /home/Rory/.nix-profile",
- "NIXITCH_NIX_REMOTE": "",
- "NIXITCH_NIX_USER_PROFILE_DIR": "/nix/var/nix/profiles/per-user/Rory",
- "Node.js.Server.ts.executor": "Debug",
- "RunOnceActivity.ShowReadmeOnStart": "true",
- "RunOnceActivity.git.unshallow": "true",
- "javascript.nodejs.core.library.configured.version": "24.8.0",
- "javascript.nodejs.core.library.typings.version": "24.7.0",
- "last_opened_file_path": "/home/Rory/git/spacebar/server-master/src/util/migration/postgres",
- "node.js.detected.package.eslint": "true",
- "node.js.selected.package.eslint": "(autodetect)",
- "node.js.selected.package.tslint": "(autodetect)",
- "nodejs_interpreter_path": "node",
- "nodejs_package_manager_path": "npm",
- "npm.Start API.executor": "Run",
- "npm.Start CDN.executor": "Run",
- "npm.Start Gateway.executor": "Run",
- "npm.build.executor": "Run",
- "npm.start.executor": "Debug",
- "prettierjs.PrettierConfiguration.Package": "/home/Rory/git/spacebar/server-master/node_modules/prettier",
- "settings.editor.selected.configurable": "preferences.pluginManager",
- "ts.external.directory.path": "/home/Rory/git/spacebar/server-master/node_modules/typescript/lib"
+
+}]]>
@@ -111,14 +111,24 @@
+
+
+
+
+
+
+
+
+
@@ -155,6 +165,17 @@
+
+
+
diff --git a/assets/openapi.json b/assets/openapi.json
index 0e21164c4..c5e927640 100644
Binary files a/assets/openapi.json and b/assets/openapi.json differ
diff --git a/assets/schemas.json b/assets/schemas.json
index bbf6461f5..e53b7b527 100644
Binary files a/assets/schemas.json and b/assets/schemas.json differ
diff --git a/scripts/openapi.js b/scripts/openapi.js
index 6d4d63667..589614cc2 100644
--- a/scripts/openapi.js
+++ b/scripts/openapi.js
@@ -24,6 +24,7 @@ const {
NO_AUTHORIZATION_ROUTES,
} = require("../dist/api/middlewares/Authentication");
require("../dist/util/util/extensions");
+const { bgRedBright } = require("picocolors");
const openapiPath = path.join(__dirname, "..", "assets", "openapi.json");
const SchemaPath = path.join(__dirname, "..", "assets", "schemas.json");
@@ -84,7 +85,7 @@ function combineSchemas(schemas) {
for (const key in definitions) {
if (!schemaRegEx.test(key)) {
- console.error(`Invalid schema name: ${key}, context:`, definitions[key]);
+ console.error(`${bgRedBright("ERROR")} Invalid schema name: ${key}, context:`, definitions[key]);
continue;
}
specification.components = specification.components || {};
diff --git a/scripts/util/getRouteDescriptions.js b/scripts/util/getRouteDescriptions.js
index 54b5e1bc0..54c8fe194 100644
--- a/scripts/util/getRouteDescriptions.js
+++ b/scripts/util/getRouteDescriptions.js
@@ -2,6 +2,7 @@ const express = require("express");
const path = require("path");
const { traverseDirectory } = require("lambert-server");
const RouteUtility = require("../../dist/api/util/handlers/route.js");
+const { bgRedBright } = require("picocolors");
const methods = ["get", "post", "put", "delete", "patch"];
const routes = new Map();
@@ -24,7 +25,7 @@ function proxy(file, method, prefix, path, ...args) {
const opts = args.find((x) => x?.prototype?.OPTS_MARKER == true);
if (!opts)
return console.error(
- `${file} has route without route() description middleware`,
+ `${bgRedBright("ERROR")} ${file} has route without route() description middleware`,
);
console.log(`${method.toUpperCase().padStart("OPTIONS".length)} ${prefix + path}`);
diff --git a/src/api/routes/users/@me/mentions.ts b/src/api/routes/users/@me/mentions.ts
new file mode 100644
index 000000000..cd3bb684e
--- /dev/null
+++ b/src/api/routes/users/@me/mentions.ts
@@ -0,0 +1,206 @@
+/*
+ Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
+ Copyright (C) 2025 Spacebar and Spacebar Contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+import { route } from "@spacebar/api";
+import { Snowflake, User, Message, Member, Channel, Permissions, timePromise, NewUrlUserSignatureData, Stopwatch, Attachment } from "@spacebar/util";
+import { Request, Response, Router } from "express";
+import { In } from "typeorm";
+
+const router: Router = Router({ mergeParams: true });
+
+router.get(
+ "",
+ route({
+ responses: {
+ 200: {
+ body: "MessageListResponse",
+ },
+ 404: {
+ body: "APIErrorResponse",
+ },
+ },
+ }),
+ // AFAICT this endpoint doesn't list DMs
+ async (req: Request, res: Response) => {
+ const limit = req.query.limit && !isNaN(Number(req.query.limit)) ? Number(req.query.limit) : 50;
+ const everyone = !!req.query.everyone;
+ const roles = !!req.query.roles;
+ const before = req.query.before && BigInt(req.query.before as string);
+
+ const user = await User.findOneOrFail({
+ where: { id: req.user_id },
+ });
+
+ const memberships = await Member.find({
+ where: { id: req.user_id },
+ select: {
+ guild_id: true,
+ id: true,
+ communication_disabled_until: true,
+ roles: {
+ // We don't want to include all guild roles, as this could cause a lot more explosive behavior
+ id: true,
+ position: true,
+ permissions: true,
+ mentionable: true, // cause we can skip querying for unmentionable roles
+ },
+ guild: {
+ id: true,
+ owner_id: true,
+ },
+ },
+ relations: ["guild", "roles"],
+ });
+
+ const channels = await Channel.find({
+ where: {
+ guild_id: In(memberships.map((m) => m.guild_id)),
+ },
+ select: { id: true, guild_id: true, permission_overwrites: true },
+ });
+
+ const visibleChannels = channels.filter((c) => {
+ const member = memberships.find((m) => m.guild_id === c.guild_id)!;
+ return Permissions.finalPermission({
+ user: { id: member.id, roles: member.roles.map((r) => r.id), communication_disabled_until: member.communication_disabled_until, flags: 0 },
+ guild: { id: member.guild.id, owner_id: member.guild.owner_id!, roles: member.roles },
+ channel: c,
+ }).has("VIEW_CHANNEL");
+ });
+
+ const visibleChannelIds = visibleChannels.map((c) => c.id);
+ const ownedMentionableRoleIds = memberships.reduce((acc, m) => {
+ acc.push(...m.roles.filter((r) => r.mentionable).map((r) => r.id));
+ return acc;
+ }, [] as Snowflake[]);
+
+ const [
+ { result: userMentions, elapsed: userMentionQueryTime },
+ { result: roleMentions, elapsed: roleMentionQueryTime },
+ { result: everyoneMentions, elapsed: everyoneMentionQueryTime },
+ ] = await Promise.all([
+ await timePromise(() =>
+ Message.find({
+ where: {
+ channel_id: In(visibleChannelIds),
+ mentions: { id: user.id },
+ },
+ select: {
+ id: true,
+ timestamp: true,
+ },
+ order: {
+ timestamp: "DESC",
+ },
+ take: limit,
+ }),
+ ),
+ await timePromise(() =>
+ !roles
+ ? Promise.resolve([])
+ : Message.find({
+ where: {
+ channel_id: In(visibleChannelIds),
+ mention_roles: { id: In(ownedMentionableRoleIds) },
+ },
+ select: {
+ id: true,
+ timestamp: true,
+ },
+ order: {
+ timestamp: "DESC",
+ },
+ take: limit,
+ }),
+ ),
+ await timePromise(() =>
+ !everyone
+ ? Promise.resolve([])
+ : Message.find({
+ where: {
+ channel_id: In(visibleChannelIds),
+ mention_everyone: true,
+ },
+ select: {
+ id: true,
+ timestamp: true,
+ },
+ order: {
+ timestamp: "DESC",
+ },
+ take: limit,
+ }),
+ ),
+ ]);
+
+ const allMentions = [...userMentions, ...roleMentions, ...everyoneMentions];
+ console.log(
+ `[Inbox/mentions] User ${user.id} query results: totalRecs=${allMentions.length} | user=${userMentions.length} (took ${userMentionQueryTime.totalMilliseconds}ms), role=${roleMentions.length} (took ${roleMentionQueryTime.totalMilliseconds}ms), everyone=${everyoneMentions.length} (took ${everyoneMentionQueryTime.totalMilliseconds}ms)`,
+ );
+ const messageIdsToReturn = allMentions
+ .sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
+ .distinctBy((m) => m.id)
+ .slice(0, limit);
+
+ const sw = Stopwatch.startNew();
+ const finalMessages = (
+ await Message.find({
+ where: { id: In(messageIdsToReturn.map((m) => m.id)) },
+ order: { timestamp: "DESC" },
+ relations: [
+ "author",
+ "webhook",
+ "application",
+ "mentions",
+ "mention_roles",
+ "mention_channels",
+ "sticker_items",
+ "attachments",
+ "referenced_message",
+ "referenced_message.author",
+ "referenced_message.webhook",
+ "referenced_message.application",
+ "referenced_message.mentions",
+ "referenced_message.mention_roles",
+ "referenced_message.mention_channels",
+ "referenced_message.sticker_items",
+ "referenced_message.attachments",
+ ],
+ })
+ ).map((m) => {
+ return {
+ ...m.toJSON(),
+ attachments: m.attachments?.map((attachment: Attachment) =>
+ Attachment.prototype.signUrls.call(
+ attachment,
+ new NewUrlUserSignatureData({
+ ip: req.ip,
+ userAgent: req.headers["user-agent"] as string,
+ }),
+ ),
+ ),
+ };
+ });
+
+ console.log(`[Inbox/mentions] User ${user.id} fetched full message data for ${finalMessages.length} messages in ${sw.elapsed().totalMilliseconds}ms`);
+
+ return res.json(finalMessages);
+ },
+);
+
+export default router;
diff --git a/src/schemas/api/users/Member.ts b/src/schemas/api/users/Member.ts
index 8436e62aa..1acd441cb 100644
--- a/src/schemas/api/users/Member.ts
+++ b/src/schemas/api/users/Member.ts
@@ -64,7 +64,12 @@ export type PublicMemberKeys =
| "deaf"
| "mute"
| "premium_since"
- | "avatar";
+ | "avatar"
+ | "banner"
+ | "bio"
+ | "theme_colors"
+ | "pronouns"
+ | "communication_disabled_until";
export const PublicMemberProjection: PublicMemberKeys[] = [
"id",
@@ -77,6 +82,11 @@ export const PublicMemberProjection: PublicMemberKeys[] = [
"mute",
"premium_since",
"avatar",
+ "banner",
+ "bio",
+ "theme_colors",
+ "pronouns",
+ "communication_disabled_until",
];
export type PublicMember = Omit, "roles"> & {
diff --git a/src/util/entities/Member.ts b/src/util/entities/Member.ts
index 2cabd8580..404d2ce73 100644
--- a/src/util/entities/Member.ts
+++ b/src/util/entities/Member.ts
@@ -44,6 +44,12 @@ export const MemberPrivateProjection: (keyof Member)[] = [
"roles",
"settings",
"user",
+ "avatar",
+ "banner",
+ "bio",
+ "theme_colors",
+ "pronouns",
+ "communication_disabled_until",
];
@Entity({
diff --git a/src/util/util/Token.ts b/src/util/util/Token.ts
index 3de05aa2a..576349538 100644
--- a/src/util/util/Token.ts
+++ b/src/util/util/Token.ts
@@ -147,8 +147,31 @@ export async function generateToken(id: string) {
});
}
+let lastFsCheck: number;
+let cachedKeypair: {
+ privateKey: crypto.KeyObject;
+ publicKey: crypto.KeyObject;
+ fingerprint: string;
+}
+
// Get ECDSA keypair from file or generate it
export async function loadOrGenerateKeypair() {
+ if (cachedKeypair) {
+ // check for file deletion every minute
+ if (Date.now() - lastFsCheck > 60000) {
+ if (!existsSync("jwt.key") || !existsSync("jwt.key.pub")) {
+ console.log("[JWT] Keypair files disappeared... Saving them again.");
+ await Promise.all([
+ fs.writeFile("jwt.key", cachedKeypair.privateKey.export({ format: "pem", type: "sec1" })),
+ fs.writeFile("jwt.key.pub", cachedKeypair.publicKey.export({ format: "pem", type: "spki" })),
+ ]);
+ }
+ lastFsCheck = Date.now();
+ }
+
+ return cachedKeypair;
+ }
+
let privateKey: crypto.KeyObject;
let publicKey: crypto.KeyObject;
@@ -185,5 +208,6 @@ export async function loadOrGenerateKeypair() {
.update(publicKey.export({ format: "pem", type: "spki" }))
.digest("hex");
- return { privateKey, publicKey, fingerprint };
+ lastFsCheck = Date.now();
+ return cachedKeypair = { privateKey, publicKey, fingerprint };
}