From 66df10d6b02cb1bed437665bc293dbcd5b9c73ff Mon Sep 17 00:00:00 2001 From: TheArcaneBrony Date: Sat, 14 Jan 2023 13:08:48 +0100 Subject: Move endpoints to respective versions, split out non implemented routes Signed-off-by: TheArcaneBrony --- src/api/routes/v9/users/@me/channels.ts | 39 ++++ src/api/routes/v9/users/@me/delete.ts | 38 +++ src/api/routes/v9/users/@me/disable.ts | 32 +++ src/api/routes/v9/users/@me/email-settings.ts | 20 ++ src/api/routes/v9/users/@me/guilds.ts | 76 ++++++ .../v9/users/@me/guilds/#guild_id/settings.ts | 44 ++++ src/api/routes/v9/users/@me/index.ts | 156 +++++++++++++ .../routes/v9/users/@me/mfa/codes-verification.ts | 49 ++++ src/api/routes/v9/users/@me/mfa/codes.ts | 62 +++++ src/api/routes/v9/users/@me/mfa/totp/disable.ts | 56 +++++ src/api/routes/v9/users/@me/mfa/totp/enable.ts | 59 +++++ src/api/routes/v9/users/@me/notes.ts | 68 ++++++ src/api/routes/v9/users/@me/relationships.ts | 259 +++++++++++++++++++++ src/api/routes/v9/users/@me/settings.ts | 35 +++ 14 files changed, 993 insertions(+) create mode 100644 src/api/routes/v9/users/@me/channels.ts create mode 100644 src/api/routes/v9/users/@me/delete.ts create mode 100644 src/api/routes/v9/users/@me/disable.ts create mode 100644 src/api/routes/v9/users/@me/email-settings.ts create mode 100644 src/api/routes/v9/users/@me/guilds.ts create mode 100644 src/api/routes/v9/users/@me/guilds/#guild_id/settings.ts create mode 100644 src/api/routes/v9/users/@me/index.ts create mode 100644 src/api/routes/v9/users/@me/mfa/codes-verification.ts create mode 100644 src/api/routes/v9/users/@me/mfa/codes.ts create mode 100644 src/api/routes/v9/users/@me/mfa/totp/disable.ts create mode 100644 src/api/routes/v9/users/@me/mfa/totp/enable.ts create mode 100644 src/api/routes/v9/users/@me/notes.ts create mode 100644 src/api/routes/v9/users/@me/relationships.ts create mode 100644 src/api/routes/v9/users/@me/settings.ts (limited to 'src/api/routes/v9/users/@me') diff --git a/src/api/routes/v9/users/@me/channels.ts b/src/api/routes/v9/users/@me/channels.ts new file mode 100644 index 00000000..237be102 --- /dev/null +++ b/src/api/routes/v9/users/@me/channels.ts @@ -0,0 +1,39 @@ +import { Request, Response, Router } from "express"; +import { + Recipient, + DmChannelDTO, + Channel, + DmChannelCreateSchema, +} from "@fosscord/util"; +import { route } from "@fosscord/api"; + +const router: Router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + const recipients = await Recipient.find({ + where: { user_id: req.user_id, closed: false }, + relations: ["channel", "channel.recipients"], + }); + res.json( + await Promise.all( + recipients.map((r) => DmChannelDTO.from(r.channel, [req.user_id])), + ), + ); +}); + +router.post( + "/", + route({ body: "DmChannelCreateSchema" }), + async (req: Request, res: Response) => { + const body = req.body as DmChannelCreateSchema; + res.json( + await Channel.createDMChannel( + body.recipients, + req.user_id, + body.name, + ), + ); + }, +); + +export default router; diff --git a/src/api/routes/v9/users/@me/delete.ts b/src/api/routes/v9/users/@me/delete.ts new file mode 100644 index 00000000..a9f8167c --- /dev/null +++ b/src/api/routes/v9/users/@me/delete.ts @@ -0,0 +1,38 @@ +import { Router, Request, Response } from "express"; +import { Guild, Member, User } from "@fosscord/util"; +import { route } from "@fosscord/api"; +import bcrypt from "bcrypt"; +import { HTTPError } from "lambert-server"; + +const router = Router(); + +router.post("/", route({}), async (req: Request, res: Response) => { + const user = await User.findOneOrFail({ + where: { id: req.user_id }, + select: ["data"], + }); //User object + let correctpass = true; + + if (user.data.hash) { + // guest accounts can delete accounts without password + correctpass = await bcrypt.compare(req.body.password, user.data.hash); + if (!correctpass) { + throw new HTTPError(req.t("auth:login.INVALID_PASSWORD")); + } + } + + // TODO: decrement guild member count + + if (correctpass) { + await Promise.all([ + User.delete({ id: req.user_id }), + Member.delete({ id: req.user_id }), + ]); + + res.sendStatus(204); + } else { + res.sendStatus(401); + } +}); + +export default router; diff --git a/src/api/routes/v9/users/@me/disable.ts b/src/api/routes/v9/users/@me/disable.ts new file mode 100644 index 00000000..313a888f --- /dev/null +++ b/src/api/routes/v9/users/@me/disable.ts @@ -0,0 +1,32 @@ +import { User } from "@fosscord/util"; +import { Router, Response, Request } from "express"; +import { route } from "@fosscord/api"; +import bcrypt from "bcrypt"; + +const router = Router(); + +router.post("/", route({}), async (req: Request, res: Response) => { + const user = await User.findOneOrFail({ + where: { id: req.user_id }, + select: ["data"], + }); //User object + let correctpass = true; + + if (user.data.hash) { + // guest accounts can delete accounts without password + correctpass = await bcrypt.compare(req.body.password, user.data.hash); //Not sure if user typed right password :/ + } + + if (correctpass) { + await User.update({ id: req.user_id }, { disabled: true }); + + res.sendStatus(204); + } else { + res.status(400).json({ + message: "Password does not match", + code: 50018, + }); + } +}); + +export default router; diff --git a/src/api/routes/v9/users/@me/email-settings.ts b/src/api/routes/v9/users/@me/email-settings.ts new file mode 100644 index 00000000..a2834b89 --- /dev/null +++ b/src/api/routes/v9/users/@me/email-settings.ts @@ -0,0 +1,20 @@ +import { Router, Response, Request } from "express"; +import { route } from "@fosscord/api"; + +const router = Router(); + +router.get("/", route({}), (req: Request, res: Response) => { + // TODO: + res.json({ + categories: { + social: true, + communication: true, + tips: false, + updates_and_announcements: false, + recommendations_and_events: false, + }, + initialized: false, + }).status(200); +}); + +export default router; diff --git a/src/api/routes/v9/users/@me/guilds.ts b/src/api/routes/v9/users/@me/guilds.ts new file mode 100644 index 00000000..e12bf258 --- /dev/null +++ b/src/api/routes/v9/users/@me/guilds.ts @@ -0,0 +1,76 @@ +import { Router, Request, Response } from "express"; +import { + Guild, + Member, + User, + GuildDeleteEvent, + GuildMemberRemoveEvent, + emitEvent, + Config, +} from "@fosscord/util"; +import { HTTPError } from "lambert-server"; +import { route } from "@fosscord/api"; + +const router: Router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + const members = await Member.find({ + relations: ["guild"], + where: { id: req.user_id }, + }); + + let guild = members.map((x) => x.guild); + + if ("with_counts" in req.query && req.query.with_counts == "true") { + guild = []; // TODO: Load guilds with user role permissions number + } + + res.json(guild); +}); + +// user send to leave a certain guild +router.delete("/:guild_id", route({}), async (req: Request, res: Response) => { + const { autoJoin } = Config.get().guild; + const { guild_id } = req.params; + const guild = await Guild.findOneOrFail({ + where: { id: guild_id }, + select: ["owner_id"], + }); + + if (!guild) throw new HTTPError("Guild doesn't exist", 404); + if (guild.owner_id === req.user_id) + throw new HTTPError("You can't leave your own guild", 400); + if ( + autoJoin.enabled && + autoJoin.guilds.includes(guild_id) && + !autoJoin.canLeave + ) { + throw new HTTPError("You can't leave instance auto join guilds", 400); + } + + await Promise.all([ + Member.delete({ id: req.user_id, guild_id: guild_id }), + emitEvent({ + event: "GUILD_DELETE", + data: { + id: guild_id, + }, + user_id: req.user_id, + } as GuildDeleteEvent), + ]); + + const user = await User.getPublicUser(req.user_id); + + await emitEvent({ + event: "GUILD_MEMBER_REMOVE", + data: { + guild_id: guild_id, + user: user, + }, + guild_id: guild_id, + } as GuildMemberRemoveEvent); + + return res.sendStatus(204); +}); + +export default router; diff --git a/src/api/routes/v9/users/@me/guilds/#guild_id/settings.ts b/src/api/routes/v9/users/@me/guilds/#guild_id/settings.ts new file mode 100644 index 00000000..436261d4 --- /dev/null +++ b/src/api/routes/v9/users/@me/guilds/#guild_id/settings.ts @@ -0,0 +1,44 @@ +import { Router, Response, Request } from "express"; +import { + Channel, + Member, + OrmUtils, + UserGuildSettingsSchema, +} from "@fosscord/util"; +import { route } from "@fosscord/api"; + +const router = Router(); + +// GET doesn't exist on discord.com +router.get("/", route({}), async (req: Request, res: Response) => { + const user = await Member.findOneOrFail({ + where: { id: req.user_id, guild_id: req.params.guild_id }, + select: ["settings"], + }); + return res.json(user.settings); +}); + +router.patch( + "/", + route({ body: "UserGuildSettingsSchema" }), + async (req: Request, res: Response) => { + const body = req.body as UserGuildSettingsSchema; + + if (body.channel_overrides) { + for (var channel in body.channel_overrides) { + Channel.findOneOrFail({ where: { id: channel } }); + } + } + + const user = await Member.findOneOrFail({ + where: { id: req.user_id, guild_id: req.params.guild_id }, + select: ["settings"], + }); + OrmUtils.mergeDeep(user.settings || {}, body); + Member.update({ id: req.user_id, guild_id: req.params.guild_id }, user); + + res.json(user.settings); + }, +); + +export default router; diff --git a/src/api/routes/v9/users/@me/index.ts b/src/api/routes/v9/users/@me/index.ts new file mode 100644 index 00000000..37356d9d --- /dev/null +++ b/src/api/routes/v9/users/@me/index.ts @@ -0,0 +1,156 @@ +import { Router, Request, Response } from "express"; +import { + User, + PrivateUserProjection, + emitEvent, + UserUpdateEvent, + handleFile, + FieldErrors, + adjustEmail, + Config, + UserModifySchema, + generateToken, +} from "@fosscord/util"; +import { route } from "@fosscord/api"; +import bcrypt from "bcrypt"; + +const router: Router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + res.json( + await User.findOne({ + select: PrivateUserProjection, + where: { id: req.user_id }, + }), + ); +}); + +router.patch( + "/", + route({ body: "UserModifySchema" }), + async (req: Request, res: Response) => { + const body = req.body as UserModifySchema; + + const user = await User.findOneOrFail({ + where: { id: req.user_id }, + select: [...PrivateUserProjection, "data"], + }); + + // Populated on password change + var newToken: string | undefined; + + if (body.avatar) + body.avatar = await handleFile( + `/avatars/${req.user_id}`, + body.avatar as string, + ); + if (body.banner) + body.banner = await handleFile( + `/banners/${req.user_id}`, + body.banner as string, + ); + + if (body.password) { + if (user.data?.hash) { + const same_password = await bcrypt.compare( + body.password, + user.data.hash || "", + ); + if (!same_password) { + throw FieldErrors({ + password: { + message: req.t("auth:login.INVALID_PASSWORD"), + code: "INVALID_PASSWORD", + }, + }); + } + } else { + user.data.hash = await bcrypt.hash(body.password, 12); + } + } + + if (body.email) { + body.email = adjustEmail(body.email); + if (!body.email && Config.get().register.email.required) + throw FieldErrors({ + email: { + message: req.t("auth:register.EMAIL_INVALID"), + code: "EMAIL_INVALID", + }, + }); + if (!body.password) + throw FieldErrors({ + password: { + message: req.t("auth:register.INVALID_PASSWORD"), + code: "INVALID_PASSWORD", + }, + }); + } + + if (body.new_password) { + if (!body.password && !user.email) { + throw FieldErrors({ + password: { + code: "BASE_TYPE_REQUIRED", + message: req.t("common:field.BASE_TYPE_REQUIRED"), + }, + }); + } + user.data.hash = await bcrypt.hash(body.new_password, 12); + user.data.valid_tokens_since = new Date(); + newToken = (await generateToken(user.id)) as string; + } + + if (body.username) { + var check_username = body?.username?.replace(/\s/g, ""); + if (!check_username) { + throw FieldErrors({ + username: { + code: "BASE_TYPE_REQUIRED", + message: req.t("common:field.BASE_TYPE_REQUIRED"), + }, + }); + } + } + + if (body.discriminator) { + if ( + await User.findOne({ + where: { + discriminator: body.discriminator, + username: body.username || user.username, + }, + }) + ) { + throw FieldErrors({ + discriminator: { + code: "INVALID_DISCRIMINATOR", + message: "This discriminator is already in use.", + }, + }); + } + } + + user.assign(body); + user.validate(); + await user.save(); + + // @ts-ignore + delete user.data; + + // TODO: send update member list event in gateway + await emitEvent({ + event: "USER_UPDATE", + user_id: req.user_id, + data: user, + } as UserUpdateEvent); + + res.json({ + ...user, + newToken, + }); + }, +); + +export default router; +// {"message": "Invalid two-factor code", "code": 60008} diff --git a/src/api/routes/v9/users/@me/mfa/codes-verification.ts b/src/api/routes/v9/users/@me/mfa/codes-verification.ts new file mode 100644 index 00000000..3411605b --- /dev/null +++ b/src/api/routes/v9/users/@me/mfa/codes-verification.ts @@ -0,0 +1,49 @@ +import { Router, Request, Response } from "express"; +import { route } from "@fosscord/api"; +import { + BackupCode, + generateMfaBackupCodes, + User, + CodesVerificationSchema, +} from "@fosscord/util"; + +const router = Router(); + +router.post( + "/", + route({ body: "CodesVerificationSchema" }), + async (req: Request, res: Response) => { + const { key, nonce, regenerate } = req.body as CodesVerificationSchema; + + // TODO: We don't have email/etc etc, so can't send a verification code. + // Once that's done, this route can verify `key` + + const user = await User.findOneOrFail({ where: { id: req.user_id } }); + + var codes: BackupCode[]; + if (regenerate) { + await BackupCode.update( + { user: { id: req.user_id } }, + { expired: true }, + ); + + codes = generateMfaBackupCodes(req.user_id); + await Promise.all(codes.map((x) => x.save())); + } else { + codes = await BackupCode.find({ + where: { + user: { + id: req.user_id, + }, + expired: false, + }, + }); + } + + return res.json({ + backup_codes: codes.map((x) => ({ ...x, expired: undefined })), + }); + }, +); + +export default router; diff --git a/src/api/routes/v9/users/@me/mfa/codes.ts b/src/api/routes/v9/users/@me/mfa/codes.ts new file mode 100644 index 00000000..33053028 --- /dev/null +++ b/src/api/routes/v9/users/@me/mfa/codes.ts @@ -0,0 +1,62 @@ +import { Router, Request, Response } from "express"; +import { route } from "@fosscord/api"; +import { + BackupCode, + FieldErrors, + generateMfaBackupCodes, + User, + MfaCodesSchema, +} from "@fosscord/util"; +import bcrypt from "bcrypt"; + +const router = Router(); + +// TODO: This route is replaced with users/@me/mfa/codes-verification in newer clients + +router.post( + "/", + route({ body: "MfaCodesSchema" }), + async (req: Request, res: Response) => { + const { password, regenerate } = req.body as MfaCodesSchema; + + const user = await User.findOneOrFail({ + where: { id: req.user_id }, + select: ["data"], + }); + + if (!(await bcrypt.compare(password, user.data.hash || ""))) { + throw FieldErrors({ + password: { + message: req.t("auth:login.INVALID_PASSWORD"), + code: "INVALID_PASSWORD", + }, + }); + } + + var codes: BackupCode[]; + if (regenerate) { + await BackupCode.update( + { user: { id: req.user_id } }, + { expired: true }, + ); + + codes = generateMfaBackupCodes(req.user_id); + await Promise.all(codes.map((x) => x.save())); + } else { + codes = await BackupCode.find({ + where: { + user: { + id: req.user_id, + }, + expired: false, + }, + }); + } + + return res.json({ + backup_codes: codes.map((x) => ({ ...x, expired: undefined })), + }); + }, +); + +export default router; diff --git a/src/api/routes/v9/users/@me/mfa/totp/disable.ts b/src/api/routes/v9/users/@me/mfa/totp/disable.ts new file mode 100644 index 00000000..7916e598 --- /dev/null +++ b/src/api/routes/v9/users/@me/mfa/totp/disable.ts @@ -0,0 +1,56 @@ +import { Router, Request, Response } from "express"; +import { route } from "@fosscord/api"; +import { verifyToken } from "node-2fa"; +import { HTTPError } from "lambert-server"; +import { + User, + generateToken, + BackupCode, + TotpDisableSchema, +} from "@fosscord/util"; + +const router = Router(); + +router.post( + "/", + route({ body: "TotpDisableSchema" }), + async (req: Request, res: Response) => { + const body = req.body as TotpDisableSchema; + + const user = await User.findOneOrFail({ + where: { id: req.user_id }, + select: ["totp_secret"], + }); + + const backup = await BackupCode.findOne({ where: { code: body.code } }); + if (!backup) { + const ret = verifyToken(user.totp_secret!, body.code); + if (!ret || ret.delta != 0) + throw new HTTPError( + req.t("auth:login.INVALID_TOTP_CODE"), + 60008, + ); + } + + await User.update( + { id: req.user_id }, + { + mfa_enabled: false, + totp_secret: "", + }, + ); + + await BackupCode.update( + { user: { id: req.user_id } }, + { + expired: true, + }, + ); + + return res.json({ + token: await generateToken(user.id), + }); + }, +); + +export default router; diff --git a/src/api/routes/v9/users/@me/mfa/totp/enable.ts b/src/api/routes/v9/users/@me/mfa/totp/enable.ts new file mode 100644 index 00000000..2c7044da --- /dev/null +++ b/src/api/routes/v9/users/@me/mfa/totp/enable.ts @@ -0,0 +1,59 @@ +import { Router, Request, Response } from "express"; +import { + User, + generateToken, + generateMfaBackupCodes, + TotpEnableSchema, +} from "@fosscord/util"; +import { route } from "@fosscord/api"; +import bcrypt from "bcrypt"; +import { HTTPError } from "lambert-server"; +import { verifyToken } from "node-2fa"; + +const router = Router(); + +router.post( + "/", + route({ body: "TotpEnableSchema" }), + async (req: Request, res: Response) => { + const body = req.body as TotpEnableSchema; + + const user = await User.findOneOrFail({ + where: { id: req.user_id }, + select: ["data", "email"], + }); + + // TODO: Are guests allowed to enable 2fa? + if (user.data.hash) { + if (!(await bcrypt.compare(body.password, user.data.hash))) { + throw new HTTPError(req.t("auth:login.INVALID_PASSWORD")); + } + } + + if (!body.secret) + throw new HTTPError(req.t("auth:login.INVALID_TOTP_SECRET"), 60005); + + if (!body.code) + throw new HTTPError(req.t("auth:login.INVALID_TOTP_CODE"), 60008); + + if (verifyToken(body.secret, body.code)?.delta != 0) + throw new HTTPError(req.t("auth:login.INVALID_TOTP_CODE"), 60008); + + let backup_codes = generateMfaBackupCodes(req.user_id); + await Promise.all(backup_codes.map((x) => x.save())); + await User.update( + { id: req.user_id }, + { mfa_enabled: true, totp_secret: body.secret }, + ); + + res.send({ + token: await generateToken(user.id), + backup_codes: backup_codes.map((x) => ({ + ...x, + expired: undefined, + })), + }); + }, +); + +export default router; diff --git a/src/api/routes/v9/users/@me/notes.ts b/src/api/routes/v9/users/@me/notes.ts new file mode 100644 index 00000000..e54eb897 --- /dev/null +++ b/src/api/routes/v9/users/@me/notes.ts @@ -0,0 +1,68 @@ +import { Request, Response, Router } from "express"; +import { route } from "@fosscord/api"; +import { User, Note, emitEvent, Snowflake } from "@fosscord/util"; + +const router: Router = Router(); + +router.get("/:id", route({}), async (req: Request, res: Response) => { + const { id } = req.params; + + const note = await Note.findOneOrFail({ + where: { + owner: { id: req.user_id }, + target: { id: id }, + }, + }); + + return res.json({ + note: note?.content, + note_user_id: id, + user_id: req.user_id, + }); +}); + +router.put("/:id", route({}), async (req: Request, res: Response) => { + const { id } = req.params; + const owner = await User.findOneOrFail({ where: { id: req.user_id } }); + const target = await User.findOneOrFail({ where: { id: id } }); //if noted user does not exist throw + const { note } = req.body; + + if (note && note.length) { + // upsert a note + if ( + await Note.findOne({ + where: { owner: { id: owner.id }, target: { id: target.id } }, + }) + ) { + Note.update( + { owner: { id: owner.id }, target: { id: target.id } }, + { owner, target, content: note }, + ); + } else { + Note.insert({ + id: Snowflake.generate(), + owner, + target, + content: note, + }); + } + } else { + await Note.delete({ + owner: { id: owner.id }, + target: { id: target.id }, + }); + } + + await emitEvent({ + event: "USER_NOTE_UPDATE", + data: { + note: note, + id: target.id, + }, + user_id: owner.id, + }); + + return res.status(204); +}); + +export default router; diff --git a/src/api/routes/v9/users/@me/relationships.ts b/src/api/routes/v9/users/@me/relationships.ts new file mode 100644 index 00000000..3eec704b --- /dev/null +++ b/src/api/routes/v9/users/@me/relationships.ts @@ -0,0 +1,259 @@ +import { + RelationshipAddEvent, + User, + PublicUserProjection, + RelationshipType, + RelationshipRemoveEvent, + emitEvent, + Relationship, + Config, +} from "@fosscord/util"; +import { Router, Response, Request } from "express"; +import { HTTPError } from "lambert-server"; +import { DiscordApiErrors } from "@fosscord/util"; +import { route } from "@fosscord/api"; + +const router = Router(); + +const userProjection: (keyof User)[] = [ + "relationships", + ...PublicUserProjection, +]; + +router.get("/", route({}), async (req: Request, res: Response) => { + const user = await User.findOneOrFail({ + where: { id: req.user_id }, + relations: ["relationships", "relationships.to"], + select: ["id", "relationships"], + }); + + //TODO DTO + const related_users = user.relationships.map((r) => { + return { + id: r.to.id, + type: r.type, + nickname: null, + user: r.to.toPublicUser(), + }; + }); + + return res.json(related_users); +}); + +router.put( + "/:id", + route({ body: "RelationshipPutSchema" }), + async (req: Request, res: Response) => { + return await updateRelationship( + req, + res, + await User.findOneOrFail({ + where: { id: req.params.id }, + relations: ["relationships", "relationships.to"], + select: userProjection, + }), + req.body.type ?? RelationshipType.friends, + ); + }, +); + +router.post( + "/", + route({ body: "RelationshipPostSchema" }), + async (req: Request, res: Response) => { + return await updateRelationship( + req, + res, + await User.findOneOrFail({ + relations: ["relationships", "relationships.to"], + select: userProjection, + where: { + discriminator: String(req.body.discriminator).padStart( + 4, + "0", + ), //Discord send the discriminator as integer, we need to add leading zeroes + username: req.body.username, + }, + }), + req.body.type, + ); + }, +); + +router.delete("/:id", route({}), async (req: Request, res: Response) => { + const { id } = req.params; + if (id === req.user_id) + throw new HTTPError("You can't remove yourself as a friend"); + + const user = await User.findOneOrFail({ + where: { id: req.user_id }, + select: userProjection, + relations: ["relationships"], + }); + const friend = await User.findOneOrFail({ + where: { id: id }, + select: userProjection, + relations: ["relationships"], + }); + + const relationship = user.relationships.find((x) => x.to_id === id); + const friendRequest = friend.relationships.find( + (x) => x.to_id === req.user_id, + ); + + if (!relationship) + throw new HTTPError("You are not friends with the user", 404); + if (relationship?.type === RelationshipType.blocked) { + // unblock user + + await Promise.all([ + Relationship.delete({ id: relationship.id }), + emitEvent({ + event: "RELATIONSHIP_REMOVE", + user_id: req.user_id, + data: relationship.toPublicRelationship(), + } as RelationshipRemoveEvent), + ]); + return res.sendStatus(204); + } + if (friendRequest && friendRequest.type !== RelationshipType.blocked) { + await Promise.all([ + Relationship.delete({ id: friendRequest.id }), + await emitEvent({ + event: "RELATIONSHIP_REMOVE", + data: friendRequest.toPublicRelationship(), + user_id: id, + } as RelationshipRemoveEvent), + ]); + } + + await Promise.all([ + Relationship.delete({ id: relationship.id }), + emitEvent({ + event: "RELATIONSHIP_REMOVE", + data: relationship.toPublicRelationship(), + user_id: req.user_id, + } as RelationshipRemoveEvent), + ]); + + return res.sendStatus(204); +}); + +export default router; + +async function updateRelationship( + req: Request, + res: Response, + friend: User, + type: RelationshipType, +) { + const id = friend.id; + if (id === req.user_id) + throw new HTTPError("You can't add yourself as a friend"); + + const user = await User.findOneOrFail({ + where: { id: req.user_id }, + relations: ["relationships", "relationships.to"], + select: userProjection, + }); + + var relationship = user.relationships.find((x) => x.to_id === id); + const friendRequest = friend.relationships.find( + (x) => x.to_id === req.user_id, + ); + + // TODO: you can add infinitely many blocked users (should this be prevented?) + if (type === RelationshipType.blocked) { + if (relationship) { + if (relationship.type === RelationshipType.blocked) + throw new HTTPError("You already blocked the user"); + relationship.type = RelationshipType.blocked; + await relationship.save(); + } else { + relationship = await Relationship.create({ + to_id: id, + type: RelationshipType.blocked, + from_id: req.user_id, + }).save(); + } + + if (friendRequest && friendRequest.type !== RelationshipType.blocked) { + await Promise.all([ + Relationship.delete({ id: friendRequest.id }), + emitEvent({ + event: "RELATIONSHIP_REMOVE", + data: friendRequest.toPublicRelationship(), + user_id: id, + } as RelationshipRemoveEvent), + ]); + } + + await emitEvent({ + event: "RELATIONSHIP_ADD", + data: relationship.toPublicRelationship(), + user_id: req.user_id, + } as RelationshipAddEvent); + + return res.sendStatus(204); + } + + const { maxFriends } = Config.get().limits.user; + if (user.relationships.length >= maxFriends) + throw DiscordApiErrors.MAXIMUM_FRIENDS.withParams(maxFriends); + + var incoming_relationship = Relationship.create({ + nickname: undefined, + type: RelationshipType.incoming, + to: user, + from: friend, + }); + var outgoing_relationship = Relationship.create({ + nickname: undefined, + type: RelationshipType.outgoing, + to: friend, + from: user, + }); + + if (friendRequest) { + if (friendRequest.type === RelationshipType.blocked) + throw new HTTPError("The user blocked you"); + if (friendRequest.type === RelationshipType.friends) + throw new HTTPError("You are already friends with the user"); + // accept friend request + incoming_relationship = friendRequest; + incoming_relationship.type = RelationshipType.friends; + } + + if (relationship) { + if (relationship.type === RelationshipType.outgoing) + throw new HTTPError("You already sent a friend request"); + if (relationship.type === RelationshipType.blocked) + throw new HTTPError( + "Unblock the user before sending a friend request", + ); + if (relationship.type === RelationshipType.friends) + throw new HTTPError("You are already friends with the user"); + outgoing_relationship = relationship; + outgoing_relationship.type = RelationshipType.friends; + } + + await Promise.all([ + incoming_relationship.save(), + outgoing_relationship.save(), + emitEvent({ + event: "RELATIONSHIP_ADD", + data: outgoing_relationship.toPublicRelationship(), + user_id: req.user_id, + } as RelationshipAddEvent), + emitEvent({ + event: "RELATIONSHIP_ADD", + data: { + ...incoming_relationship.toPublicRelationship(), + should_notify: true, + }, + user_id: id, + } as RelationshipAddEvent), + ]); + + return res.sendStatus(204); +} diff --git a/src/api/routes/v9/users/@me/settings.ts b/src/api/routes/v9/users/@me/settings.ts new file mode 100644 index 00000000..cce366ac --- /dev/null +++ b/src/api/routes/v9/users/@me/settings.ts @@ -0,0 +1,35 @@ +import { Router, Response, Request } from "express"; +import { OrmUtils, User, UserSettingsSchema } from "@fosscord/util"; +import { route } from "@fosscord/api"; + +const router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + const user = await User.findOneOrFail({ + where: { id: req.user_id }, + relations: ["settings"], + }); + return res.json(user.settings); +}); + +router.patch( + "/", + route({ body: "UserSettingsSchema" }), + async (req: Request, res: Response) => { + const body = req.body as UserSettingsSchema; + if (body.locale === "en") body.locale = "en-US"; // fix discord client crash on unkown locale + + const user = await User.findOneOrFail({ + where: { id: req.user_id, bot: false }, + relations: ["settings"], + }); + + user.settings.assign(body); + + user.settings.save(); + + res.json(user.settings); + }, +); + +export default router; -- cgit 1.5.1