diff --git a/src/api/middlewares/Authentication.ts b/src/api/middlewares/Authentication.ts
index d0e4d8a0..812888a3 100644
--- a/src/api/middlewares/Authentication.ts
+++ b/src/api/middlewares/Authentication.ts
@@ -92,12 +92,7 @@ export async function Authentication(
Sentry.setUser({ id: req.user_id });
try {
- const { jwtSecret } = Config.get().security;
-
- const { decoded, user } = await checkToken(
- req.headers.authorization,
- jwtSecret,
- );
+ const { decoded, user } = await checkToken(req.headers.authorization);
req.token = decoded;
req.user_id = decoded.id;
diff --git a/src/api/routes/auth/register.ts b/src/api/routes/auth/register.ts
index 321b4a65..14dc319a 100644
--- a/src/api/routes/auth/register.ts
+++ b/src/api/routes/auth/register.ts
@@ -225,6 +225,20 @@ router.post(
}
if (body.password) {
+ const min = register.password.minLength
+ ? register.password.minLength
+ : 8;
+ if (body.password.length < min) {
+ throw FieldErrors({
+ password: {
+ code: "PASSWORD_REQUIREMENTS_MIN_LENGTH",
+ message: req.t(
+ "auth:register.PASSWORD_REQUIREMENTS_MIN_LENGTH",
+ { min: min },
+ ),
+ },
+ });
+ }
// the salt is saved in the password refer to bcrypt docs
body.password = await bcrypt.hash(body.password, 12);
} else if (register.password.required) {
diff --git a/src/api/routes/auth/reset.ts b/src/api/routes/auth/reset.ts
index f97045a6..cb4f8180 100644
--- a/src/api/routes/auth/reset.ts
+++ b/src/api/routes/auth/reset.ts
@@ -48,11 +48,9 @@ router.post(
async (req: Request, res: Response) => {
const { password, token } = req.body as PasswordResetSchema;
- const { jwtSecret } = Config.get().security;
-
let user;
try {
- const userTokenData = await checkToken(token, jwtSecret, true);
+ const userTokenData = await checkToken(token);
user = userTokenData.user;
} catch {
throw FieldErrors({
diff --git a/src/api/routes/auth/verify/index.ts b/src/api/routes/auth/verify/index.ts
index a98c17fa..49f74277 100644
--- a/src/api/routes/auth/verify/index.ts
+++ b/src/api/routes/auth/verify/index.ts
@@ -78,11 +78,10 @@ router.post(
}
}
- const { jwtSecret } = Config.get().security;
let user;
try {
- const userTokenData = await checkToken(token, jwtSecret, true);
+ const userTokenData = await checkToken(token);
user = userTokenData.user;
} catch {
throw FieldErrors({
diff --git a/src/api/routes/channels/#channel_id/messages/index.ts b/src/api/routes/channels/#channel_id/messages/index.ts
index f031fa75..edc0321c 100644
--- a/src/api/routes/channels/#channel_id/messages/index.ts
+++ b/src/api/routes/channels/#channel_id/messages/index.ts
@@ -20,7 +20,6 @@ import { handleMessage, postHandleMessage, route } from "@spacebar/api";
import {
Attachment,
Channel,
- ChannelType,
Config,
DmChannelDTO,
FieldErrors,
@@ -93,8 +92,6 @@ router.get(
if (limit < 1 || limit > 100)
throw new HTTPError("limit must be between 1 and 100", 422);
- const halfLimit = Math.floor(limit / 2);
-
const permissions = await getPermission(
req.user_id,
channel.guild_id,
@@ -121,64 +118,72 @@ router.get(
],
};
- if (after) {
- if (BigInt(after) > BigInt(Snowflake.generate()))
- return res.status(422);
- query.where.id = MoreThan(after);
- } else if (before) {
- if (BigInt(before) < BigInt(req.params.channel_id))
- return res.status(422);
- query.where.id = LessThan(before);
- } else if (around) {
- query.where.id = [
- MoreThan((BigInt(around) - BigInt(halfLimit)).toString()),
- LessThan((BigInt(around) + BigInt(halfLimit)).toString()),
- ];
-
- return res.json([]); // TODO: fix around
+ let messages: Message[];
+
+ if (around) {
+ query.take = Math.floor(limit / 2);
+ const [right, left] = await Promise.all([
+ Message.find({ ...query, where: { id: LessThan(around) } }),
+ Message.find({ ...query, where: { id: MoreThan(around) } }),
+ ]);
+ right.push(...left);
+ messages = right;
+ } else {
+ if (after) {
+ if (BigInt(after) > BigInt(Snowflake.generate()))
+ return res.status(422);
+ query.where.id = MoreThan(after);
+ } else if (before) {
+ if (BigInt(before) < BigInt(Snowflake.generate()))
+ return res.status(422);
+ query.where.id = LessThan(before);
+ }
+
+ messages = await Message.find(query);
}
- const messages = await Message.find(query);
const endpoint = Config.get().cdn.endpointPublic;
- return res.json(
- messages.map((x: Partial<Message>) => {
- (x.reactions || []).forEach((y: Partial<Reaction>) => {
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- //@ts-ignore
- if ((y.user_ids || []).includes(req.user_id)) y.me = true;
- delete y.user_ids;
- });
- if (!x.author)
- x.author = User.create({
- id: "4",
- discriminator: "0000",
- username: "Spacebar Ghost",
- public_flags: 0,
- });
- x.attachments?.forEach((y: Attachment) => {
- // dynamically set attachment proxy_url in case the endpoint changed
- const uri = y.proxy_url.startsWith("http")
- ? y.proxy_url
- : `https://example.org${y.proxy_url}`;
- y.proxy_url = `${endpoint == null ? "" : endpoint}${
- new URL(uri).pathname
- }`;
+ const ret = messages.map((x: Message) => {
+ x = x.toJSON();
+
+ (x.reactions || []).forEach((y: Partial<Reaction>) => {
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ //@ts-ignore
+ if ((y.user_ids || []).includes(req.user_id)) y.me = true;
+ delete y.user_ids;
+ });
+ if (!x.author)
+ x.author = User.create({
+ id: "4",
+ discriminator: "0000",
+ username: "Spacebar Ghost",
+ public_flags: 0,
});
+ x.attachments?.forEach((y: Attachment) => {
+ // dynamically set attachment proxy_url in case the endpoint changed
+ const uri = y.proxy_url.startsWith("http")
+ ? y.proxy_url
+ : `https://example.org${y.proxy_url}`;
+ y.proxy_url = `${endpoint == null ? "" : endpoint}${
+ new URL(uri).pathname
+ }`;
+ });
- /**
+ /**
Some clients ( discord.js ) only check if a property exists within the response,
which causes errors when, say, the `application` property is `null`.
**/
- // for (var curr in x) {
- // if (x[curr] === null)
- // delete x[curr];
- // }
+ // for (var curr in x) {
+ // if (x[curr] === null)
+ // delete x[curr];
+ // }
- return x;
- }),
- );
+ return x;
+ });
+
+ return res.json(ret);
},
);
@@ -304,9 +309,11 @@ router.post(
embeds,
channel_id,
attachments,
- edited_timestamp: undefined,
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;
diff --git a/src/api/routes/guilds/#guild_id/index.ts b/src/api/routes/guilds/#guild_id/index.ts
index afe60614..86777b36 100644
--- a/src/api/routes/guilds/#guild_id/index.ts
+++ b/src/api/routes/guilds/#guild_id/index.ts
@@ -161,7 +161,7 @@ router.patch(
const data = guild.toJSON();
// TODO: guild hashes
// TODO: fix vanity_url_code, template_id
- delete data.vanity_url_code;
+ // delete data.vanity_url_code;
delete data.template_id;
await Promise.all([
diff --git a/src/api/routes/guilds/#guild_id/members/#member_id/index.ts b/src/api/routes/guilds/#guild_id/members/#member_id/index.ts
index 5f1f6fa7..cafb922e 100644
--- a/src/api/routes/guilds/#guild_id/members/#member_id/index.ts
+++ b/src/api/routes/guilds/#guild_id/members/#member_id/index.ts
@@ -27,6 +27,8 @@ import {
handleFile,
Member,
MemberChangeSchema,
+ PublicMemberProjection,
+ PublicUserProjection,
Role,
Sticker,
} from "@spacebar/util";
@@ -39,7 +41,7 @@ router.get(
route({
responses: {
200: {
- body: "Member",
+ body: "APIPublicMember",
},
403: {
body: "APIErrorResponse",
@@ -55,9 +57,28 @@ router.get(
const member = await Member.findOneOrFail({
where: { id: member_id, guild_id },
+ relations: ["roles", "user"],
+ select: {
+ index: true,
+ // only grab public member props
+ ...Object.fromEntries(
+ PublicMemberProjection.map((x) => [x, true]),
+ ),
+ // and public user props
+ user: Object.fromEntries(
+ PublicUserProjection.map((x) => [x, true]),
+ ),
+ roles: {
+ id: true,
+ },
+ },
});
- return res.json(member);
+ return res.json({
+ ...member.toPublicMember(),
+ user: member.user.toPublicUser(),
+ roles: member.roles.map((x) => x.id),
+ });
},
);
diff --git a/src/api/routes/guilds/index.ts b/src/api/routes/guilds/index.ts
index 26173ed5..545beb18 100644
--- a/src/api/routes/guilds/index.ts
+++ b/src/api/routes/guilds/index.ts
@@ -72,7 +72,7 @@ router.post(
await Member.addToGuild(req.user_id, guild.id);
- res.status(201).json({ id: guild.id });
+ res.status(201).json(guild);
},
);
diff --git a/src/api/routes/users/#id/profile.ts b/src/api/routes/users/#id/profile.ts
index a94eb546..eecec0f3 100644
--- a/src/api/routes/users/#id/profile.ts
+++ b/src/api/routes/users/#id/profile.ts
@@ -84,18 +84,6 @@ router.get(
// TODO: make proper DTO's in util?
- const userDto = {
- username: user.username,
- discriminator: user.discriminator,
- id: user.id,
- public_flags: user.public_flags,
- avatar: user.avatar,
- accent_color: user.accent_color,
- banner: user.banner,
- bio: req.user_bot ? null : user.bio,
- bot: user.bot,
- };
-
const userProfile = {
bio: req.user_bot ? null : user.bio,
accent_color: user.accent_color,
@@ -104,28 +92,6 @@ router.get(
theme_colors: user.theme_colors,
};
- const guildMemberDto = guild_member
- ? {
- avatar: guild_member.avatar,
- banner: guild_member.banner,
- bio: req.user_bot ? null : guild_member.bio,
- communication_disabled_until:
- guild_member.communication_disabled_until,
- deaf: guild_member.deaf,
- flags: user.flags,
- is_pending: guild_member.pending,
- pending: guild_member.pending, // why is this here twice, discord?
- joined_at: guild_member.joined_at,
- mute: guild_member.mute,
- nick: guild_member.nick,
- premium_since: guild_member.premium_since,
- roles: guild_member.roles
- .map((x) => x.id)
- .filter((id) => id != guild_id),
- user: userDto,
- }
- : undefined;
-
const guildMemberProfile = {
accent_color: null,
banner: guild_member?.banner || null,
@@ -139,11 +105,11 @@ router.get(
premium_guild_since: premium_guild_since, // TODO
premium_since: user.premium_since, // TODO
mutual_guilds: mutual_guilds, // TODO {id: "", nick: null} when ?with_mutual_guilds=true
- user: userDto,
+ user: user.toPublicUser(),
premium_type: user.premium_type,
profile_themes_experiment_bucket: 4, // TODO: This doesn't make it available, for some reason?
user_profile: userProfile,
- guild_member: guild_id && guildMemberDto,
+ guild_member: guild_member?.toPublicMember(),
guild_member_profile: guild_id && guildMemberProfile,
});
},
diff --git a/src/api/util/utility/ipAddress.ts b/src/api/util/utility/ipAddress.ts
index 172e9604..c51daf6c 100644
--- a/src/api/util/utility/ipAddress.ts
+++ b/src/api/util/utility/ipAddress.ts
@@ -102,7 +102,7 @@ export function getIpAdress(req: Request): string {
return (
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
- req.headers[Config.get().security.forwadedFor] ||
+ req.headers[Config.get().security.forwardedFor] ||
req.socket.remoteAddress
);
}
diff --git a/src/gateway/events/Connection.ts b/src/gateway/events/Connection.ts
index 68273ace..1991ebbe 100644
--- a/src/gateway/events/Connection.ts
+++ b/src/gateway/events/Connection.ts
@@ -45,7 +45,7 @@ export async function Connection(
socket: WebSocket,
request: IncomingMessage,
) {
- const forwardedFor = Config.get().security.forwadedFor;
+ const forwardedFor = Config.get().security.forwardedFor;
const ipAddress = forwardedFor
? (request.headers[forwardedFor] as string)
: request.socket.remoteAddress;
diff --git a/src/gateway/opcodes/Heartbeat.ts b/src/gateway/opcodes/Heartbeat.ts
index 7866c3e9..b9b62be3 100644
--- a/src/gateway/opcodes/Heartbeat.ts
+++ b/src/gateway/opcodes/Heartbeat.ts
@@ -25,5 +25,5 @@ export async function onHeartbeat(this: WebSocket) {
setHeartbeat(this);
- await Send(this, { op: 11 });
+ await Send(this, { op: 11, d: {} });
}
diff --git a/src/gateway/opcodes/Identify.ts b/src/gateway/opcodes/Identify.ts
index 98fae3ed..7610901a 100644
--- a/src/gateway/opcodes/Identify.ts
+++ b/src/gateway/opcodes/Identify.ts
@@ -16,17 +16,23 @@
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 {
checkToken,
Intents,
Member,
ReadyEventData,
- User,
Session,
EVENTEnum,
Config,
- PublicMember,
PublicUser,
PrivateUserProjection,
ReadState,
@@ -36,310 +42,385 @@ import {
PrivateSessionProjection,
MemberPrivateProjection,
PresenceUpdateEvent,
- UserSettings,
IdentifySchema,
DefaultUserGuildSettings,
- UserGuildSettings,
ReadyGuildDTO,
Guild,
- UserTokenData,
- ConnectedAccount,
+ PublicUserProjection,
+ ReadyUserGuildSettingsEntries,
+ UserSettings,
+ Permissions,
+ DMChannel,
+ GuildOrUnavailable,
+ Recipient,
+ OPCodes,
} 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 { Recipient } from "@spacebar/util";
// TODO: user sharding
// 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) {
+ if (this.user_id) {
+ // we've already identified
+ return this.close(CLOSECODES.Already_authenticated);
+ }
+
clearTimeout(this.readyTimeout);
- // TODO: is this needed now that we use `json-bigint`?
- if (typeof data.d?.client_state?.highest_last_message_id === "number")
- data.d.client_state.highest_last_message_id += "";
- check.call(this, IdentifySchema, data.d);
+ // Check payload matches schema
+ check.call(this, IdentifySchema, data.d);
const identify: IdentifySchema = data.d;
- let decoded: UserTokenData["decoded"];
- 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 [
- user,
- read_states,
- members,
- recipients,
- session,
- application,
- connected_accounts,
- ] = await Promise.all([
- User.findOneOrFail({
- where: { id: this.user_id },
- relations: ["relationships", "relationships.to", "settings"],
- select: [...PrivateUserProjection, "relationships"],
- }),
- ReadState.find({ where: { user_id: this.user_id } }),
- Member.find({
- where: { id: this.user_id },
- select: MemberPrivateProjection,
- relations: [
- "guild",
- "guild.channels",
- "guild.emojis",
- "guild.roles",
- "guild.stickers",
- "user",
- "roles",
- ],
- }),
- Recipient.find({
- where: { user_id: this.user_id, closed: false },
- relations: [
- "channel",
- "channel.recipients",
- "channel.recipients.user",
- ],
- // TODO: public user selection
- }),
- // save the session and delete it when the websocket is closed
- Session.create({
- user_id: this.user_id,
- session_id: session_id,
- // TODO: check if status is only one of: online, dnd, offline, idle
- status: identify.presence?.status || "offline", //does the session always start as online?
- client_info: {
- //TODO read from identity
- client: "desktop",
- os: identify.properties?.os,
- version: 0,
- },
- activities: [],
- }).save(),
- Application.findOne({ where: { id: this.user_id } }),
- ConnectedAccount.find({ where: { user_id: this.user_id } }),
- ]);
+ this.capabilities = new Capabilities(identify.capabilities || 0);
+ const { user } = await checkToken(identify.token, {
+ relations: ["relationships", "relationships.to", "settings"],
+ select: [...PrivateUserProjection, "relationships"],
+ });
if (!user) return this.close(CLOSECODES.Authentication_failed);
- if (!user.settings) {
- user.settings = new UserSettings();
- await user.settings.save();
- }
+ this.user_id = user.id;
- if (!identify.intents) identify.intents = BigInt("0x6ffffffff");
+ // 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 > this.shard_count ||
this.shard_id < 0 ||
this.shard_count <= 0
) {
- console.log(identify.shard);
+ // 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);
}
}
- let users: PublicUser[] = [];
- const merged_members = members.map((x: Member) => {
+ // 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 },
+ select: [
+ "id",
+ "channel_id",
+ "last_message_id",
+ "last_pin_timestamp",
+ "mention_count",
+ ],
+ }),
+
+ Member.find({
+ where: { id: this.user_id },
+ 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: [
+ "guild",
+ "guild.channels",
+ "guild.emojis",
+ "guild.roles",
+ "guild.stickers",
+ "roles",
+
+ // For these entities, `user` is always just the logged in user we fetched above
+ // "user",
+ ],
+ }),
+
+ Recipient.find({
+ where: { user_id: this.user_id, closed: false },
+ relations: [
+ "channel",
+ "channel.recipients",
+ "channel.recipients.user",
+ ],
+ select: {
+ channel: {
+ id: true,
+ flags: true,
+ // is_spam: true, // TODO
+ last_message_id: true,
+ last_pin_timestamp: true,
+ type: true,
+ icon: true,
+ name: true,
+ owner_id: true,
+ recipients: {
+ // 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]),
+ ),
+ },
+ },
+ },
+ }),
+ ],
+ );
+
+ // 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) {
+ user.settings = new UserSettings();
+ await user.settings.save();
+ }
+
+ // Generate merged_members
+ const merged_members = members.map((x) => {
return [
{
...x,
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,
- 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 };
+ // Populated with guilds 'unavailable' currently
+ // 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");
});
- // TODO: Rewrite this. Perhaps a DTO?
- const user_guild_settings_entries = members.map((x) => ({
- ...DefaultUserGuildSettings,
- ...x.settings,
- guild_id: x.guild.id,
- channel_overrides: Object.entries(
- x.settings.channel_overrides ?? {},
- ).map((y) => ({
- ...y[1],
- channel_id: y[0],
- })),
- })) as unknown as UserGuildSettings[];
-
- const channels = recipients.map((x) => {
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- //@ts-ignore
- x.channel.recipients = x.channel.recipients.map((x) =>
- x.user.toPublicUser(),
- );
- //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[]);
- if (x.channel.isDm()) {
- x.channel.recipients = x.channel.recipients?.filter(
- (x) => x.id !== this.user_id,
- );
+ if (user.bot) {
+ pending_guilds.push(member.guild);
+ return { id: member.guild.id, unavailable: true };
}
- return x.channel;
- });
- for (const relation of user.relationships) {
- const related_user = relation.to;
- 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,
+ return {
+ ...member.guild.toJSON(),
+ joined_at: member.joined_at,
+
+ threads: [],
};
- users.push(public_related_user);
- }
+ });
+
+ // Generate user_guild_settings
+ const user_guild_settings_entries: ReadyUserGuildSettingsEntries[] =
+ members.map((x) => ({
+ ...DefaultUserGuildSettings,
+ ...x.settings,
+ guild_id: x.guild_id,
+ channel_overrides: Object.entries(
+ x.settings.channel_overrides ?? {},
+ ).map((y) => ({
+ ...y[1],
+ channel_id: y[0],
+ })),
+ }));
+
+ // Popultaed with users from private channels, relationships.
+ // Uses a set to dedupe for us.
+ const users: Set<PublicUser> = new Set();
+
+ // 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,
+ );
+
+ const channelUsers = channel.recipients?.map((recipient) =>
+ recipient.user.toPublicUser(),
+ );
+
+ if (channelUsers && channelUsers.length > 0)
+ channelUsers.forEach((user) => users.add(user));
- setImmediate(async () => {
- // run in seperate "promise context" because ready payload is not dependent on those events
+ return {
+ id: channel.id,
+ flags: channel.flags,
+ last_message_id: channel.last_message_id,
+ type: channel.type,
+ recipients: channelUsers || [],
+ is_spam: false, // TODO
+ };
+ });
+
+ // From user relationships ( friends ), also append to `users` list
+ user.relationships.forEach((x) => users.add(x.to.toPublicUser()));
+
+ // Send SESSIONS_REPLACE and PRESENCE_UPDATE
+ 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({
event: "SESSIONS_REPLACE",
user_id: this.user_id,
- data: await Session.find({
- where: { user_id: this.user_id },
- select: PrivateSessionProjection,
- }),
- } as SessionsReplace);
+ data: allSessions,
+ } as SessionsReplace),
emitEvent({
event: "PRESENCE_UPDATE",
user_id: this.user_id,
data: {
- user: await User.getPublicUser(this.user_id),
+ user: user.toPublicUser(),
activities: session.activities,
- client_status: session?.client_info,
+ client_status: session.client_info,
status: session.status,
},
- } as PresenceUpdateEvent);
- });
+ } as PresenceUpdateEvent),
+ ]);
- read_states.forEach((s: Partial<ReadState>) => {
- s.id = s.channel_id;
- delete s.user_id;
- delete s.channel_id;
- });
+ // Build READY
- const privateUser = {
- avatar: user.avatar,
- 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,
- };
+ read_states.forEach((x) => {
+ x.id = x.channel_id;
+ });
const d: ReadyEventData = {
v: 9,
- application: {
- id: application?.id ?? "",
- flags: application?.flags ?? 0,
- }, //TODO: check this code!
- user: privateUser,
+ application: application
+ ? { id: application.id, flags: application.flags }
+ : undefined,
+ user: user.toPrivateUser(),
user_settings: user.settings,
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-ignore
- guilds: guilds.map((x: Guild & { joined_at: Date }) => {
- 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
+ guilds: this.capabilities.has(Capabilities.FLAGS.CLIENT_STATE_V2)
+ ? guilds.map((x) => new ReadyGuildDTO(x).toJSON())
+ : guilds,
relationships: user.relationships.map((x) => x.toPublicRelationship()),
read_state: {
entries: read_states,
partial: false,
- version: 304128,
+ version: 0, // TODO
},
user_guild_settings: {
entries: user_guild_settings_entries,
- partial: false, // TODO partial
- version: 642,
+ partial: false,
+ version: 0, // TODO
},
private_channels: channels,
- session_id: session_id,
- analytics_token: "", // TODO
- connected_accounts,
- 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(),
+ session_id: this.session_id,
+ country_code: user.settings.locale, // TODO: do ip analysis instead
+ users: Array.from(users),
merged_members: merged_members,
- // shard // TODO: only for user sharding
- sessions: [], // TODO:
+ sessions: allSessions,
+
+ resume_gateway_url:
+ Config.get().gateway.endpointClient ||
+ Config.get().gateway.endpointPublic ||
+ "ws://127.0.0.1:3001",
// lol hack whatever
required_action:
Config.get().login.requireVerification && !user.verified
? "REQUIRE_VERIFIED_EMAIL"
: 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, {
op: OPCODES.Dispatch,
t: EVENTEnum.Ready,
@@ -347,23 +428,41 @@ export async function onIdentify(this: WebSocket, data: Payload) {
d,
});
+ // If we're a bot user, send GUILD_CREATE for each unavailable guild
await Promise.all(
- pending_guilds.map((guild) =>
+ pending_guilds.map((x) =>
Send(this, {
op: OPCODES.Dispatch,
t: EVENTEnum.GuildCreate,
s: this.sequence++,
- d: guild,
- })?.catch(console.error),
+ d: x,
+ })?.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 SESSIONS_REPLACE
//TODO send VOICE_STATE_UPDATE to let the client know if another device is already connected to a voice channel
await setupListener.call(this);
-
- // console.log(`${this.ipAddress} identified as ${d.user.id}`);
}
diff --git a/src/gateway/opcodes/LazyRequest.ts b/src/gateway/opcodes/LazyRequest.ts
index cde91a75..4ad1ae7b 100644
--- a/src/gateway/opcodes/LazyRequest.ts
+++ b/src/gateway/opcodes/LazyRequest.ts
@@ -27,6 +27,8 @@ import {
User,
Presence,
partition,
+ Channel,
+ Permissions,
} from "@spacebar/util";
import {
WebSocket,
@@ -35,6 +37,7 @@ import {
OPCODES,
Send,
} from "@spacebar/gateway";
+import murmur from "murmurhash-js/murmurhash3_gc";
import { check } from "./instanceOf";
// TODO: only show roles/members that have access to this channel
@@ -92,7 +95,7 @@ async function getMembers(guild_id: string, range: [number, number]) {
console.error(`LazyRequest`, e);
}
- if (!members) {
+ if (!members || !members.length) {
return {
items: [],
groups: [],
@@ -271,6 +274,28 @@ export async function onLazyRequest(this: WebSocket, { d }: Payload) {
ranges.map((x) => getMembers(guild_id, x as [number, number])),
);
+ let list_id = "everyone";
+
+ const channel = await Channel.findOneOrFail({
+ where: { id: channel_id },
+ });
+ if (channel.permission_overwrites) {
+ const perms: string[] = [];
+
+ channel.permission_overwrites.forEach((overwrite) => {
+ const { id, allow, deny } = overwrite;
+
+ if (allow.toBigInt() & Permissions.FLAGS.VIEW_CHANNEL)
+ perms.push(`allow:${id}`);
+ else if (deny.toBigInt() & Permissions.FLAGS.VIEW_CHANNEL)
+ perms.push(`deny:${id}`);
+ });
+
+ if (perms.length > 0) {
+ list_id = murmur(perms.sort().join(",")).toString();
+ }
+ }
+
// TODO: unsubscribe member_events that are not in op.members
ops.forEach((op) => {
@@ -299,7 +324,7 @@ export async function onLazyRequest(this: WebSocket, { d }: Payload) {
member_count -
(groups.find((x) => x.id == "offline")?.count ?? 0),
member_count,
- id: "everyone",
+ id: list_id,
guild_id,
groups,
},
diff --git a/src/gateway/util/Capabilities.ts b/src/gateway/util/Capabilities.ts
new file mode 100644
index 00000000..6c94bb45
--- /dev/null
+++ b/src/gateway/util/Capabilities.ts
@@ -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);
+}
diff --git a/src/gateway/util/WebSocket.ts b/src/gateway/util/WebSocket.ts
index 972129c7..833756ff 100644
--- a/src/gateway/util/WebSocket.ts
+++ b/src/gateway/util/WebSocket.ts
@@ -19,6 +19,7 @@
import { Intents, ListenEventOpts, Permissions } from "@spacebar/util";
import WS from "ws";
import { Deflate, Inflate } from "fast-zlib";
+import { Capabilities } from "./Capabilities";
// import { Client } from "@spacebar/webrtc";
export interface WebSocket extends WS {
@@ -40,5 +41,6 @@ export interface WebSocket extends WS {
events: Record<string, undefined | (() => unknown)>;
member_events: Record<string, () => unknown>;
listen_options: ListenEventOpts;
+ capabilities?: Capabilities;
// client?: Client;
}
diff --git a/src/gateway/util/index.ts b/src/gateway/util/index.ts
index 627f12b2..6ef694d9 100644
--- a/src/gateway/util/index.ts
+++ b/src/gateway/util/index.ts
@@ -21,3 +21,4 @@ export * from "./Send";
export * from "./SessionUtils";
export * from "./Heartbeat";
export * from "./WebSocket";
+export * from "./Capabilities";
diff --git a/src/util/config/types/SecurityConfiguration.ts b/src/util/config/types/SecurityConfiguration.ts
index 5e971cfe..35776642 100644
--- a/src/util/config/types/SecurityConfiguration.ts
+++ b/src/util/config/types/SecurityConfiguration.ts
@@ -28,7 +28,7 @@ export class SecurityConfiguration {
// header to get the real user ip address
// X-Forwarded-For for nginx/reverse proxies
// CF-Connecting-IP for cloudflare
- forwadedFor: string | null = null;
+ forwardedFor: string | null = null;
ipdataApiKey: string | null =
"eca677b284b3bac29eb72f5e496aa9047f26543605efe99ff2ce35c9";
mfaBackupCodeCount: number = 10;
diff --git a/src/util/dtos/ReadyGuildDTO.ts b/src/util/dtos/ReadyGuildDTO.ts
index b21afe74..905ede74 100644
--- a/src/util/dtos/ReadyGuildDTO.ts
+++ b/src/util/dtos/ReadyGuildDTO.ts
@@ -18,13 +18,45 @@
import {
Channel,
+ ChannelOverride,
+ ChannelType,
Emoji,
Guild,
- PublicMember,
+ PublicUser,
Role,
Sticker,
+ UserGuildSettings,
+ PublicMember,
} 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 {
application_command_counts?: { 1: number; 2: number; 3: number }; // ????????????
channels: Channel[];
@@ -65,12 +97,21 @@ export interface IReadyGuildDTO {
max_members: number | undefined;
nsfw_level: number | undefined;
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[];
stage_instances: unknown[];
stickers: Sticker[];
threads: unknown[];
version: string;
+ guild_hashes: unknown;
+ unavailable: boolean;
}
export class ReadyGuildDTO implements IReadyGuildDTO {
@@ -113,14 +154,30 @@ export class ReadyGuildDTO implements IReadyGuildDTO {
max_members: number | undefined;
nsfw_level: number | undefined;
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[];
stage_instances: unknown[];
stickers: Sticker[];
threads: unknown[];
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 = {
1: 5,
2: 2,
@@ -164,12 +221,21 @@ export class ReadyGuildDTO implements IReadyGuildDTO {
max_members: guild.max_members,
nsfw_level: guild.nsfw_level,
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.stage_instances = [];
this.stickers = guild.stickers;
this.threads = [];
this.version = "1"; // ??????
+ this.guild_hashes = {};
+ this.joined_at = guild.joined_at;
}
toJSON() {
diff --git a/src/util/entities/Channel.ts b/src/util/entities/Channel.ts
index e23d93db..38627c39 100644
--- a/src/util/entities/Channel.ts
+++ b/src/util/entities/Channel.ts
@@ -468,6 +468,18 @@ export class Channel extends BaseClass {
];
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 {
@@ -483,6 +495,12 @@ export enum ChannelPermissionOverwriteType {
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 {
switch (type) {
case ChannelType.GUILD_STORE:
diff --git a/src/util/entities/Guild.ts b/src/util/entities/Guild.ts
index e2b3e1bd..e364ed98 100644
--- a/src/util/entities/Guild.ts
+++ b/src/util/entities/Guild.ts
@@ -353,6 +353,7 @@ export class Guild extends BaseClass {
position: 0,
icon: undefined,
unicode_emoji: undefined,
+ flags: 0, // TODO?
}).save();
if (!body.channels || !body.channels.length)
@@ -389,4 +390,11 @@ export class Guild extends BaseClass {
return guild;
}
+
+ toJSON() {
+ return {
+ ...this,
+ unavailable: this.unavailable == false ? undefined : true,
+ };
+ }
}
diff --git a/src/util/entities/Member.ts b/src/util/entities/Member.ts
index 8c208202..8be6eae1 100644
--- a/src/util/entities/Member.ts
+++ b/src/util/entities/Member.ts
@@ -344,11 +344,7 @@ export class Member extends BaseClassWithoutId {
relations: ["user", "roles"],
take: 10,
})
- ).map((member) => ({
- ...member.toPublicMember(),
- user: member.user.toPublicUser(),
- roles: member.roles.map((x) => x.id),
- }));
+ ).map((member) => member.toPublicMember());
if (
await Member.count({
@@ -455,6 +451,10 @@ export class Member extends BaseClassWithoutId {
PublicMemberProjection.forEach((x) => {
member[x] = this[x];
});
+
+ if (member.roles) member.roles = member.roles.map((x: Role) => x.id);
+ if (member.user) member.user = member.user.toPublicUser();
+
return member as PublicMember;
}
}
diff --git a/src/util/entities/Message.ts b/src/util/entities/Message.ts
index 519c431e..e5390300 100644
--- a/src/util/entities/Message.ts
+++ b/src/util/entities/Message.ts
@@ -193,7 +193,7 @@ export class Message extends BaseClass {
};
@Column({ nullable: true })
- flags?: string;
+ flags?: number;
@Column({ type: "simple-json", nullable: true })
message_reference?: {
@@ -217,6 +217,30 @@ export class Message extends BaseClass {
@Column({ type: "simple-json", nullable: true })
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 {
diff --git a/src/util/entities/Role.ts b/src/util/entities/Role.ts
index 85877c12..3ae5efc1 100644
--- a/src/util/entities/Role.ts
+++ b/src/util/entities/Role.ts
@@ -66,4 +66,7 @@ export class Role extends BaseClass {
integration_id?: string;
premium_subscriber?: boolean;
};
+
+ @Column()
+ flags: number;
}
diff --git a/src/util/entities/User.ts b/src/util/entities/User.ts
index 3e72c3c9..3f1bda05 100644
--- a/src/util/entities/User.ts
+++ b/src/util/entities/User.ts
@@ -175,7 +175,7 @@ export class User extends BaseClass {
email?: string; // email of the user
@Column()
- flags: string = "0"; // UserFlags // TODO: generate
+ flags: number = 0; // UserFlags // TODO: generate
@Column()
public_flags: number = 0;
@@ -281,6 +281,15 @@ export class User extends BaseClass {
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>) {
return await User.findOneOrFail({
where: { id: user_id },
diff --git a/src/util/interfaces/Event.ts b/src/util/interfaces/Event.ts
index 76a5f8d0..deb54428 100644
--- a/src/util/interfaces/Event.ts
+++ b/src/util/interfaces/Event.ts
@@ -28,7 +28,6 @@ import {
Role,
Emoji,
PublicMember,
- UserGuildSettings,
Guild,
Channel,
PublicUser,
@@ -40,6 +39,10 @@ import {
UserSettings,
IReadyGuildDTO,
ReadState,
+ UserPrivate,
+ ReadyUserGuildSettingsEntries,
+ ReadyPrivateChannel,
+ GuildOrUnavailable,
} from "@spacebar/util";
export interface Event {
@@ -68,22 +71,10 @@ export interface PublicRelationship {
export interface ReadyEventData {
v: number;
- user: PublicUser & {
- mobile: boolean;
- 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
+ user: UserPrivate;
+ private_channels: ReadyPrivateChannel[]; // this will be empty for bots
session_id: string; // resuming
- guilds: IReadyGuildDTO[];
+ guilds: IReadyGuildDTO[] | GuildOrUnavailable[]; // depends on capability
analytics_token?: string;
connected_accounts?: ConnectedAccount[];
consents?: {
@@ -115,7 +106,7 @@ export interface ReadyEventData {
version: number;
};
user_guild_settings?: {
- entries: UserGuildSettings[];
+ entries: ReadyUserGuildSettingsEntries[];
version: number;
partial: boolean;
};
@@ -127,6 +118,17 @@ export interface ReadyEventData {
// probably all users who the user is in contact with
users?: PublicUser[];
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 {
@@ -581,6 +583,7 @@ export type EventData =
export enum EVENTEnum {
Ready = "READY",
+ ReadySupplemental = "READY_SUPPLEMENTAL",
ChannelCreate = "CHANNEL_CREATE",
ChannelUpdate = "CHANNEL_UPDATE",
ChannelDelete = "CHANNEL_DELETE",
diff --git a/src/util/schemas/MessageCreateSchema.ts b/src/util/schemas/MessageCreateSchema.ts
index 45cd735e..7e130751 100644
--- a/src/util/schemas/MessageCreateSchema.ts
+++ b/src/util/schemas/MessageCreateSchema.ts
@@ -29,7 +29,7 @@ export interface MessageCreateSchema {
nonce?: string;
channel_id?: string;
tts?: boolean;
- flags?: string;
+ flags?: number;
embeds?: Embed[];
embed?: Embed;
// TODO: ^ embed is deprecated in favor of embeds (https://discord.com/developers/docs/resources/channel#message-object)
diff --git a/src/util/schemas/RegisterSchema.ts b/src/util/schemas/RegisterSchema.ts
index f6c99b18..7b7de9c7 100644
--- a/src/util/schemas/RegisterSchema.ts
+++ b/src/util/schemas/RegisterSchema.ts
@@ -42,4 +42,8 @@ export interface RegisterSchema {
captcha_key?: string;
promotional_email_opt_in?: boolean;
+
+ // part of pomelo
+ unique_username_registration?: boolean;
+ global_name?: string;
}
diff --git a/src/util/schemas/UserProfileResponse.ts b/src/util/schemas/UserProfileResponse.ts
deleted file mode 100644
index 10bbcdbf..00000000
--- a/src/util/schemas/UserProfileResponse.ts
+++ /dev/null
@@ -1,26 +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/>.
-*/
-
-import { PublicConnectedAccount, PublicUser } from "..";
-
-export interface UserProfileResponse {
- user: PublicUser;
- connected_accounts: PublicConnectedAccount;
- premium_guild_since?: Date;
- premium_since?: Date;
-}
diff --git a/src/util/schemas/responses/TypedResponses.ts b/src/util/schemas/responses/TypedResponses.ts
index 099efba3..4349b93c 100644
--- a/src/util/schemas/responses/TypedResponses.ts
+++ b/src/util/schemas/responses/TypedResponses.ts
@@ -11,6 +11,7 @@ import {
Member,
Message,
PrivateUser,
+ PublicMember,
PublicUser,
Role,
Sticker,
@@ -68,6 +69,7 @@ export type APIChannelArray = Channel[];
export type APIEmojiArray = Emoji[];
export type APIMemberArray = Member[];
+export type APIPublicMember = PublicMember;
export interface APIGuildWithJoinedAt extends Guild {
joined_at: string;
diff --git a/src/util/schemas/responses/UserProfileResponse.ts b/src/util/schemas/responses/UserProfileResponse.ts
index bd1f46dd..eba7cbcc 100644
--- a/src/util/schemas/responses/UserProfileResponse.ts
+++ b/src/util/schemas/responses/UserProfileResponse.ts
@@ -1,8 +1,37 @@
-import { PublicConnectedAccount, PublicUser } from "../../entities";
+import {
+ Member,
+ PublicConnectedAccount,
+ PublicMember,
+ PublicUser,
+ User,
+} from "@spacebar/util";
+
+export type MutualGuild = {
+ id: string;
+ nick?: string;
+};
+
+export type PublicMemberProfile = Pick<
+ Member,
+ "banner" | "bio" | "guild_id"
+> & {
+ accent_color: null; // TODO
+};
+
+export type UserProfile = Pick<
+ User,
+ "bio" | "accent_color" | "banner" | "pronouns" | "theme_colors"
+>;
export interface UserProfileResponse {
user: PublicUser;
connected_accounts: PublicConnectedAccount;
premium_guild_since?: Date;
premium_since?: Date;
+ mutual_guilds: MutualGuild[];
+ premium_type: number;
+ profile_themes_experiment_bucket: number;
+ user_profile: UserProfile;
+ guild_member?: PublicMember;
+ guild_member_profile?: PublicMemberProfile;
}
diff --git a/src/util/util/JSON.ts b/src/util/util/JSON.ts
index 1c39b66e..c7dcf47e 100644
--- a/src/util/util/JSON.ts
+++ b/src/util/util/JSON.ts
@@ -27,6 +27,16 @@ const JSONReplacer = function (
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;
};
diff --git a/src/util/util/Token.ts b/src/util/util/Token.ts
index 90310176..eec72522 100644
--- a/src/util/util/Token.ts
+++ b/src/util/util/Token.ts
@@ -19,94 +19,66 @@
import jwt, { VerifyOptions } from "jsonwebtoken";
import { Config } from "./Config";
import { User } from "../entities";
+// TODO: dont use deprecated APIs lol
+import {
+ FindOptionsRelationByString,
+ FindOptionsSelectByString,
+} from "typeorm";
export const JWTOptions: VerifyOptions = { algorithms: ["HS256"] };
export type UserTokenData = {
user: User;
- decoded: { id: string; iat: number };
+ decoded: { id: string; iat: number; email?: string };
};
-async function checkEmailToken(
- decoded: jwt.JwtPayload,
-): Promise<UserTokenData> {
- // eslint-disable-next-line no-async-promise-executor
- return new Promise(async (res, rej) => {
- if (!decoded.iat) return rej("Invalid Token"); // will never happen, just for typings.
-
- const user = await User.findOne({
- where: {
- email: decoded.email,
- },
- select: [
- "email",
- "id",
- "verified",
- "deleted",
- "disabled",
- "username",
- "data",
- ],
- });
-
- if (!user) return rej("Invalid Token");
-
- 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(
+export const 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
- if (
- decoded.iat * 1000 <
- new Date(user.data.valid_tokens_since).setSeconds(0, 0)
- )
- return rej("Invalid Token");
-
- if (user.disabled) return rej("User disabled");
- if (user.deleted) return rej("User not found");
-
- // 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);
- });
+ opts?: {
+ select?: FindOptionsSelectByString<User>;
+ relations?: FindOptionsRelationByString;
+ },
+): 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({
+ where: decoded.email
+ ? { email: decoded.email }
+ : { id: decoded.id },
+ select: [
+ ...(opts?.select || []),
+ "bot",
+ "disabled",
+ "deleted",
+ "rights",
+ "data",
+ ],
+ relations: opts?.relations,
+ });
+
+ if (!user) return reject("User not found");
+
+ // we need to round it to seconds as it saved as seconds in jwt iat and valid_tokens_since is stored in milliseconds
+ if (
+ decoded.iat * 1000 <
+ new Date(user.data.valid_tokens_since).setSeconds(0, 0)
+ )
+ return reject("Invalid Token");
+
+ if (user.disabled) return reject("User disabled");
+ if (user.deleted) return reject("User not found");
+
+ return resolve({ decoded, user });
+ },
+ );
});
-}
export async function generateToken(id: string, email?: string) {
const iat = Math.floor(Date.now() / 1000);
|