summary refs log tree commit diff
path: root/api/src
diff options
context:
space:
mode:
Diffstat (limited to 'api/src')
-rw-r--r--api/src/middlewares/Authentication.ts1
-rw-r--r--api/src/middlewares/RateLimit.ts24
-rw-r--r--api/src/routes/auth/login.ts31
-rw-r--r--api/src/routes/auth/mfa/totp.ts49
-rw-r--r--api/src/routes/auth/register.ts17
-rw-r--r--api/src/routes/auth/verify/view-backup-codes-challenge.ts26
-rw-r--r--api/src/routes/channels/#channel_id/messages/#message_id/ack.ts2
-rw-r--r--api/src/routes/channels/#channel_id/messages/index.ts7
-rw-r--r--api/src/routes/guilds/#guild_id/index.ts1
-rw-r--r--api/src/routes/guilds/#guild_id/member-verification.ts14
-rw-r--r--api/src/routes/guilds/#guild_id/members/#member_id/index.ts3
-rw-r--r--api/src/routes/guilds/#guild_id/roles/#role_id/index.ts3
-rw-r--r--api/src/routes/guilds/#guild_id/welcome-screen.ts (renamed from api/src/routes/guilds/#guild_id/welcome_screen.ts)4
-rw-r--r--api/src/routes/guilds/index.ts2
-rw-r--r--api/src/routes/store/published-listings/skus/#sku_id/subscription-plans.ts16
-rw-r--r--api/src/routes/users/#id/profile.ts81
-rw-r--r--api/src/routes/users/@me/index.ts35
-rw-r--r--api/src/routes/users/@me/mfa/codes-verification.ts45
-rw-r--r--api/src/routes/users/@me/mfa/codes.ts48
-rw-r--r--api/src/routes/users/@me/mfa/totp/disable.ts45
-rw-r--r--api/src/routes/users/@me/mfa/totp/enable.ts53
-rw-r--r--api/src/routes/users/@me/notes.ts43
-rw-r--r--api/src/util/handlers/Message.ts115
-rw-r--r--api/src/util/index.ts1
-rw-r--r--api/src/util/utility/captcha.ts46
25 files changed, 603 insertions, 109 deletions
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