Merge pull request #966 from spacebarchat/feat/refactorIdentify

Rewrite identify handler
This commit is contained in:
Madeline 2023-08-06 23:29:08 +10:00 committed by GitHub
commit 549979372b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 11612 additions and 8551 deletions

View File

@ -2390,12 +2390,16 @@
}, },
"additionalProperties": false "additionalProperties": false
}, },
"flags": {
"type": "integer"
},
"id": { "id": {
"type": "string" "type": "string"
} }
}, },
"required": [ "required": [
"color", "color",
"flags",
"guild", "guild",
"guild_id", "guild_id",
"hoist", "hoist",
@ -3632,47 +3636,45 @@
"type": "object", "type": "object",
"additionalProperties": false "additionalProperties": false
}, },
"id": {
"type": "string"
},
"roles": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Role"
}
},
"name": { "name": {
"type": "string" "type": "string"
}, },
"banner": {
"type": "string"
},
"unavailable": {
"type": "boolean"
},
"channels": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Channel"
}
},
"region": {
"type": "string"
},
"icon": { "icon": {
"type": "string" "type": "string"
}, },
"system_channel_id": { "parent": {
"type": "string" "type": "string"
}, },
"rules_channel_id": { "owner_id": {
"type": "string" "type": "string"
}, },
"afk_timeout": { "nsfw": {
"type": "integer" "type": "boolean"
}, },
"explicit_content_filter": { "invites": {
"type": "integer" "type": "array",
"items": {
"$ref": "#/components/schemas/Invite"
}
},
"voice_states": {
"type": "array",
"items": {
"$ref": "#/components/schemas/VoiceState"
}
},
"webhooks": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Webhook"
}
},
"id": {
"type": "string"
},
"_do_validate": {
"type": "object",
"additionalProperties": false
}, },
"assign": { "assign": {
"type": "object", "type": "object",
@ -3707,6 +3709,39 @@
"type": "object", "type": "object",
"additionalProperties": false "additionalProperties": false
}, },
"roles": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Role"
}
},
"banner": {
"type": "string"
},
"unavailable": {
"type": "boolean"
},
"channels": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Channel"
}
},
"region": {
"type": "string"
},
"system_channel_id": {
"type": "string"
},
"rules_channel_id": {
"type": "string"
},
"afk_timeout": {
"type": "integer"
},
"explicit_content_filter": {
"type": "integer"
},
"afk_channel_id": { "afk_channel_id": {
"type": "string" "type": "string"
}, },
@ -3773,30 +3808,9 @@
"$ref": "#/components/schemas/Sticker" "$ref": "#/components/schemas/Sticker"
} }
}, },
"invites": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Invite"
}
},
"voice_states": {
"type": "array",
"items": {
"$ref": "#/components/schemas/VoiceState"
}
},
"webhooks": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Webhook"
}
},
"mfa_level": { "mfa_level": {
"type": "integer" "type": "integer"
}, },
"owner_id": {
"type": "string"
},
"preferred_locale": { "preferred_locale": {
"type": "string" "type": "string"
}, },
@ -3830,21 +3844,11 @@
"nsfw_level": { "nsfw_level": {
"type": "integer" "type": "integer"
}, },
"nsfw": {
"type": "boolean"
},
"parent": {
"type": "string"
},
"permissions": { "permissions": {
"type": "integer" "type": "integer"
}, },
"premium_progress_bar_enabled": { "premium_progress_bar_enabled": {
"type": "boolean" "type": "boolean"
},
"_do_validate": {
"type": "object",
"additionalProperties": false
} }
}, },
"required": [ "required": [
@ -4173,7 +4177,9 @@
"channel": { "channel": {
"$ref": "#/components/schemas/RateLimitOptions" "$ref": "#/components/schemas/RateLimitOptions"
}, },
"auth": {} "auth": {
"$ref": "#/components/schemas/AuthRateLimit"
}
}, },
"required": [ "required": [
"auth", "auth",
@ -4182,6 +4188,21 @@
"webhook" "webhook"
] ]
}, },
"AuthRateLimit": {
"type": "object",
"properties": {
"login": {
"$ref": "#/components/schemas/RateLimitOptions"
},
"register": {
"$ref": "#/components/schemas/RateLimitOptions"
}
},
"required": [
"login",
"register"
]
},
"GlobalRateLimits": { "GlobalRateLimits": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -4664,13 +4685,13 @@
"discovery_splash": { "discovery_splash": {
"type": "string" "type": "string"
}, },
"region": {
"type": "string"
},
"icon": { "icon": {
"type": "string", "type": "string",
"nullable": true "nullable": true
}, },
"region": {
"type": "string"
},
"guild_template_code": { "guild_template_code": {
"type": "string" "type": "string"
}, },
@ -5406,6 +5427,12 @@
}, },
"promotional_email_opt_in": { "promotional_email_opt_in": {
"type": "boolean" "type": "boolean"
},
"unique_username_registration": {
"type": "boolean"
},
"global_name": {
"type": "string"
} }
}, },
"required": [ "required": [
@ -5684,13 +5711,6 @@
"version": { "version": {
"type": "integer" "type": "integer"
}, },
"guild_id": {
"type": "string",
"nullable": true
},
"flags": {
"type": "integer"
},
"message_notifications": { "message_notifications": {
"type": "integer" "type": "integer"
}, },
@ -5716,6 +5736,13 @@
"suppress_roles": { "suppress_roles": {
"type": "boolean" "type": "boolean"
}, },
"guild_id": {
"type": "string",
"nullable": true
},
"flags": {
"type": "integer"
},
"mute_scheduled_events": { "mute_scheduled_events": {
"type": "boolean" "type": "boolean"
}, },
@ -6934,6 +6961,9 @@
"APIPrivateUser": { "APIPrivateUser": {
"type": "object", "type": "object",
"properties": { "properties": {
"flags": {
"type": "string"
},
"id": { "id": {
"type": "string" "type": "string"
}, },
@ -6980,9 +7010,6 @@
"pronouns": { "pronouns": {
"type": "string" "type": "string"
}, },
"flags": {
"type": "string"
},
"mfa_enabled": { "mfa_enabled": {
"type": "boolean" "type": "boolean"
}, },
@ -7051,6 +7078,9 @@
"newToken": { "newToken": {
"type": "string" "type": "string"
}, },
"flags": {
"type": "string"
},
"id": { "id": {
"type": "string" "type": "string"
}, },
@ -7097,9 +7127,6 @@
"pronouns": { "pronouns": {
"type": "string" "type": "string"
}, },
"flags": {
"type": "string"
},
"mfa_enabled": { "mfa_enabled": {
"type": "boolean" "type": "boolean"
}, },
@ -7264,10 +7291,10 @@
"APIPublicMember": { "APIPublicMember": {
"type": "object", "type": "object",
"properties": { "properties": {
"id": { "guild_id": {
"type": "string" "type": "string"
}, },
"guild_id": { "id": {
"type": "string" "type": "string"
}, },
"nick": { "nick": {
@ -7696,10 +7723,10 @@
"additionalProperties": false, "additionalProperties": false,
"type": "object", "type": "object",
"properties": { "properties": {
"id": { "guild_id": {
"type": "string" "type": "string"
}, },
"guild_id": { "id": {
"type": "string" "type": "string"
}, },
"nick": { "nick": {

File diff suppressed because it is too large Load Diff

View File

@ -1,82 +0,0 @@
/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2023 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/>.
*/
require("dotenv").config();
const cluster = require("cluster");
const WebSocket = require("ws");
const endpoint = process.env.GATEWAY || "ws://localhost:3001";
const connections = Number(process.env.CONNECTIONS) || 50;
const token = process.env.TOKEN;
var cores = 1;
try {
cores = Number(process.env.THREADS) || os.cpus().length;
} catch {
console.log("[Bundle] Failed to get thread count! Using 1...");
}
if (!token) {
console.error("TOKEN env var missing");
process.exit();
}
if (cluster.isMaster) {
for (let i = 0; i < cores; i++) {
cluster.fork();
}
cluster.on("exit", (worker, code, signal) => {
console.log(`worker ${worker.process.pid} died`);
});
} else {
for (let i = 0; i < connections; i++) {
connect();
}
}
function connect() {
const client = new WebSocket(endpoint);
client.on("message", (data) => {
data = JSON.parse(data);
switch (data.op) {
case 10:
client.interval = setInterval(() => {
client.send(JSON.stringify({ op: 1 }));
}, data.d.heartbeat_interval);
client.send(
JSON.stringify({
op: 2,
d: {
token,
properties: {},
},
}),
);
break;
}
});
client.once("close", (code, reason) => {
clearInterval(client.interval);
connect();
});
client.on("error", (err) => {
// console.log(err);
});
}

View File

@ -0,0 +1,53 @@
/* eslint-env node */
require("dotenv").config();
const { OPCODES } = require("../../dist/gateway/util/Constants.js");
const WebSocket = require("ws");
const ENDPOINT = `ws://localhost:3002?v=9&encoding=json`;
const TOKEN =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEwOTMxMTgwMjgzNjA1MzYxMDYiLCJpYXQiOjE2ODA2OTE5MDB9.9ByCqDvC4mIutW8nM7WhVCtGuKW08UimPnmBeNw-K0E";
const TOTAL_ITERATIONS = 500;
const doTimedIdentify = () =>
new Promise((resolve) => {
let start;
const ws = new WebSocket(ENDPOINT);
ws.on("message", (data) => {
const parsed = JSON.parse(data);
switch (parsed.op) {
case OPCODES.Hello:
// send identify
start = performance.now();
ws.send(
JSON.stringify({
op: OPCODES.Identify,
d: {
token: TOKEN,
properties: {},
},
}),
);
break;
case OPCODES.Dispatch:
if (parsed.t == "READY") {
ws.close();
return resolve(performance.now() - start);
}
break;
}
});
});
(async () => {
const perfs = [];
while (perfs.length < TOTAL_ITERATIONS) {
const ret = await doTimedIdentify();
perfs.push(ret);
// console.log(`${perfs.length}/${TOTAL_ITERATIONS} - this: ${Math.floor(ret)}ms`)
}
const avg = perfs.reduce((prev, curr) => prev + curr) / (perfs.length - 1);
console.log(`Average identify time: ${Math.floor(avg * 100) / 100}ms`);
})();

View File

@ -92,12 +92,7 @@ export async function Authentication(
Sentry.setUser({ id: req.user_id }); Sentry.setUser({ id: req.user_id });
try { try {
const { jwtSecret } = Config.get().security; const { decoded, user } = await checkToken(req.headers.authorization);
const { decoded, user } = await checkToken(
req.headers.authorization,
jwtSecret,
);
req.token = decoded; req.token = decoded;
req.user_id = decoded.id; req.user_id = decoded.id;

View File

@ -48,11 +48,9 @@ router.post(
async (req: Request, res: Response) => { async (req: Request, res: Response) => {
const { password, token } = req.body as PasswordResetSchema; const { password, token } = req.body as PasswordResetSchema;
const { jwtSecret } = Config.get().security;
let user; let user;
try { try {
const userTokenData = await checkToken(token, jwtSecret, true); const userTokenData = await checkToken(token);
user = userTokenData.user; user = userTokenData.user;
} catch { } catch {
throw FieldErrors({ throw FieldErrors({

View File

@ -78,11 +78,10 @@ router.post(
} }
} }
const { jwtSecret } = Config.get().security;
let user; let user;
try { try {
const userTokenData = await checkToken(token, jwtSecret, true); const userTokenData = await checkToken(token);
user = userTokenData.user; user = userTokenData.user;
} catch { } catch {
throw FieldErrors({ throw FieldErrors({

View File

@ -144,8 +144,9 @@ router.get(
const endpoint = Config.get().cdn.endpointPublic; const endpoint = Config.get().cdn.endpointPublic;
return res.json( const ret = messages.map((x: Message) => {
messages.map((x: Partial<Message>) => { x = x.toJSON();
(x.reactions || []).forEach((y: Partial<Reaction>) => { (x.reactions || []).forEach((y: Partial<Reaction>) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore //@ts-ignore
@ -180,8 +181,9 @@ router.get(
// } // }
return x; return x;
}), });
);
return res.json(ret);
}, },
); );
@ -307,9 +309,11 @@ router.post(
embeds, embeds,
channel_id, channel_id,
attachments, attachments,
edited_timestamp: undefined,
timestamp: new Date(), timestamp: new Date(),
}); });
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore dont care2
message.edited_timestamp = null;
channel.last_message_id = message.id; channel.last_message_id = message.id;

View File

@ -161,7 +161,7 @@ router.patch(
const data = guild.toJSON(); const data = guild.toJSON();
// TODO: guild hashes // TODO: guild hashes
// TODO: fix vanity_url_code, template_id // TODO: fix vanity_url_code, template_id
delete data.vanity_url_code; // delete data.vanity_url_code;
delete data.template_id; delete data.template_id;
await Promise.all([ await Promise.all([

View File

@ -25,5 +25,5 @@ export async function onHeartbeat(this: WebSocket) {
setHeartbeat(this); setHeartbeat(this);
await Send(this, { op: 11 }); await Send(this, { op: 11, d: {} });
} }

View File

@ -16,17 +16,23 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { WebSocket, Payload } from "@spacebar/gateway"; import {
WebSocket,
Payload,
setupListener,
Capabilities,
CLOSECODES,
OPCODES,
Send,
} from "@spacebar/gateway";
import { import {
checkToken, checkToken,
Intents, Intents,
Member, Member,
ReadyEventData, ReadyEventData,
User,
Session, Session,
EVENTEnum, EVENTEnum,
Config, Config,
PublicMember,
PublicUser, PublicUser,
PrivateUserProjection, PrivateUserProjection,
ReadState, ReadState,
@ -36,77 +42,138 @@ import {
PrivateSessionProjection, PrivateSessionProjection,
MemberPrivateProjection, MemberPrivateProjection,
PresenceUpdateEvent, PresenceUpdateEvent,
UserSettings,
IdentifySchema, IdentifySchema,
DefaultUserGuildSettings, DefaultUserGuildSettings,
UserGuildSettings,
ReadyGuildDTO, ReadyGuildDTO,
Guild, Guild,
UserTokenData, PublicUserProjection,
ConnectedAccount, ReadyUserGuildSettingsEntries,
UserSettings,
Permissions,
DMChannel,
GuildOrUnavailable,
Recipient,
OPCodes,
} from "@spacebar/util"; } from "@spacebar/util";
import { Send } from "../util/Send";
import { CLOSECODES, OPCODES } from "../util/Constants";
import { setupListener } from "../listener/listener";
// import experiments from "./experiments.json";
const experiments: unknown[] = [];
import { check } from "./instanceOf"; import { check } from "./instanceOf";
import { Recipient } from "@spacebar/util";
// TODO: user sharding // TODO: user sharding
// TODO: check privileged intents, if defined in the config // TODO: check privileged intents, if defined in the config
// TODO: check if already identified
// TODO: Refactor identify ( and lazyrequest, tbh )
export async function onIdentify(this: WebSocket, data: Payload) { export async function onIdentify(this: WebSocket, data: Payload) {
clearTimeout(this.readyTimeout); if (this.user_id) {
// TODO: is this needed now that we use `json-bigint`? // we've already identified
if (typeof data.d?.client_state?.highest_last_message_id === "number") return this.close(CLOSECODES.Already_authenticated);
data.d.client_state.highest_last_message_id += ""; }
check.call(this, IdentifySchema, data.d);
clearTimeout(this.readyTimeout);
// Check payload matches schema
check.call(this, IdentifySchema, data.d);
const identify: IdentifySchema = data.d; const identify: IdentifySchema = data.d;
let decoded: UserTokenData["decoded"]; this.capabilities = new Capabilities(identify.capabilities || 0);
try {
const { jwtSecret } = Config.get().security;
decoded = (await checkToken(identify.token, jwtSecret)).decoded; // will throw an error if invalid
} catch (error) {
console.error("invalid token", error);
return this.close(CLOSECODES.Authentication_failed);
}
this.user_id = decoded.id;
const session_id = this.session_id;
const [ const { user } = await checkToken(identify.token, {
user,
read_states,
members,
recipients,
session,
application,
connected_accounts,
] = await Promise.all([
User.findOneOrFail({
where: { id: this.user_id },
relations: ["relationships", "relationships.to", "settings"], relations: ["relationships", "relationships.to", "settings"],
select: [...PrivateUserProjection, "relationships"], select: [...PrivateUserProjection, "relationships"],
});
if (!user) return this.close(CLOSECODES.Authentication_failed);
this.user_id = user.id;
// Check intents
if (!identify.intents) identify.intents = 30064771071n; // TODO: what is this number?
this.intents = new Intents(identify.intents);
// TODO: actually do intent things.
// Validate sharding
if (identify.shard) {
this.shard_id = identify.shard[0];
this.shard_count = identify.shard[1];
if (
this.shard_count == null ||
this.shard_id == null ||
this.shard_id > this.shard_count ||
this.shard_id < 0 ||
this.shard_count <= 0
) {
// TODO: why do we even care about this right now?
console.log(
`[Gateway] Invalid sharding from ${user.id}: ${identify.shard}`,
);
return this.close(CLOSECODES.Invalid_shard);
}
}
// Generate a new gateway session ( id is already made, just save it in db )
const session = Session.create({
user_id: this.user_id,
session_id: this.session_id,
status: identify.presence?.status || "online",
client_info: {
client: identify.properties?.$device,
os: identify.properties?.os,
version: 0,
},
activities: identify.presence?.activities, // TODO: validation
});
// Get from database:
// * the users read states
// * guild members for this user
// * recipients ( dm channels )
// * the bot application, if it exists
const [, application, read_states, members, recipients] = await Promise.all(
[
session.save(),
Application.findOne({
where: { id: this.user_id },
select: ["id", "flags"],
}), }),
ReadState.find({ where: { user_id: this.user_id } }),
ReadState.find({
where: { user_id: this.user_id },
select: [
"id",
"channel_id",
"last_message_id",
"last_pin_timestamp",
"mention_count",
],
}),
Member.find({ Member.find({
where: { id: this.user_id }, where: { id: this.user_id },
select: MemberPrivateProjection, select: {
// We only want some member props
...Object.fromEntries(
MemberPrivateProjection.map((x) => [x, true]),
),
settings: true, // guild settings
roles: { id: true }, // the full role is fetched from the `guild` relation
// TODO: we don't really need every property of
// guild channels, emoji, roles, stickers
// but we do want almost everything from guild.
// How do you do that without just enumerating the guild props?
guild: true,
},
relations: [ relations: [
"guild", "guild",
"guild.channels", "guild.channels",
"guild.emojis", "guild.emojis",
"guild.roles", "guild.roles",
"guild.stickers", "guild.stickers",
"user",
"roles", "roles",
// For these entities, `user` is always just the logged in user we fetched above
// "user",
], ],
}), }),
Recipient.find({ Recipient.find({
where: { user_id: this.user_id, closed: false }, where: { user_id: this.user_id, closed: false },
relations: [ relations: [
@ -114,232 +181,246 @@ export async function onIdentify(this: WebSocket, data: Payload) {
"channel.recipients", "channel.recipients",
"channel.recipients.user", "channel.recipients.user",
], ],
// TODO: public user selection select: {
}), channel: {
// save the session and delete it when the websocket is closed id: true,
Session.create({ flags: true,
user_id: this.user_id, // is_spam: true, // TODO
session_id: session_id, last_message_id: true,
// TODO: check if status is only one of: online, dnd, offline, idle last_pin_timestamp: true,
status: identify.presence?.status || "offline", //does the session always start as online? type: true,
client_info: { icon: true,
//TODO read from identity name: true,
client: "desktop", owner_id: true,
os: identify.properties?.os, recipients: {
version: 0, // we don't actually need this ID or any other information about the recipient info,
// but typeorm does not select anything from the users relation of recipients unless we select
// at least one column.
id: true,
// We only want public user data for each dm channel
user: Object.fromEntries(
PublicUserProjection.map((x) => [x, true]),
),
}, },
activities: [], },
}).save(), },
Application.findOne({ where: { id: this.user_id } }), }),
ConnectedAccount.find({ where: { user_id: this.user_id } }), ],
]); );
if (!user) return this.close(CLOSECODES.Authentication_failed); // We forgot to migrate user settings from the JSON column of `users`
// to the `user_settings` table theyre in now,
// so for instances that migrated, users may not have a `user_settings` row.
if (!user.settings) { if (!user.settings) {
user.settings = new UserSettings(); user.settings = new UserSettings();
await user.settings.save(); await user.settings.save();
} }
if (!identify.intents) identify.intents = BigInt("0x6ffffffff"); // Generate merged_members
this.intents = new Intents(identify.intents); const merged_members = members.map((x) => {
if (identify.shard) {
this.shard_id = identify.shard[0];
this.shard_count = identify.shard[1];
if (
this.shard_count == null ||
this.shard_id == null ||
this.shard_id >= this.shard_count ||
this.shard_id < 0 ||
this.shard_count <= 0
) {
console.log(identify.shard);
return this.close(CLOSECODES.Invalid_shard);
}
}
let users: PublicUser[] = [];
const merged_members = members.map((x: Member) => {
return [ return [
{ {
...x, ...x,
roles: x.roles.map((x) => x.id), roles: x.roles.map((x) => x.id),
// add back user, which we don't fetch from db
// TODO: For guild profiles, this may need to be changed.
// TODO: The only field required in the user prop is `id`,
// but our types are annoying so I didn't bother.
user: user.toPublicUser(),
guild: {
id: x.guild.id,
},
settings: undefined, settings: undefined,
guild: undefined,
}, },
]; ];
}) as PublicMember[][];
// TODO: This type is bad.
let guilds: Partial<Guild>[] = members.map((x) => ({
...x.guild,
joined_at: x.joined_at,
}));
const pending_guilds: typeof guilds = [];
if (user.bot)
guilds = guilds.map((guild) => {
pending_guilds.push(guild);
return { id: guild.id, unavailable: true };
}); });
// TODO: Rewrite this. Perhaps a DTO? // Populated with guilds 'unavailable' currently
const user_guild_settings_entries = members.map((x) => ({ // Just for bots
const pending_guilds: Guild[] = [];
// Generate guilds list ( make them unavailable if user is bot )
const guilds: GuildOrUnavailable[] = members.map((member) => {
// filter guild channels we don't have permission to view
// TODO: check if this causes issues when the user is granted other roles?
member.guild.channels = member.guild.channels.filter((channel) => {
const perms = Permissions.finalPermission({
user: {
id: member.id,
roles: member.roles.map((x) => x.id),
},
guild: member.guild,
channel,
});
return perms.has("VIEW_CHANNEL");
});
if (user.bot) {
pending_guilds.push(member.guild);
return { id: member.guild.id, unavailable: true };
}
return {
...member.guild.toJSON(),
joined_at: member.joined_at,
threads: [],
};
});
// Generate user_guild_settings
const user_guild_settings_entries: ReadyUserGuildSettingsEntries[] =
members.map((x) => ({
...DefaultUserGuildSettings, ...DefaultUserGuildSettings,
...x.settings, ...x.settings,
guild_id: x.guild.id, guild_id: x.guild_id,
channel_overrides: Object.entries( channel_overrides: Object.entries(
x.settings.channel_overrides ?? {}, x.settings.channel_overrides ?? {},
).map((y) => ({ ).map((y) => ({
...y[1], ...y[1],
channel_id: y[0], channel_id: y[0],
})), })),
})) as unknown as UserGuildSettings[]; }));
const channels = recipients.map((x) => { // Popultaed with users from private channels, relationships.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // Uses a set to dedupe for us.
//@ts-ignore const users: Set<PublicUser> = new Set();
x.channel.recipients = x.channel.recipients.map((x) =>
x.user.toPublicUser(), // Generate dm channels from recipients list. Append recipients to `users` list
const channels = recipients
.filter(({ channel }) => channel.isDm())
.map((r) => {
// TODO: fix the types of Recipient
// Their channels are only ever private (I think) and thus are always DM channels
const channel = r.channel as DMChannel;
// Remove ourself from the list of other users in dm channel
channel.recipients = channel.recipients.filter(
(recipient) => recipient.user.id !== this.user_id,
); );
//TODO is this needed? check if users in group dm that are not friends are sent in the READY event
users = users.concat(x.channel.recipients as unknown as User[]); const channelUsers = channel.recipients?.map((recipient) =>
if (x.channel.isDm()) { recipient.user.toPublicUser(),
x.channel.recipients = x.channel.recipients?.filter(
(x) => x.id !== this.user_id,
); );
}
return x.channel; if (channelUsers && channelUsers.length > 0)
channelUsers.forEach((user) => users.add(user));
return {
id: channel.id,
flags: channel.flags,
last_message_id: channel.last_message_id,
type: channel.type,
recipients: channelUsers || [],
is_spam: false, // TODO
};
}); });
for (const relation of user.relationships) { // From user relationships ( friends ), also append to `users` list
const related_user = relation.to; user.relationships.forEach((x) => users.add(x.to.toPublicUser()));
const public_related_user = {
username: related_user.username,
discriminator: related_user.discriminator,
id: related_user.id,
public_flags: related_user.public_flags,
avatar: related_user.avatar,
bot: related_user.bot,
bio: related_user.bio,
premium_since: user.premium_since,
premium_type: user.premium_type,
accent_color: related_user.accent_color,
};
users.push(public_related_user);
}
setImmediate(async () => { // Send SESSIONS_REPLACE and PRESENCE_UPDATE
// run in seperate "promise context" because ready payload is not dependent on those events const allSessions = (
await Session.find({
where: { user_id: this.user_id },
select: PrivateSessionProjection,
})
).map((x) => ({
// TODO how is active determined?
// in our lazy request impl, we just pick the 'most relevant' session
active: x.session_id == session.session_id,
activities: x.activities,
client_info: x.client_info,
// TODO: what does all mean?
session_id: x.session_id == session.session_id ? "all" : x.session_id,
status: x.status,
}));
Promise.all([
emitEvent({ emitEvent({
event: "SESSIONS_REPLACE", event: "SESSIONS_REPLACE",
user_id: this.user_id, user_id: this.user_id,
data: await Session.find({ data: allSessions,
where: { user_id: this.user_id }, } as SessionsReplace),
select: PrivateSessionProjection,
}),
} as SessionsReplace);
emitEvent({ emitEvent({
event: "PRESENCE_UPDATE", event: "PRESENCE_UPDATE",
user_id: this.user_id, user_id: this.user_id,
data: { data: {
user: await User.getPublicUser(this.user_id), user: user.toPublicUser(),
activities: session.activities, activities: session.activities,
client_status: session?.client_info, client_status: session.client_info,
status: session.status, status: session.status,
}, },
} as PresenceUpdateEvent); } as PresenceUpdateEvent),
}); ]);
read_states.forEach((s: Partial<ReadState>) => { // Build READY
s.id = s.channel_id;
delete s.user_id;
delete s.channel_id;
});
const privateUser = { read_states.forEach((x) => {
avatar: user.avatar, x.id = x.channel_id;
mobile: user.mobile, });
desktop: user.desktop,
discriminator: user.discriminator,
email: user.email,
flags: user.flags,
id: user.id,
mfa_enabled: user.mfa_enabled,
nsfw_allowed: user.nsfw_allowed,
phone: user.phone,
premium: user.premium,
premium_type: user.premium_type,
public_flags: user.public_flags,
premium_usage_flags: user.premium_usage_flags,
purchased_flags: user.purchased_flags,
username: user.username,
verified: user.verified,
bot: user.bot,
accent_color: user.accent_color,
banner: user.banner,
bio: user.bio,
premium_since: user.premium_since,
};
const d: ReadyEventData = { const d: ReadyEventData = {
v: 9, v: 9,
application: { application: application
id: application?.id ?? "", ? { id: application.id, flags: application.flags }
flags: application?.flags ?? 0, : undefined,
}, //TODO: check this code! user: user.toPrivateUser(),
user: privateUser,
user_settings: user.settings, user_settings: user.settings,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment guilds: this.capabilities.has(Capabilities.FLAGS.CLIENT_STATE_V2)
// @ts-ignore ? guilds.map((x) => new ReadyGuildDTO(x).toJSON())
guilds: guilds.map((x: Guild & { joined_at: Date }) => { : guilds,
return {
...new ReadyGuildDTO(x).toJSON(),
guild_hashes: {},
joined_at: x.joined_at,
name: x.name,
icon: x.icon,
};
}),
guild_experiments: [], // TODO
geo_ordered_rtc_regions: [], // TODO
relationships: user.relationships.map((x) => x.toPublicRelationship()), relationships: user.relationships.map((x) => x.toPublicRelationship()),
read_state: { read_state: {
entries: read_states, entries: read_states,
partial: false, partial: false,
version: 304128, version: 0, // TODO
}, },
user_guild_settings: { user_guild_settings: {
entries: user_guild_settings_entries, entries: user_guild_settings_entries,
partial: false, // TODO partial partial: false,
version: 642, version: 0, // TODO
}, },
private_channels: channels, private_channels: channels,
session_id: session_id, session_id: this.session_id,
analytics_token: "", // TODO country_code: user.settings.locale, // TODO: do ip analysis instead
connected_accounts, users: Array.from(users),
consents: {
personalization: {
consented: false, // TODO
},
},
country_code: user.settings.locale,
friend_suggestion_count: 0, // TODO
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
experiments: experiments, // TODO
guild_join_requests: [], // TODO what is this?
users: users.filter((x) => x).unique(),
merged_members: merged_members, merged_members: merged_members,
// shard // TODO: only for user sharding sessions: allSessions,
sessions: [], // TODO:
resume_gateway_url:
Config.get().gateway.endpointClient ||
Config.get().gateway.endpointPublic ||
"ws://127.0.0.1:3001",
// lol hack whatever // lol hack whatever
required_action: required_action:
Config.get().login.requireVerification && !user.verified Config.get().login.requireVerification && !user.verified
? "REQUIRE_VERIFIED_EMAIL" ? "REQUIRE_VERIFIED_EMAIL"
: undefined, : undefined,
consents: {
personalization: {
consented: false, // TODO
},
},
experiments: [],
guild_join_requests: [],
connected_accounts: [],
guild_experiments: [],
geo_ordered_rtc_regions: [],
api_code_version: 1,
friend_suggestion_count: 0,
analytics_token: "",
tutorial: null,
session_type: "normal", // TODO
auth_session_id_hash: "", // TODO
}; };
// TODO: send real proper data structure // Send READY
await Send(this, { await Send(this, {
op: OPCODES.Dispatch, op: OPCODES.Dispatch,
t: EVENTEnum.Ready, t: EVENTEnum.Ready,
@ -347,23 +428,41 @@ export async function onIdentify(this: WebSocket, data: Payload) {
d, d,
}); });
// If we're a bot user, send GUILD_CREATE for each unavailable guild
await Promise.all( await Promise.all(
pending_guilds.map((guild) => pending_guilds.map((x) =>
Send(this, { Send(this, {
op: OPCODES.Dispatch, op: OPCODES.Dispatch,
t: EVENTEnum.GuildCreate, t: EVENTEnum.GuildCreate,
s: this.sequence++, s: this.sequence++,
d: guild, d: x,
})?.catch(console.error), })?.catch((e) =>
console.error(`[Gateway] error when sending bot guilds`, e),
),
), ),
); );
//TODO send READY_SUPPLEMENTAL // TODO: ready supplemental
await Send(this, {
op: OPCodes.DISPATCH,
t: EVENTEnum.ReadySupplemental,
s: this.sequence++,
d: {
merged_presences: {
guilds: [],
friends: [],
},
// these merged members seem to be all users currently in vc in your guilds
merged_members: [],
lazy_private_channels: [],
guilds: [], // { voice_states: [], id: string, embedded_activities: [] }
// embedded_activities are users currently in an activity?
disclose: [], // Config.get().general.uniqueUsernames ? ["pomelo"] : []
},
});
//TODO send GUILD_MEMBER_LIST_UPDATE //TODO send GUILD_MEMBER_LIST_UPDATE
//TODO send SESSIONS_REPLACE
//TODO send VOICE_STATE_UPDATE to let the client know if another device is already connected to a voice channel //TODO send VOICE_STATE_UPDATE to let the client know if another device is already connected to a voice channel
await setupListener.call(this); await setupListener.call(this);
// console.log(`${this.ipAddress} identified as ${d.user.id}`);
} }

View File

@ -95,7 +95,7 @@ async function getMembers(guild_id: string, range: [number, number]) {
console.error(`LazyRequest`, e); console.error(`LazyRequest`, e);
} }
if (!members) { if (!members || !members.length) {
return { return {
items: [], items: [],
groups: [], groups: [],

View File

@ -0,0 +1,26 @@
import { BitField, BitFieldResolvable, BitFlag } from "@spacebar/util";
export type CapabilityResolvable = BitFieldResolvable | CapabilityString;
type CapabilityString = keyof typeof Capabilities.FLAGS;
export class Capabilities extends BitField {
static FLAGS = {
// Thanks, Opencord!
// https://github.com/MateriiApps/OpenCord/blob/master/app/src/main/java/com/xinto/opencord/gateway/io/Capabilities.kt
LAZY_USER_NOTES: BitFlag(0),
NO_AFFINE_USER_IDS: BitFlag(1),
VERSIONED_READ_STATES: BitFlag(2),
VERSIONED_USER_GUILD_SETTINGS: BitFlag(3),
DEDUPLICATE_USER_OBJECTS: BitFlag(4),
PRIORITIZED_READY_PAYLOAD: BitFlag(5),
MULTIPLE_GUILD_EXPERIMENT_POPULATIONS: BitFlag(6),
NON_CHANNEL_READ_STATES: BitFlag(7),
AUTH_TOKEN_REFRESH: BitFlag(8),
USER_SETTINGS_PROTO: BitFlag(9),
CLIENT_STATE_V2: BitFlag(10),
PASSIVE_GUILD_UPDATE: BitFlag(11),
};
any = (capability: CapabilityResolvable) => super.any(capability);
has = (capability: CapabilityResolvable) => super.has(capability);
}

View File

@ -19,6 +19,7 @@
import { Intents, ListenEventOpts, Permissions } from "@spacebar/util"; import { Intents, ListenEventOpts, Permissions } from "@spacebar/util";
import WS from "ws"; import WS from "ws";
import { Deflate, Inflate } from "fast-zlib"; import { Deflate, Inflate } from "fast-zlib";
import { Capabilities } from "./Capabilities";
// import { Client } from "@spacebar/webrtc"; // import { Client } from "@spacebar/webrtc";
export interface WebSocket extends WS { export interface WebSocket extends WS {
@ -40,5 +41,6 @@ export interface WebSocket extends WS {
events: Record<string, undefined | (() => unknown)>; events: Record<string, undefined | (() => unknown)>;
member_events: Record<string, () => unknown>; member_events: Record<string, () => unknown>;
listen_options: ListenEventOpts; listen_options: ListenEventOpts;
capabilities?: Capabilities;
// client?: Client; // client?: Client;
} }

View File

@ -21,3 +21,4 @@ export * from "./Send";
export * from "./SessionUtils"; export * from "./SessionUtils";
export * from "./Heartbeat"; export * from "./Heartbeat";
export * from "./WebSocket"; export * from "./WebSocket";
export * from "./Capabilities";

View File

@ -18,13 +18,45 @@
import { import {
Channel, Channel,
ChannelOverride,
ChannelType,
Emoji, Emoji,
Guild, Guild,
PublicMember, PublicUser,
Role, Role,
Sticker, Sticker,
UserGuildSettings,
PublicMember,
} from "../entities"; } from "../entities";
// TODO: this is not the best place for this type
export type ReadyUserGuildSettingsEntries = Omit<
UserGuildSettings,
"channel_overrides"
> & {
channel_overrides: (ChannelOverride & { channel_id: string })[];
};
// TODO: probably should move somewhere else
export interface ReadyPrivateChannel {
id: string;
flags: number;
is_spam: boolean;
last_message_id?: string;
recipients: PublicUser[];
type: ChannelType.DM | ChannelType.GROUP_DM;
}
export type GuildOrUnavailable =
| { id: string; unavailable: boolean }
| (Guild & { joined_at?: Date; unavailable: undefined });
const guildIsAvailable = (
guild: GuildOrUnavailable,
): guild is Guild & { joined_at: Date; unavailable: false } => {
return guild.unavailable != true;
};
export interface IReadyGuildDTO { export interface IReadyGuildDTO {
application_command_counts?: { 1: number; 2: number; 3: number }; // ???????????? application_command_counts?: { 1: number; 2: number; 3: number }; // ????????????
channels: Channel[]; channels: Channel[];
@ -65,12 +97,21 @@ export interface IReadyGuildDTO {
max_members: number | undefined; max_members: number | undefined;
nsfw_level: number | undefined; nsfw_level: number | undefined;
hub_type?: unknown | null; // ???? hub_type?: unknown | null; // ????
home_header: null; // TODO
latest_onboarding_question_id: null; // TODO
safety_alerts_channel_id: null; // TODO
max_stage_video_channel_users: 50; // TODO
nsfw: boolean;
id: string;
}; };
roles: Role[]; roles: Role[];
stage_instances: unknown[]; stage_instances: unknown[];
stickers: Sticker[]; stickers: Sticker[];
threads: unknown[]; threads: unknown[];
version: string; version: string;
guild_hashes: unknown;
unavailable: boolean;
} }
export class ReadyGuildDTO implements IReadyGuildDTO { export class ReadyGuildDTO implements IReadyGuildDTO {
@ -113,14 +154,30 @@ export class ReadyGuildDTO implements IReadyGuildDTO {
max_members: number | undefined; max_members: number | undefined;
nsfw_level: number | undefined; nsfw_level: number | undefined;
hub_type?: unknown | null; // ???? hub_type?: unknown | null; // ????
home_header: null; // TODO
latest_onboarding_question_id: null; // TODO
safety_alerts_channel_id: null; // TODO
max_stage_video_channel_users: 50; // TODO
nsfw: boolean;
id: string;
}; };
roles: Role[]; roles: Role[];
stage_instances: unknown[]; stage_instances: unknown[];
stickers: Sticker[]; stickers: Sticker[];
threads: unknown[]; threads: unknown[];
version: string; version: string;
guild_hashes: unknown;
unavailable: boolean;
joined_at: Date;
constructor(guild: GuildOrUnavailable) {
if (!guildIsAvailable(guild)) {
this.id = guild.id;
this.unavailable = true;
return;
}
constructor(guild: Guild) {
this.application_command_counts = { this.application_command_counts = {
1: 5, 1: 5,
2: 2, 2: 2,
@ -164,12 +221,21 @@ export class ReadyGuildDTO implements IReadyGuildDTO {
max_members: guild.max_members, max_members: guild.max_members,
nsfw_level: guild.nsfw_level, nsfw_level: guild.nsfw_level,
hub_type: null, hub_type: null,
home_header: null,
id: guild.id,
latest_onboarding_question_id: null,
max_stage_video_channel_users: 50, // TODO
nsfw: guild.nsfw,
safety_alerts_channel_id: null,
}; };
this.roles = guild.roles; this.roles = guild.roles;
this.stage_instances = []; this.stage_instances = [];
this.stickers = guild.stickers; this.stickers = guild.stickers;
this.threads = []; this.threads = [];
this.version = "1"; // ?????? this.version = "1"; // ??????
this.guild_hashes = {};
this.joined_at = guild.joined_at;
} }
toJSON() { toJSON() {

View File

@ -468,6 +468,18 @@ export class Channel extends BaseClass {
]; ];
return disallowedChannelTypes.indexOf(this.type) == -1; return disallowedChannelTypes.indexOf(this.type) == -1;
} }
toJSON() {
return {
...this,
// these fields are not returned depending on the type of channel
bitrate: this.bitrate || undefined,
user_limit: this.user_limit || undefined,
rate_limit_per_user: this.rate_limit_per_user || undefined,
owner_id: this.owner_id || undefined,
};
}
} }
export interface ChannelPermissionOverwrite { export interface ChannelPermissionOverwrite {
@ -483,6 +495,12 @@ export enum ChannelPermissionOverwriteType {
group = 2, group = 2,
} }
export interface DMChannel extends Omit<Channel, "type" | "recipients"> {
type: ChannelType.DM | ChannelType.GROUP_DM;
recipients: Recipient[];
}
// TODO: probably more props
export function isTextChannel(type: ChannelType): boolean { export function isTextChannel(type: ChannelType): boolean {
switch (type) { switch (type) {
case ChannelType.GUILD_STORE: case ChannelType.GUILD_STORE:

View File

@ -353,6 +353,7 @@ export class Guild extends BaseClass {
position: 0, position: 0,
icon: undefined, icon: undefined,
unicode_emoji: undefined, unicode_emoji: undefined,
flags: 0, // TODO?
}).save(); }).save();
if (!body.channels || !body.channels.length) if (!body.channels || !body.channels.length)
@ -389,4 +390,11 @@ export class Guild extends BaseClass {
return guild; return guild;
} }
toJSON() {
return {
...this,
unavailable: this.unavailable == false ? undefined : true,
};
}
} }

View File

@ -193,7 +193,7 @@ export class Message extends BaseClass {
}; };
@Column({ nullable: true }) @Column({ nullable: true })
flags?: string; flags?: number;
@Column({ type: "simple-json", nullable: true }) @Column({ type: "simple-json", nullable: true })
message_reference?: { message_reference?: {
@ -217,6 +217,30 @@ export class Message extends BaseClass {
@Column({ type: "simple-json", nullable: true }) @Column({ type: "simple-json", nullable: true })
components?: MessageComponent[]; components?: MessageComponent[];
toJSON(): Message {
return {
...this,
author_id: undefined,
member_id: undefined,
guild_id: undefined,
webhook_id: undefined,
application_id: undefined,
nonce: undefined,
tts: this.tts ?? false,
guild: this.guild ?? undefined,
webhook: this.webhook ?? undefined,
interaction: this.interaction ?? undefined,
reactions: this.reactions ?? undefined,
sticker_items: this.sticker_items ?? undefined,
message_reference: this.message_reference ?? undefined,
author: this.author?.toPublicUser() ?? undefined,
activity: this.activity ?? undefined,
application: this.application ?? undefined,
components: this.components ?? undefined,
};
}
} }
export interface MessageComponent { export interface MessageComponent {

View File

@ -66,4 +66,7 @@ export class Role extends BaseClass {
integration_id?: string; integration_id?: string;
premium_subscriber?: boolean; premium_subscriber?: boolean;
}; };
@Column()
flags: number;
} }

View File

@ -175,7 +175,7 @@ export class User extends BaseClass {
email?: string; // email of the user email?: string; // email of the user
@Column() @Column()
flags: string = "0"; // UserFlags // TODO: generate flags: number = 0; // UserFlags // TODO: generate
@Column() @Column()
public_flags: number = 0; public_flags: number = 0;
@ -281,6 +281,15 @@ export class User extends BaseClass {
return user as PublicUser; return user as PublicUser;
} }
toPrivateUser() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const user: any = {};
PrivateUserProjection.forEach((x) => {
user[x] = this[x];
});
return user as UserPrivate;
}
static async getPublicUser(user_id: string, opts?: FindOneOptions<User>) { static async getPublicUser(user_id: string, opts?: FindOneOptions<User>) {
return await User.findOneOrFail({ return await User.findOneOrFail({
where: { id: user_id }, where: { id: user_id },

View File

@ -28,7 +28,6 @@ import {
Role, Role,
Emoji, Emoji,
PublicMember, PublicMember,
UserGuildSettings,
Guild, Guild,
Channel, Channel,
PublicUser, PublicUser,
@ -40,6 +39,10 @@ import {
UserSettings, UserSettings,
IReadyGuildDTO, IReadyGuildDTO,
ReadState, ReadState,
UserPrivate,
ReadyUserGuildSettingsEntries,
ReadyPrivateChannel,
GuildOrUnavailable,
} from "@spacebar/util"; } from "@spacebar/util";
export interface Event { export interface Event {
@ -68,22 +71,10 @@ export interface PublicRelationship {
export interface ReadyEventData { export interface ReadyEventData {
v: number; v: number;
user: PublicUser & { user: UserPrivate;
mobile: boolean; private_channels: ReadyPrivateChannel[]; // this will be empty for bots
desktop: boolean;
email: string | undefined;
flags: string;
mfa_enabled: boolean;
nsfw_allowed: boolean;
phone: string | undefined;
premium: boolean;
premium_type: number;
verified: boolean;
bot: boolean;
};
private_channels: Channel[]; // this will be empty for bots
session_id: string; // resuming session_id: string; // resuming
guilds: IReadyGuildDTO[]; guilds: IReadyGuildDTO[] | GuildOrUnavailable[]; // depends on capability
analytics_token?: string; analytics_token?: string;
connected_accounts?: ConnectedAccount[]; connected_accounts?: ConnectedAccount[];
consents?: { consents?: {
@ -115,7 +106,7 @@ export interface ReadyEventData {
version: number; version: number;
}; };
user_guild_settings?: { user_guild_settings?: {
entries: UserGuildSettings[]; entries: ReadyUserGuildSettingsEntries[];
version: number; version: number;
partial: boolean; partial: boolean;
}; };
@ -127,6 +118,17 @@ export interface ReadyEventData {
// probably all users who the user is in contact with // probably all users who the user is in contact with
users?: PublicUser[]; users?: PublicUser[];
sessions: unknown[]; sessions: unknown[];
api_code_version: number;
tutorial: number | null;
resume_gateway_url: string;
session_type: string;
auth_session_id_hash: string;
required_action?:
| "REQUIRE_VERIFIED_EMAIL"
| "REQUIRE_VERIFIED_PHONE"
| "REQUIRE_CAPTCHA" // TODO: allow these to be triggered
| "TOS_UPDATE_ACKNOWLEDGMENT"
| "AGREEMENTS";
} }
export interface ReadyEvent extends Event { export interface ReadyEvent extends Event {
@ -581,6 +583,7 @@ export type EventData =
export enum EVENTEnum { export enum EVENTEnum {
Ready = "READY", Ready = "READY",
ReadySupplemental = "READY_SUPPLEMENTAL",
ChannelCreate = "CHANNEL_CREATE", ChannelCreate = "CHANNEL_CREATE",
ChannelUpdate = "CHANNEL_UPDATE", ChannelUpdate = "CHANNEL_UPDATE",
ChannelDelete = "CHANNEL_DELETE", ChannelDelete = "CHANNEL_DELETE",

View File

@ -29,7 +29,7 @@ export interface MessageCreateSchema {
nonce?: string; nonce?: string;
channel_id?: string; channel_id?: string;
tts?: boolean; tts?: boolean;
flags?: string; flags?: number;
embeds?: Embed[]; embeds?: Embed[];
embed?: Embed; embed?: Embed;
// TODO: ^ embed is deprecated in favor of embeds (https://discord.com/developers/docs/resources/channel#message-object) // TODO: ^ embed is deprecated in favor of embeds (https://discord.com/developers/docs/resources/channel#message-object)

View File

@ -42,4 +42,8 @@ export interface RegisterSchema {
captcha_key?: string; captcha_key?: string;
promotional_email_opt_in?: boolean; promotional_email_opt_in?: boolean;
// part of pomelo
unique_username_registration?: boolean;
global_name?: string;
} }

View File

@ -27,6 +27,16 @@ const JSONReplacer = function (
return (this[key] as Date).toISOString().replace("Z", "+00:00"); return (this[key] as Date).toISOString().replace("Z", "+00:00");
} }
// erlpack encoding doesn't call json.stringify,
// so our toJSON functions don't get called.
// manually call it here
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
if (this?.[key]?.toJSON)
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
this[key] = this[key].toJSON();
return value; return value;
}; };

View File

@ -19,94 +19,66 @@
import jwt, { VerifyOptions } from "jsonwebtoken"; import jwt, { VerifyOptions } from "jsonwebtoken";
import { Config } from "./Config"; import { Config } from "./Config";
import { User } from "../entities"; import { User } from "../entities";
// TODO: dont use deprecated APIs lol
import {
FindOptionsRelationByString,
FindOptionsSelectByString,
} from "typeorm";
export const JWTOptions: VerifyOptions = { algorithms: ["HS256"] }; export const JWTOptions: VerifyOptions = { algorithms: ["HS256"] };
export type UserTokenData = { export type UserTokenData = {
user: User; user: User;
decoded: { id: string; iat: number }; decoded: { id: string; iat: number; email?: string };
}; };
async function checkEmailToken( export const checkToken = (
decoded: jwt.JwtPayload, token: string,
): Promise<UserTokenData> { opts?: {
// eslint-disable-next-line no-async-promise-executor select?: FindOptionsSelectByString<User>;
return new Promise(async (res, rej) => { relations?: FindOptionsRelationByString;
if (!decoded.iat) return rej("Invalid Token"); // will never happen, just for typings. },
): Promise<UserTokenData> =>
new Promise((resolve, reject) => {
jwt.verify(
token,
Config.get().security.jwtSecret,
JWTOptions,
async (err, out) => {
const decoded = out as UserTokenData["decoded"];
if (err || !decoded) return reject("Invalid Token");
const user = await User.findOne({ const user = await User.findOne({
where: { where: decoded.email
email: decoded.email, ? { email: decoded.email }
}, : { id: decoded.id },
select: [ select: [
"email", ...(opts?.select || []),
"id", "bot",
"verified",
"deleted",
"disabled", "disabled",
"username", "deleted",
"rights",
"data", "data",
], ],
relations: opts?.relations,
}); });
if (!user) return rej("Invalid Token"); if (!user) return reject("User not found");
if (new Date().getTime() > decoded.iat * 1000 + 86400 * 1000)
return rej("Invalid Token");
// Using as here because we assert `id` and `iat` are in decoded.
// TS just doesn't want to assume its there, though.
return res({ decoded, user } as UserTokenData);
});
}
export function checkToken(
token: string,
jwtSecret: string,
isEmailVerification = false,
): Promise<UserTokenData> {
return new Promise((res, rej) => {
token = token.replace("Bot ", "");
token = token.replace("Bearer ", "");
/**
in spacebar, even with instances that have bot distinction; we won't enforce "Bot" prefix,
as we don't really have separate pathways for bots
**/
jwt.verify(token, jwtSecret, JWTOptions, async (err, decoded) => {
if (err || !decoded) return rej("Invalid Token");
if (
typeof decoded == "string" ||
!("id" in decoded) ||
!decoded.iat
)
return rej("Invalid Token"); // will never happen, just for typings.
if (isEmailVerification) return res(checkEmailToken(decoded));
const user = await User.findOne({
where: { id: decoded.id },
select: ["data", "bot", "disabled", "deleted", "rights"],
});
if (!user) return rej("Invalid Token");
// we need to round it to seconds as it saved as seconds in jwt iat and valid_tokens_since is stored in milliseconds // we need to round it to seconds as it saved as seconds in jwt iat and valid_tokens_since is stored in milliseconds
if ( if (
decoded.iat * 1000 < decoded.iat * 1000 <
new Date(user.data.valid_tokens_since).setSeconds(0, 0) new Date(user.data.valid_tokens_since).setSeconds(0, 0)
) )
return rej("Invalid Token"); return reject("Invalid Token");
if (user.disabled) return rej("User disabled"); if (user.disabled) return reject("User disabled");
if (user.deleted) return rej("User not found"); if (user.deleted) return reject("User not found");
// Using as here because we assert `id` and `iat` are in decoded. return resolve({ decoded, user });
// TS just doesn't want to assume its there, though. },
return res({ decoded, user } as UserTokenData); );
}); });
});
}
export async function generateToken(id: string, email?: string) { export async function generateToken(id: string, email?: string) {
const iat = Math.floor(Date.now() / 1000); const iat = Math.floor(Date.now() / 1000);