diff --git a/api/src/middlewares/Authentication.ts b/api/src/middlewares/Authentication.ts
index 5a08caf3..1df7911b 100644
--- a/api/src/middlewares/Authentication.ts
+++ b/api/src/middlewares/Authentication.ts
@@ -7,6 +7,7 @@ export const NO_AUTHORIZATION_ROUTES = [
"/auth/login",
"/auth/register",
"/auth/location-metadata",
+ "/auth/mfa/totp",
// Routes with a seperate auth system
"/webhooks/",
// Public information endpoints
diff --git a/api/src/middlewares/RateLimit.ts b/api/src/middlewares/RateLimit.ts
index 13f1602c..1a38cfcf 100644
--- a/api/src/middlewares/RateLimit.ts
+++ b/api/src/middlewares/RateLimit.ts
@@ -1,4 +1,4 @@
-import { Config, getRights, listenEvent, Rights } from "@fosscord/util";
+import { Config, listenEvent } from "@fosscord/util";
import { NextFunction, Request, Response, Router } from "express";
import { getIpAdress } from "@fosscord/api";
import { API_PREFIX_TRAILING_SLASH } from "./Authentication";
@@ -9,7 +9,6 @@ import { API_PREFIX_TRAILING_SLASH } from "./Authentication";
/*
? bucket limit? Max actions/sec per bucket?
-(ANSWER: a small fosscord instance might not need a complex rate limiting system)
TODO: delay database requests to include multiple queries
TODO: different for methods (GET/POST)
@@ -45,12 +44,6 @@ export default function rateLimit(opts: {
onlyIp?: boolean;
}): any {
return async (req: Request, res: Response, next: NextFunction): Promise<any> => {
- // exempt user? if so, immediately short circuit
- if (req.user_id) {
- const rights = await getRights(req.user_id);
- if (rights.has("BYPASS_RATE_LIMITS")) return;
- }
-
const bucket_id = opts.bucket || req.originalUrl.replace(API_PREFIX_TRAILING_SLASH, "");
var executor_id = getIpAdress(req);
if (!opts.onlyIp && req.user_id) executor_id = req.user_id;
@@ -60,12 +53,12 @@ export default function rateLimit(opts: {
if (opts.GET && ["GET", "OPTIONS", "HEAD"].includes(req.method)) max_hits = opts.GET;
else if (opts.MODIFY && ["POST", "DELETE", "PATCH", "PUT"].includes(req.method)) max_hits = opts.MODIFY;
- let offender = Cache.get(executor_id + bucket_id);
+ const offender = Cache.get(executor_id + bucket_id);
if (offender) {
- let reset = offender.expires_at.getTime();
- let resetAfterMs = reset - Date.now();
- let resetAfterSec = Math.ceil(resetAfterMs / 1000);
+ const reset = offender.expires_at.getTime();
+ const resetAfterMs = reset - Date.now();
+ const resetAfterSec = resetAfterMs / 1000;
if (resetAfterMs <= 0) {
offender.hits = 0;
@@ -77,11 +70,6 @@ export default function rateLimit(opts: {
if (offender.blocked) {
const global = bucket_id === "global";
- // each block violation pushes the expiry one full window further
- reset += opts.window * 1000;
- offender.expires_at = new Date(offender.expires_at.getTime() + opts.window * 1000);
- resetAfterMs = reset - Date.now();
- resetAfterSec = Math.ceil(resetAfterMs / 1000);
console.log("blocked bucket: " + bucket_id, { resetAfterMs });
return (
@@ -163,7 +151,7 @@ export async function initRateLimits(app: Router) {
app.use("/auth/register", rateLimit({ onlyIp: true, success: true, ...routes.auth.register }));
}
-async function hitRoute(opts: { executor_id: string; bucket_id: string; max_hits: number; window: number; }) {
+async function hitRoute(opts: { executor_id: string; bucket_id: string; max_hits: number; window: number }) {
const id = opts.executor_id + opts.bucket_id;
var limit = Cache.get(id);
if (!limit) {
diff --git a/api/src/routes/auth/login.ts b/api/src/routes/auth/login.ts
index a89721ea..bcaccb30 100644
--- a/api/src/routes/auth/login.ts
+++ b/api/src/routes/auth/login.ts
@@ -1,7 +1,8 @@
import { Request, Response, Router } from "express";
-import { route } from "@fosscord/api";
+import { route, getIpAdress, verifyCaptcha } from "@fosscord/api";
import bcrypt from "bcrypt";
import { Config, User, generateToken, adjustEmail, FieldErrors } from "@fosscord/util";
+import crypto from "crypto";
const router: Router = Router();
export default router;
@@ -23,8 +24,8 @@ router.post("/", route({ body: "LoginSchema" }), async (req: Request, res: Respo
const config = Config.get();
if (config.login.requireCaptcha && config.security.captcha.enabled) {
+ const { sitekey, service } = config.security.captcha;
if (!captcha_key) {
- const { sitekey, service } = config.security.captcha;
return res.status(400).json({
captcha_key: ["captcha-required"],
captcha_sitekey: sitekey,
@@ -32,12 +33,20 @@ router.post("/", route({ body: "LoginSchema" }), async (req: Request, res: Respo
});
}
- // TODO: check captcha
+ const ip = getIpAdress(req);
+ const verify = await verifyCaptcha(captcha_key, ip);
+ if (!verify.success) {
+ return res.status(400).json({
+ captcha_key: verify["error-codes"],
+ captcha_sitekey: sitekey,
+ captcha_service: service
+ });
+ }
}
const user = await User.findOneOrFail({
where: [{ phone: login }, { email: login }],
- select: ["data", "id", "disabled", "deleted", "settings"]
+ select: ["data", "id", "disabled", "deleted", "settings", "totp_secret", "mfa_enabled"]
}).catch((e) => {
throw FieldErrors({ login: { message: req.t("auth:login.INVALID_LOGIN"), code: "INVALID_LOGIN" } });
});
@@ -57,6 +66,20 @@ router.post("/", route({ body: "LoginSchema" }), async (req: Request, res: Respo
throw FieldErrors({ password: { message: req.t("auth:login.INVALID_PASSWORD"), code: "INVALID_PASSWORD" } });
}
+ if (user.mfa_enabled) {
+ // TODO: This is not a discord.com ticket. I'm not sure what it is but I'm lazy
+ const ticket = crypto.randomBytes(40).toString("hex");
+
+ await User.update({ id: user.id }, { totp_last_ticket: ticket });
+
+ return res.json({
+ ticket: ticket,
+ mfa: true,
+ sms: false, // TODO
+ token: null,
+ })
+ }
+
const token = await generateToken(user.id);
// Notice this will have a different token structure, than discord
diff --git a/api/src/routes/auth/mfa/totp.ts b/api/src/routes/auth/mfa/totp.ts
new file mode 100644
index 00000000..cec6e5ee
--- /dev/null
+++ b/api/src/routes/auth/mfa/totp.ts
@@ -0,0 +1,49 @@
+import { Router, Request, Response } from "express";
+import { route } from "@fosscord/api";
+import { BackupCode, FieldErrors, generateToken, User } from "@fosscord/util";
+import { verifyToken } from "node-2fa";
+import { HTTPError } from "lambert-server";
+const router = Router();
+
+export interface TotpSchema {
+ code: string,
+ ticket: string,
+ gift_code_sku_id?: string | null,
+ login_source?: string | null,
+}
+
+router.post("/", route({ body: "TotpSchema" }), async (req: Request, res: Response) => {
+ const { code, ticket, gift_code_sku_id, login_source } = req.body as TotpSchema;
+
+ const user = await User.findOneOrFail({
+ where: {
+ totp_last_ticket: ticket,
+ },
+ select: [
+ "id",
+ "totp_secret",
+ "settings",
+ ],
+ });
+
+ const backup = await BackupCode.findOne({ code: code, expired: false, consumed: false, user: { id: user.id }});
+
+ if (!backup) {
+ const ret = verifyToken(user.totp_secret!, code);
+ if (!ret || ret.delta != 0)
+ throw new HTTPError(req.t("auth:login.INVALID_TOTP_CODE"), 60008);
+ }
+ else {
+ backup.consumed = true;
+ await backup.save();
+ }
+
+ await User.update({ id: user.id }, { totp_last_ticket: "" });
+
+ return res.json({
+ token: await generateToken(user.id),
+ user_settings: user.settings,
+ });
+});
+
+export default router;
diff --git a/api/src/routes/auth/register.ts b/api/src/routes/auth/register.ts
index 94dd6502..f74d0d63 100644
--- a/api/src/routes/auth/register.ts
+++ b/api/src/routes/auth/register.ts
@@ -1,6 +1,6 @@
import { Request, Response, Router } from "express";
-import { Config, generateToken, Invite, FieldErrors, User, adjustEmail, trimSpecial } from "@fosscord/util";
-import { route, getIpAdress, IPAnalysis, isProxy } from "@fosscord/api";
+import { Config, generateToken, Invite, FieldErrors, User, adjustEmail } from "@fosscord/util";
+import { route, getIpAdress, IPAnalysis, isProxy, verifyCaptcha } from "@fosscord/api";
import "missing-native-js-functions";
import bcrypt from "bcrypt";
import { HTTPError } from "lambert-server";
@@ -31,6 +31,8 @@ export interface RegisterSchema {
date_of_birth?: Date; // "2000-04-03"
gift_code_sku_id?: string;
captcha_key?: string;
+
+ promotional_email_opt_in?: boolean;
}
router.post("/", route({ body: "RegisterSchema" }), async (req: Request, res: Response) => {
@@ -65,8 +67,8 @@ router.post("/", route({ body: "RegisterSchema" }), async (req: Request, res: Re
}
if (register.requireCaptcha && security.captcha.enabled) {
+ const { sitekey, service } = security.captcha;
if (!body.captcha_key) {
- const { sitekey, service } = security.captcha;
return res?.status(400).json({
captcha_key: ["captcha-required"],
captcha_sitekey: sitekey,
@@ -74,7 +76,14 @@ router.post("/", route({ body: "RegisterSchema" }), async (req: Request, res: Re
});
}
- // TODO: check captcha
+ const verify = await verifyCaptcha(body.captcha_key, ip);
+ if (!verify.success) {
+ return res.status(400).json({
+ captcha_key: verify["error-codes"],
+ captcha_sitekey: sitekey,
+ captcha_service: service
+ });
+ }
}
if (!register.allowMultipleAccounts) {
diff --git a/api/src/routes/auth/verify/view-backup-codes-challenge.ts b/api/src/routes/auth/verify/view-backup-codes-challenge.ts
new file mode 100644
index 00000000..be651686
--- /dev/null
+++ b/api/src/routes/auth/verify/view-backup-codes-challenge.ts
@@ -0,0 +1,26 @@
+import { Router, Request, Response } from "express";
+import { route } from "@fosscord/api";
+import { FieldErrors, User } from "@fosscord/util";
+import bcrypt from "bcrypt";
+const router = Router();
+
+export interface BackupCodesChallengeSchema {
+ password: string;
+}
+
+router.post("/", route({ body: "BackupCodesChallengeSchema" }), async (req: Request, res: Response) => {
+ const { password } = req.body as BackupCodesChallengeSchema;
+
+ const user = await User.findOneOrFail({ 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" } });
+ }
+
+ return res.json({
+ nonce: "NoncePlaceholder",
+ regenerate_nonce: "RegenNoncePlaceholder",
+ })
+});
+
+export default router;
diff --git a/api/src/routes/channels/#channel_id/messages/#message_id/ack.ts b/api/src/routes/channels/#channel_id/messages/#message_id/ack.ts
index 885c5eca..1e3564d8 100644
--- a/api/src/routes/channels/#channel_id/messages/#message_id/ack.ts
+++ b/api/src/routes/channels/#channel_id/messages/#message_id/ack.ts
@@ -35,7 +35,7 @@ router.post("/", route({ body: "MessageAcknowledgeSchema" }), async (req: Reques
}
} as MessageAckEvent);
- res.sendStatus(204);
+ res.json({ token: null });
});
export default router;
diff --git a/api/src/routes/channels/#channel_id/messages/index.ts b/api/src/routes/channels/#channel_id/messages/index.ts
index 54e6edcc..00e38239 100644
--- a/api/src/routes/channels/#channel_id/messages/index.ts
+++ b/api/src/routes/channels/#channel_id/messages/index.ts
@@ -102,12 +102,11 @@ router.get("/", async (req: Request, res: Response) => {
if (!permissions.has("READ_MESSAGE_HISTORY")) return res.json([]);
var query: FindManyOptions<Message> & { where: { id?: any; }; } = {
- order: { id: "DESC" },
+ order: { timestamp: "DESC" },
take: limit,
where: { channel_id },
relations: ["author", "webhook", "application", "mentions", "mention_roles", "mention_channels", "sticker_items", "attachments"]
};
-
if (after) {
if (after > new Snowflake()) return res.status(422);
@@ -179,7 +178,7 @@ const messageUpload = multer({
router.post(
"/",
messageUpload.any(),
- async (req, res, next) => {
+ (req, res, next) => {
if (req.body.payload_json) {
req.body = JSON.parse(req.body.payload_json);
}
@@ -228,7 +227,7 @@ router.post(
const channel_dto = await DmChannelDTO.from(channel);
// Only one recipients should be closed here, since in group DMs the recipient is deleted not closed
- Promise.all(
+ await Promise.all(
channel.recipients!.map((recipient) => {
if (recipient.closed) {
recipient.closed = false;
diff --git a/api/src/routes/guilds/#guild_id/index.ts b/api/src/routes/guilds/#guild_id/index.ts
index 4ec3df72..45e30a74 100644
--- a/api/src/routes/guilds/#guild_id/index.ts
+++ b/api/src/routes/guilds/#guild_id/index.ts
@@ -20,6 +20,7 @@ export interface GuildUpdateSchema extends Omit<GuildCreateSchema, "channels"> {
afk_timeout?: number;
afk_channel_id?: string;
preferred_locale?: string;
+ premium_progress_bar_enabled?: boolean;
}
router.get("/", route({}), async (req: Request, res: Response) => {
diff --git a/api/src/routes/guilds/#guild_id/member-verification.ts b/api/src/routes/guilds/#guild_id/member-verification.ts
new file mode 100644
index 00000000..265a1b35
--- /dev/null
+++ b/api/src/routes/guilds/#guild_id/member-verification.ts
@@ -0,0 +1,14 @@
+import { Router, Request, Response } from "express";
+import { route } from "@fosscord/api";
+const router = Router();
+
+router.get("/",route({}), async (req: Request, res: Response) => {
+ // TODO: member verification
+
+ res.status(404).json({
+ message: "Unknown Guild Member Verification Form",
+ code: 10068
+ });
+});
+
+export default router;
diff --git a/api/src/routes/guilds/#guild_id/members/#member_id/index.ts b/api/src/routes/guilds/#guild_id/members/#member_id/index.ts
index c285abb3..2ff89eae 100644
--- a/api/src/routes/guilds/#guild_id/members/#member_id/index.ts
+++ b/api/src/routes/guilds/#guild_id/members/#member_id/index.ts
@@ -7,6 +7,7 @@ const router = Router();
export interface MemberChangeSchema {
roles?: string[];
+ nick?: string;
}
router.get("/", route({}), async (req: Request, res: Response) => {
@@ -34,6 +35,8 @@ router.patch("/", route({ body: "MemberChangeSchema" }), async (req: Request, re
member.roles = body.roles.map((x) => new Role({ id: x })); // foreign key constraint will fail if role doesn't exist
}
+ if (body.nick) member.nick = body.nick;
+
await member.save();
member.roles = member.roles.filter((x) => x.id !== everyone.id);
diff --git a/api/src/routes/guilds/#guild_id/roles/#role_id/index.ts b/api/src/routes/guilds/#guild_id/roles/#role_id/index.ts
index 2ad01682..16b5a59f 100644
--- a/api/src/routes/guilds/#guild_id/roles/#role_id/index.ts
+++ b/api/src/routes/guilds/#guild_id/roles/#role_id/index.ts
@@ -41,7 +41,8 @@ router.patch("/", route({ body: "RoleModifySchema", permission: "MANAGE_ROLES" }
const { role_id, guild_id } = req.params;
const body = req.body as RoleModifySchema;
- if (body.icon) body.icon = await handleFile(`/role-icons/${role_id}`, body.icon as string);
+ if (body.icon && body.icon.length) body.icon = await handleFile(`/role-icons/${role_id}`, body.icon as string);
+ else body.icon = undefined;
const role = new Role({
...body,
diff --git a/api/src/routes/guilds/#guild_id/welcome_screen.ts b/api/src/routes/guilds/#guild_id/welcome-screen.ts
index 7141f17e..5c7a9daa 100644
--- a/api/src/routes/guilds/#guild_id/welcome_screen.ts
+++ b/api/src/routes/guilds/#guild_id/welcome-screen.ts
@@ -10,7 +10,7 @@ export interface GuildUpdateWelcomeScreenSchema {
channel_id: string;
description: string;
emoji_id?: string;
- emoji_name: string;
+ emoji_name?: string;
}[];
enabled?: boolean;
description?: string;
@@ -36,6 +36,8 @@ router.patch("/", route({ body: "GuildUpdateWelcomeScreenSchema", permission: "M
if (body.description) guild.welcome_screen.description = body.description;
if (body.enabled != null) guild.welcome_screen.enabled = body.enabled;
+ await guild.save();
+
res.sendStatus(204);
});
diff --git a/api/src/routes/guilds/index.ts b/api/src/routes/guilds/index.ts
index 10721413..489dea49 100644
--- a/api/src/routes/guilds/index.ts
+++ b/api/src/routes/guilds/index.ts
@@ -9,7 +9,7 @@ export interface GuildCreateSchema {
/**
* @maxLength 100
*/
- name: string;
+ name?: string;
region?: string;
icon?: string | null;
channels?: ChannelModifySchema[];
diff --git a/api/src/routes/store/published-listings/skus/#sku_id/subscription-plans.ts b/api/src/routes/store/published-listings/skus/#sku_id/subscription-plans.ts
index 723a5160..03162ec8 100644
--- a/api/src/routes/store/published-listings/skus/#sku_id/subscription-plans.ts
+++ b/api/src/routes/store/published-listings/skus/#sku_id/subscription-plans.ts
@@ -5,6 +5,22 @@ const router: Router = Router();
const skus = new Map([
[
+ "978380684370378762",
+ [
+ {
+ id: "978380692553465866",
+ name: "Nitro Lite Monthly",
+ interval: 1,
+ interval_count: 1,
+ tag_inclusive: true,
+ sku_id: "978380684370378762",
+ currency: "usd",
+ price: 0,
+ price_tier: null,
+ }
+ ]
+ ],
+ [
"521842865731534868",
[
{
diff --git a/api/src/routes/users/#id/profile.ts b/api/src/routes/users/#id/profile.ts
index 4dbb84cf..a77fbdb5 100644
--- a/api/src/routes/users/#id/profile.ts
+++ b/api/src/routes/users/#id/profile.ts
@@ -1,5 +1,5 @@
import { Router, Request, Response } from "express";
-import { PublicConnectedAccount, PublicUser, User, UserPublic, Member } from "@fosscord/util";
+import { PublicConnectedAccount, PublicUser, User, UserPublic, Member, Guild } from "@fosscord/util";
import { route } from "@fosscord/api";
const router: Router = Router();
@@ -13,45 +13,78 @@ export interface UserProfileResponse {
router.get("/", route({ test: { response: { body: "UserProfileResponse" } } }), async (req: Request, res: Response) => {
if (req.params.id === "@me") req.params.id = req.user_id;
+
+ const { guild_id, with_mutual_guilds } = req.query;
+
const user = await User.getPublicUser(req.params.id, { relations: ["connected_accounts"] });
var mutual_guilds: object[] = [];
var premium_guild_since;
- const requested_member = await Member.find( { id: req.params.id, })
- const self_member = await Member.find( { id: req.user_id, })
- for(const rmem of requested_member) {
- if(rmem.premium_since) {
- if(premium_guild_since){
- if(premium_guild_since > rmem.premium_since) {
+ if (with_mutual_guilds == "true") {
+ const requested_member = await Member.find({ id: req.params.id, });
+ const self_member = await Member.find({ id: req.user_id, });
+
+ for (const rmem of requested_member) {
+ if (rmem.premium_since) {
+ if (premium_guild_since) {
+ if (premium_guild_since > rmem.premium_since) {
+ premium_guild_since = rmem.premium_since;
+ }
+ } else {
premium_guild_since = rmem.premium_since;
}
- } else {
- premium_guild_since = rmem.premium_since;
}
- }
- for(const smem of self_member) {
- if (smem.guild_id === rmem.guild_id) {
- mutual_guilds.push({id: rmem.guild_id, nick: rmem.nick})
+ for (const smem of self_member) {
+ if (smem.guild_id === rmem.guild_id) {
+ mutual_guilds.push({ id: rmem.guild_id, nick: rmem.nick });
+ }
}
}
}
+
+ const guild_member = guild_id && typeof guild_id == "string"
+ ? await Member.findOneOrFail({ id: req.params.id, guild_id: guild_id }, { relations: ["roles"] })
+ : undefined;
+
+ // 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 guildMemberDto = guild_member ? {
+ avatar: user.avatar, // TODO
+ banner: user.banner, // TODO
+ bio: req.user_bot ? null : user.bio, // TODO
+ communication_disabled_until: null, // TODO
+ 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;
+
res.json({
connected_accounts: user.connected_accounts,
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: {
- 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
- }
+ user: userDto,
+ guild_member: guildMemberDto,
});
});
diff --git a/api/src/routes/users/@me/index.ts b/api/src/routes/users/@me/index.ts
index 1af413c4..dc0d1cb1 100644
--- a/api/src/routes/users/@me/index.ts
+++ b/api/src/routes/users/@me/index.ts
@@ -1,7 +1,8 @@
import { Router, Request, Response } from "express";
-import { User, PrivateUserProjection, emitEvent, UserUpdateEvent, handleFile, FieldErrors } from "@fosscord/util";
+import { User, PrivateUserProjection, emitEvent, UserUpdateEvent, handleFile, FieldErrors, adjustEmail, Config } from "@fosscord/util";
import { route } from "@fosscord/api";
import bcrypt from "bcrypt";
+import { HTTPError } from "lambert-server";
const router: Router = Router();
@@ -21,6 +22,8 @@ export interface UserModifySchema {
password?: string;
new_password?: string;
code?: string;
+ email?: string;
+ discriminator?: string;
}
router.get("/", route({}), async (req: Request, res: Response) => {
@@ -30,11 +33,13 @@ router.get("/", route({}), async (req: Request, res: Response) => {
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"] });
+
+ if (user.email == "demo@maddy.k.vu") throw new HTTPError("Demo user, sorry", 400);
+
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);
- const user = await User.findOneOrFail({ where: { id: req.user_id }, select: [...PrivateUserProjection, "data"] });
-
if (body.password) {
if (user.data?.hash) {
const same_password = await bcrypt.compare(body.password, user.data.hash || "");
@@ -46,6 +51,14 @@ router.patch("/", route({ body: "UserModifySchema" }), async (req: Request, res:
}
}
+ 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({
@@ -55,14 +68,14 @@ router.patch("/", route({ body: "UserModifySchema" }), async (req: Request, res:
user.data.hash = await bcrypt.hash(body.new_password, 12);
}
- 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.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") }
+ });
+ }
+ }
user.assign(body);
await user.save();
diff --git a/api/src/routes/users/@me/mfa/codes-verification.ts b/api/src/routes/users/@me/mfa/codes-verification.ts
new file mode 100644
index 00000000..3aca44a6
--- /dev/null
+++ b/api/src/routes/users/@me/mfa/codes-verification.ts
@@ -0,0 +1,45 @@
+import { Router, Request, Response } from "express";
+import { route } from "@fosscord/api";
+import { BackupCode, generateMfaBackupCodes, User } from "@fosscord/util";
+
+const router = Router();
+
+export interface CodesVerificationSchema {
+ key: string;
+ nonce: string;
+ regenerate?: boolean;
+}
+
+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({ 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({
+ user: {
+ id: req.user_id,
+ },
+ expired: false,
+ });
+ }
+
+ return res.json({
+ backup_codes: codes.map(x => ({ ...x, expired: undefined })),
+ })
+});
+
+export default router;
diff --git a/api/src/routes/users/@me/mfa/codes.ts b/api/src/routes/users/@me/mfa/codes.ts
new file mode 100644
index 00000000..2a1fb498
--- /dev/null
+++ b/api/src/routes/users/@me/mfa/codes.ts
@@ -0,0 +1,48 @@
+import { Router, Request, Response } from "express";
+import { route } from "@fosscord/api";
+import { BackupCode, FieldErrors, generateMfaBackupCodes, User } from "@fosscord/util";
+import bcrypt from "bcrypt";
+
+const router = Router();
+
+export interface MfaCodesSchema {
+ password: string;
+ regenerate?: boolean;
+}
+
+// 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({ 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({
+ user: {
+ id: req.user_id,
+ },
+ expired: false,
+ });
+ }
+
+ return res.json({
+ backup_codes: codes.map(x => ({ ...x, expired: undefined })),
+ })
+});
+
+export default router;
diff --git a/api/src/routes/users/@me/mfa/totp/disable.ts b/api/src/routes/users/@me/mfa/totp/disable.ts
new file mode 100644
index 00000000..5e039ea3
--- /dev/null
+++ b/api/src/routes/users/@me/mfa/totp/disable.ts
@@ -0,0 +1,45 @@
+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 } from "@fosscord/util";
+
+const router = Router();
+
+export interface TotpDisableSchema {
+ code: string;
+}
+
+router.post("/", route({ body: "TotpDisableSchema" }), async (req: Request, res: Response) => {
+ const body = req.body as TotpDisableSchema;
+
+ const user = await User.findOneOrFail({ id: req.user_id }, { select: ["totp_secret"] });
+
+ const backup = await BackupCode.findOne({ 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;
\ No newline at end of file
diff --git a/api/src/routes/users/@me/mfa/totp/enable.ts b/api/src/routes/users/@me/mfa/totp/enable.ts
new file mode 100644
index 00000000..e4ce9ce0
--- /dev/null
+++ b/api/src/routes/users/@me/mfa/totp/enable.ts
@@ -0,0 +1,53 @@
+import { Router, Request, Response } from "express";
+import { User, generateToken, BackupCode, generateMfaBackupCodes } from "@fosscord/util";
+import { route } from "@fosscord/api";
+import bcrypt from "bcrypt";
+import { HTTPError } from "lambert-server";
+import { verifyToken } from 'node-2fa';
+import crypto from "crypto";
+
+const router = Router();
+
+export interface TotpEnableSchema {
+ password: string;
+ code?: string;
+ secret?: string;
+}
+
+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"] });
+
+ if (user.email == "demo@maddy.k.vu") throw new HTTPError("Demo user, sorry", 400);
+
+ // 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;
\ No newline at end of file
diff --git a/api/src/routes/users/@me/notes.ts b/api/src/routes/users/@me/notes.ts
index 4887b191..3c503942 100644
--- a/api/src/routes/users/@me/notes.ts
+++ b/api/src/routes/users/@me/notes.ts
@@ -1,37 +1,58 @@
import { Request, Response, Router } from "express";
import { route } from "@fosscord/api";
-import { User, emitEvent } from "@fosscord/util";
+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 user = await User.findOneOrFail({ where: { id: req.user_id }, select: ["notes"] });
- const note = user.notes[id];
+ const note = await Note.findOneOrFail({
+ where: {
+ owner: { id: req.user_id },
+ target: { id: id },
+ }
+ });
+
return res.json({
- note: note,
+ note: note?.content,
note_user_id: id,
- user_id: user.id,
+ user_id: req.user_id,
});
});
router.put("/:id", route({}), async (req: Request, res: Response) => {
const { id } = req.params;
- const user = await User.findOneOrFail({ where: { id: req.user_id } });
- const noteUser = await User.findOneOrFail({ where: { id: id }}); //if noted user does not exist throw
+ 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;
- await User.update({ id: req.user_id }, { notes: { ...user.notes, [noteUser.id]: note } });
+ if (note && note.length) {
+ // upsert a note
+ if (await Note.findOne({ 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: noteUser.id
+ id: target.id
},
- user_id: user.id,
- })
+ user_id: owner.id,
+ });
return res.status(204);
});
diff --git a/api/src/util/handlers/Message.ts b/api/src/util/handlers/Message.ts
index 48f87dfe..a6754bd1 100644
--- a/api/src/util/handlers/Message.ts
+++ b/api/src/util/handlers/Message.ts
@@ -54,26 +54,26 @@ export async function handleMessage(opts: MessageOptions): Promise<Message> {
channel_id: opts.channel_id,
attachments: opts.attachments || [],
embeds: opts.embeds || [],
- reactions: /*opts.reactions ||*/ [],
+ reactions: /*opts.reactions ||*/[],
type: opts.type ?? 0
});
if (message.content && message.content.length > Config.get().limits.message.maxCharacters) {
- throw new HTTPError("Content length over max character limit")
+ throw new HTTPError("Content length over max character limit");
}
if (opts.author_id) {
message.author = await User.getPublicUser(opts.author_id);
const rights = await getRights(opts.author_id);
rights.hasThrow("SEND_MESSAGES");
- }
+ }
if (opts.application_id) {
message.application = await Application.findOneOrFail({ id: opts.application_id });
}
if (opts.webhook_id) {
message.webhook = await Webhook.findOneOrFail({ id: opts.webhook_id });
}
-
+
const permission = await getPermission(opts.author_id, channel.guild_id, opts.channel_id);
permission.hasThrow("SEND_MESSAGES");
if (permission.cache.member) {
@@ -152,6 +152,8 @@ export async function postHandleMessage(message: Message) {
links = links.slice(0, 20); // embed max 20 links — TODO: make this configurable with instance policies
+ const { endpointPublic, resizeWidthMax, resizeHeightMax } = Config.get().cdn;
+
for (const link of links) {
try {
const request = await fetch(link, {
@@ -159,33 +161,88 @@ export async function postHandleMessage(message: Message) {
size: Config.get().limits.message.maxEmbedDownloadSize,
});
- const text = await request.text();
- const $ = cheerio.load(text);
-
- const title = $('meta[property="og:title"]').attr("content");
- const provider_name = $('meta[property="og:site_name"]').text();
- const author_name = $('meta[property="article:author"]').attr("content");
- const description = $('meta[property="og:description"]').attr("content") || $('meta[property="description"]').attr("content");
- const image = $('meta[property="og:image"]').attr("content");
- const url = $('meta[property="og:url"]').attr("content");
- // TODO: color
- const embed: Embed = {
- provider: {
- url: link,
- name: provider_name
- }
- };
-
- if (author_name) embed.author = { name: author_name };
- if (image) embed.thumbnail = { proxy_url: image, url: image };
- if (title) embed.title = title;
- if (url) embed.url = url;
- if (description) embed.description = description;
+ let embed: Embed;
- if (title || description) {
+ const type = request.headers.get("content-type");
+ if (type?.indexOf("image") == 0) {
+ embed = {
+ provider: {
+ url: link,
+ name: new URL(link).hostname,
+ },
+ image: {
+ // can't be bothered rn
+ proxy_url: `${endpointPublic}/external/resize/${encodeURIComponent(link)}?width=500&height=400`,
+ url: link,
+ width: 500,
+ height: 400
+ }
+ };
data.embeds.push(embed);
}
- } catch (error) {}
+ else {
+ const text = await request.text();
+ const $ = cheerio.load(text);
+
+ const title = $('meta[property="og:title"]').attr("content");
+ const provider_name = $('meta[property="og:site_name"]').text();
+ const author_name = $('meta[property="article:author"]').attr("content");
+ const description = $('meta[property="og:description"]').attr("content") || $('meta[property="description"]').attr("content");
+
+ const image = $('meta[property="og:image"]').attr("content");
+ const width = parseInt($('meta[property="og:image:width"]').attr("content") || "") || undefined;
+ const height = parseInt($('meta[property="og:image:height"]').attr("content") || "") || undefined;
+
+ const url = $('meta[property="og:url"]').attr("content");
+ // TODO: color
+ embed = {
+ provider: {
+ url: link,
+ name: provider_name
+ }
+ };
+
+ const resizeWidth = Math.min(resizeWidthMax ?? 1, width ?? 100);
+ const resizeHeight = Math.min(resizeHeightMax ?? 1, height ?? 100);
+ if (author_name) embed.author = { name: author_name };
+ if (image) embed.thumbnail = {
+ proxy_url: `${endpointPublic}/external/resize/${encodeURIComponent(image)}?width=${resizeWidth}&height=${resizeHeight}`,
+ url: image,
+ width: width,
+ height: height
+ };
+ if (title) embed.title = title;
+ if (url) embed.url = url;
+ if (description) embed.description = description;
+
+ const approvedProviders = [
+ "media4.giphy.com",
+ "c.tenor.com",
+ // todo: make configurable? don't really care tho
+ ];
+
+ // very bad code below
+ // don't care lol
+ if (embed?.thumbnail?.url && approvedProviders.indexOf(new URL(embed.thumbnail.url).hostname) !== -1) {
+ embed = {
+ provider: {
+ url: link,
+ name: new URL(link).hostname,
+ },
+ image: {
+ proxy_url: `${endpointPublic}/external/resize/${encodeURIComponent(image!)}?width=${resizeWidth}&height=${resizeHeight}`,
+ url: image,
+ width: width,
+ height: height
+ }
+ };
+ }
+
+ if (title || description) {
+ data.embeds.push(embed);
+ }
+ }
+ } catch (error) { }
}
await Promise.all([
@@ -206,7 +263,7 @@ export async function sendMessage(opts: MessageOptions) {
emitEvent({ event: "MESSAGE_CREATE", channel_id: opts.channel_id, data: message.toJSON() } as MessageCreateEvent)
]);
- postHandleMessage(message).catch((e) => {}); // no await as it should catch error non-blockingly
+ postHandleMessage(message).catch((e) => { }); // no await as it should catch error non-blockingly
return message;
}
diff --git a/api/src/util/index.ts b/api/src/util/index.ts
index ffbcf24e..de6b6064 100644
--- a/api/src/util/index.ts
+++ b/api/src/util/index.ts
@@ -6,3 +6,4 @@ export * from "./utility/RandomInviteID";
export * from "./handlers/route";
export * from "./utility/String";
export * from "./handlers/Voice";
+export * from "./utility/captcha";
\ No newline at end of file
diff --git a/api/src/util/utility/captcha.ts b/api/src/util/utility/captcha.ts
new file mode 100644
index 00000000..739647d2
--- /dev/null
+++ b/api/src/util/utility/captcha.ts
@@ -0,0 +1,46 @@
+import { Config } from "@fosscord/util";
+import fetch from "node-fetch";
+
+export interface hcaptchaResponse {
+ success: boolean;
+ challenge_ts: string;
+ hostname: string;
+ credit: boolean;
+ "error-codes": string[];
+ score: number; // enterprise only
+ score_reason: string[]; // enterprise only
+}
+
+export interface recaptchaResponse {
+ success: boolean;
+ score: number; // between 0 - 1
+ action: string;
+ challenge_ts: string;
+ hostname: string;
+ "error-codes"?: string[];
+}
+
+const verifyEndpoints = {
+ hcaptcha: "https://hcaptcha.com/siteverify",
+ recaptcha: "https://www.google.com/recaptcha/api/siteverify",
+}
+
+export async function verifyCaptcha(response: string, ip?: string) {
+ const { security } = Config.get();
+ const { service, secret, sitekey } = security.captcha;
+
+ if (!service) throw new Error("Cannot verify captcha without service");
+
+ const res = await fetch(verifyEndpoints[service], {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ },
+ body: `response=${encodeURIComponent(response)}`
+ + `&secret=${encodeURIComponent(secret!)}`
+ + `&sitekey=${encodeURIComponent(sitekey!)}`
+ + (ip ? `&remoteip=${encodeURIComponent(ip!)}` : ""),
+ });
+
+ return await res.json() as hcaptchaResponse | recaptchaResponse;
+}
\ No newline at end of file
|