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;
|