diff --git a/src/api/routes/users/@me/mentions.ts b/src/api/routes/users/@me/mentions.ts
new file mode 100644
index 00000000..cd3bb684
--- /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 <https://www.gnu.org/licenses/>.
+*/
+
+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 8436e62a..1acd441c 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<Pick<Member, PublicMemberKeys>, "roles"> & {
diff --git a/src/util/entities/Member.ts b/src/util/entities/Member.ts
index 2cabd858..404d2ce7 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 3de05aa2..57634953 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 };
}
|