const express = require("express") const router = express.Router() const Errors = require("../lib/errors.js") const auth = require("../lib/authorize.js") const axios = require("axios") const { User, Session, Theme, Friend, Attachment } = require("../models") const cryptoRandomString = require("crypto-random-string") const { Op } = require("sequelize") const speakeasy = require("speakeasy") const argon2 = require("argon2") const UAParser = require("ua-parser-js") const fs = require("fs") const path = require("path") const semver = require("semver") const multer = require("multer") const FileType = require("file-type") const rateLimit = require("express-rate-limit") const Mailgen = require("mailgen") const nodemailer = require("nodemailer") const storage = multer.diskStorage({ destination: function (req, file, cb) { cb(null, "usercontent/") }, filename: function (req, file, cb) { cb( null, cryptoRandomString({ length: 32 }) + path.extname(file.originalname) ) } }) const limiter = rateLimit({ windowMs: 5 * 60 * 1000, max: 2, message: Errors.rateLimit, standardHeaders: true, legacyHeaders: false, skipFailedRequests: true, keyGenerator: (req, res) => req.user?.id || req.ip }) const mailLimiter = rateLimit({ windowMs: 60 * 1000, max: 1, message: Errors.rateLimit, standardHeaders: true, legacyHeaders: false, skipFailedRequests: true, keyGenerator: (req, res) => req.user?.id || req.ip }) const whitelist = [ "image/png", "image/jpeg", "image/jpg", "image/webp", "image/gif" ] const upload = multer({ storage: storage, limits: { fileSize: 2 * 1024 * 1024 }, fileFilter: (req, file, cb) => { if (!whitelist.includes(file.mimetype)) { return cb(new Error("Only images are allowed")) } cb(null, true) } }) router.post("/verify/resend", auth, mailLimiter, async (req, res, next) => { try { if (!req.app.locals.config.emailVerification) { throw Errors.invalidParameter("Email verification is disabled") } if (req.user.emailVerified) { throw Errors.invalidParameter("Email is already verified") } const token = "COLUBRINA-VERIFY-" + cryptoRandomString({ length: 64 }) await req.user.update({ emailToken: token }) const mailGenerator = new Mailgen({ theme: "default", product: { name: req.app.locals.config.siteName, link: req.app.locals.config.corsHostname } }) const email = { body: { name: req.user.username, intro: `${req.app.locals.config.siteName} Account Verification`, action: { instructions: `You are receiving this email because you registered on ${req.app.locals.config.siteName}, please use the link below to verify your account.`, button: { color: "#1A97FF", text: "Account Verification", link: req.app.locals.config.corsHostname + "/email/confirm/" + token } }, outro: "If you did not register, please disregard this email." } } const emailBody = mailGenerator.generate(email) const emailText = mailGenerator.generatePlaintext(email) const transporter = nodemailer.createTransport({ host: req.app.locals.config.emailSMTPHost, port: req.app.locals.config.emailSMTPPort, secure: req.app.locals.config.emailSMTPSecure, auth: { user: req.app.locals.config.emailSMTPUser, pass: req.app.locals.config.emailSMTPPassword } }) let info = await transporter.sendMail({ from: req.app.locals.config.emailSMTPFrom, to: req.user.email, subject: "Email Verification - " + req.app.locals.config.siteName, text: emailText, html: emailBody }) if (info) { res.json({ success: true }) } else { throw Errors.mailFail } } catch (e) { next(e) } }) router.post("/verify/confirm/:token", async (req, res, next) => { try { if (!req.app.locals.config.emailVerification) { throw Errors.invalidParameter("Email verification is disabled") } if (!req.params.token) { throw Errors.invalidToken } const user = await User.findOne({ where: { emailToken: req.params.token, emailVerified: false } }) if (user) { await user.update({ emailVerified: true, emailToken: null }) res.json({ success: true }) } else { throw Errors.invalidToken } } catch (e) { next(e) } }) router.post("/login", async (req, res, next) => { async function checkPassword(password, hash) { try { return await argon2.verify(hash, password) } catch { console.log("Error") return false } } async function generateSession(user) { try { const ua = UAParser(req.headers["user-agent"]) let ip = {} await axios .get("http://ip-api.com/json/ " + req.ip) .then((res) => { ip = res.data }) .catch(() => {}) const session = await Session.create({ userId: user.id, session: "COLUBRINA-" + cryptoRandomString({ length: 128 }), expiredAt: new Date(new Date().getTime() + 1000 * 60 * 60 * 24 * 30), other: { ip: req.ip, location: ip.country ? `${ip.city} - ${ip.regionName} - ${ip.country}` : null, isp: ip.isp, asn: ip.as, browserString: ua.browser.name + " v" + ua.browser.major, osString: ua.os.name + " " + ua.os.version, browser: ua.browser.name, browserVersion: ua.browser.version, browserVersionMajor: ua.browser.major, os: ua.os.name, osVersion: ua.os.version } }) res.json({ session: session.session, success: true, userId: user.id }) } catch (e) { console.log(e) return false } } try { const user = await User.findOne({ where: { username: req.body.username || "" } }) if (user) { if (user.banned) throw Errors.banned if (await checkPassword(req.body.password, user.password)) { if (user.totpEnabled) { const verified = speakeasy.totp.verify({ secret: user.totp, encoding: "base32", token: req.body.totp || "" }) if (verified) { await generateSession(user) } else { throw Errors.invalidTotp } } else { await generateSession(user) } } else { throw Errors.invalidCredentials } } else { throw Errors.invalidCredentials } } catch (err) { next(err) } }) router.post("/register", limiter, async (req, res, next) => { async function generatePassword(password) { try { return await argon2.hash(password) } catch { console.log("Error") return false } } async function generateSession(user) { try { const ua = UAParser(req.headers["user-agent"]) let ip = {} await axios .get("http://ip-api.com/json/ " + req.ip) .then((res) => { ip = res.data }) .catch(() => {}) const session = await Session.create({ userId: user.id, session: "COLUBRINA-" + cryptoRandomString({ length: 128 }), expiredAt: new Date(new Date().getTime() + 1000 * 60 * 60 * 24 * 30), other: { ip: req.ip, location: ip.country ? `${ip.city} - ${ip.regionName} - ${ip.country}` : null, isp: ip.isp, asn: ip.as, browserString: ua.browser.name + " v" + ua.browser.major, osString: ua.os.name + " " + ua.os.version, browser: ua.browser.name, browserVersion: ua.browser.version, browserVersionMajor: ua.browser.major, os: ua.os.name, osVersion: ua.os.version } }) res.json({ session: session.session, success: true, userId: user.id }) } catch (e) { console.log(e) return false } } try { if (!req.app.locals.config.allowRegistrations) { throw Errors.registrationsDisabled } if (req.body.password.length < 8) { throw Errors.invalidPassword } const user = await User.create({ username: req.body.username, password: await generatePassword(req.body.password), theme: "dark", themeId: 1, guidedWizard: true, name: req.body.username, admin: false, email: req.body.email, font: "Inter", status: "offline", storedStatus: "online", experiments: [], avatar: null }) if (user) { await generateSession(user) } else { throw Errors.unknown } } catch (err) { next(err) } }) router.get("/", auth, (req, res, next) => { try { res.json(req.user) } catch {} }) router.get("/sessions", auth, async (req, res, next) => { try { const sessions = await Session.findAll({ where: { userId: req.user.id }, attributes: { exclude: ["session"] } }) res.json(sessions) } catch (e) { next(e) } }) router.delete("/sessions/:id", auth, async (req, res, next) => { try { await Session.destroy({ where: { id: req.params.id, userId: req.user.id } }) res.sendStatus(204) } catch (e) { next(e) } }) router.post( "/settings/avatar", auth, upload.single("avatar"), async (req, res, next) => { try { const io = req.app.get("io") if (req.file) { const meta = await FileType.fromFile(req.file.path) if (!whitelist.includes(meta.mime)) { throw Errors.invalidFileType } const attachment = await Attachment.create({ userId: req.user.id, type: "avatar", attachment: req.file.filename, name: req.file.originalname, extension: meta.ext, size: req.file.size }) await User.update( { avatar: attachment.attachment }, { where: { id: req.user.id } } ) res.json(attachment) const friends = await Friend.findAll({ where: { userId: req.user.id } }) io.to(req.user.id).emit("userSettings", { userId: req.user.id, avatar: attachment.attachment, status: req.body.status === "invisible" ? "offline" : req.body.status, storedStatus: req.body.status }) friends.forEach((friend) => { io.to(friend.id).emit("userSettings", { userId: req.user.id, avatar: attachment.attachment, status: req.body.status === "invisible" ? "offline" : req.body.status, storedStatus: req.body.status }) }) } else { throw Errors.invalidParameter("avatar") } } catch (e) { next(e) } } ) router.put("/settings/:type", auth, async (req, res, next) => { async function checkPasswordArgon2(password, hash) { try { return await argon2.verify(hash, password) } catch { console.log("Error") return false } } try { const io = req.app.get("io") if (req.params.type === "full") { await User.update( { theme: req.body.theme, guidedWizard: req.body.guidedWizard, font: req.body.font, experiments: req.body.experiments, username: req.body.username, email: req.body.email }, { where: { id: req.user.id } } ) res.sendStatus(204) } else if (req.params.type === "theme") { const theme = await Theme.findOne({ where: { id: req.body.id, [Op.or]: [ { userId: req.user.id }, { public: true } ] } }) if (theme) { await User.update( { themeId: theme.id, accentColor: req.body.accent }, { where: { id: req.user.id } } ) res.sendStatus(204) } else { throw Errors.invalidParameter("Theme", "Invalid theme specified") } } else if (req.params.type === "totp") { if (req.user.totpEnabled && req.body.code) { res.json({ enabled: true }) } else if (!req.user.totpEnabled && req.body.password) { const match = await checkPassword(req.body.password) if (match) { const token = speakeasy.generateSecret({ name: "Colubrina - " + req.user.username, issuer: "Colubrina" }) await User.update( { totp: token.base32 }, { where: { id: req.user.id } } ) res.json({ secret: token.base32, enabled: false }) } else { throw Errors.invalidCredentials } } else { throw Errors.invalidParameter("Password or Code") } } else if (req.params.type === "password") { const user = await User.findOne({ where: { id: req.user.id } }) const match = await checkPasswordArgon2(req.body.current, user.password) if (match) { await user.update({ password: await argon2.hash(req.body.new) }) res.sendStatus(204) } else { throw Errors.invalidCredentials } } else if (req.params.type === "communications") { const user = await User.findOne({ where: { id: req.user.id } }) await user.update({ privacy: { communications: { enabled: req.body.enabled, outsideTenant: req.body.outsideTenant, directMessages: req.body.directMessages, friendRequests: req.body.friendRequests } } }) res.sendStatus(204) } else if (req.params.type === "status") { const user = await User.findOne({ where: { id: req.user.id } }) if (!["online", "away", "busy", "invisible"].includes(req.body.status)) { res.json({ status: user.status, storedStatus: user.storedStatus }) } else { await user.update({ storedStatus: req.body.status, status: req.body.status === "invisible" ? "offline" : req.body.status }) const friends = await Friend.findAll({ where: { userId: user.id, status: "accepted" } }) friends.forEach((friend) => { io.to(friend.friendId).emit("userStatus", { userId: user.id, status: req.body.status === "invisible" ? "offline" : req.body.status }) }) io.to(user.id).emit("userStatus", { userId: user.id, status: req.body.status === "invisible" ? "offline" : req.body.status }) io.to(user.id).emit("userSettings", { userId: user.id, status: req.body.status === "invisible" ? "offline" : req.body.status, storedStatus: req.body.status }) res.json({ status: req.body.status === "invisible" ? "offline" : req.body.status, storedStatus: req.body.status }) } } else { throw Errors.invalidParameter("Settings type", "Invalid settings type") } } catch (e) { console.log(e) next(e) } }) router.post("/reset/send", mailLimiter, async (req, res, next) => { try { const user = await User.findOne({ where: { email: req.body.email } }) if (user) { const token = cryptoRandomString({ length: 64 }) await user.update({ passwordResetToken: token, passwordResetExpiry: Date.now() + 86400000 }) const mailGenerator = new Mailgen({ theme: "default", product: { name: req.app.locals.config.siteName, link: req.app.locals.config.corsHostname } }) const email = { body: { name: user.username, intro: `${req.app.locals.config.siteName} Password Reset`, action: { instructions: `You are receiving this email because you registered on ${req.app.locals.config.siteName}, please use the link below to reset your account's password.`, button: { color: "#1A97FF", text: "Password Reset", link: req.app.locals.config.corsHostname + "/reset/" + token } }, outro: "If you did not request a password reset, ignore this email." } } const emailBody = mailGenerator.generate(email) const emailText = mailGenerator.generatePlaintext(email) const transporter = nodemailer.createTransport({ host: req.app.locals.config.emailSMTPHost, port: req.app.locals.config.emailSMTPPort, secure: req.app.locals.config.emailSMTPSecure, auth: { user: req.app.locals.config.emailSMTPUser, pass: req.app.locals.config.emailSMTPPassword } }) let info = await transporter.sendMail({ from: req.app.locals.config.emailSMTPFrom, to: user.email, subject: "Password Reset - " + req.app.locals.config.siteName, text: emailText, html: emailBody }) res.sendStatus(204) } else { throw Errors.customMessage( "Email not found, please type your email, not your username." ) } } catch (e) { console.log(e) next(e) } }) router.put("/reset", async (req, res, next) => { try { if (req.body.password && req.body.token) { const user = await User.findOne({ where: { passwordResetToken: req.body.token, passwordResetExpiry: { [Op.gt]: Date.now() } } }) if (!req.body.token) { throw Errors.invalidParameter("Token", "Invalid or expired token") } if (user) { await user.update({ password: await argon2.hash(req.body.password), passwordResetToken: null, passwordResetExpiry: null }) res.sendStatus(204) } else { throw Errors.invalidParameter("Token", "Invalid or expired token") } } else { throw Errors.invalidParameter("Password or Token") } } catch (e) { console.log(e) next(e) } }) module.exports = router