diff --git a/Api/bptiers.js b/Api/bptiers.js new file mode 100644 index 0000000..ddf31a9 --- /dev/null +++ b/Api/bptiers.js @@ -0,0 +1,384 @@ +const express = require("express"); +const app = express.Router(); + +const Profile = require("../model/profiles.js"); +const User = require("../model/user.js"); +const functions = require("../structs/functions.js"); +const log = require("../structs/log.js"); + +const fs = require("fs"); +const path = require("path"); +const config = require("../Config/config.json"); + +app.get("/api/reload/bptiers", async (req, res) => { + try { + const { apikey, username, reason } = req.query; + + // --- Basic validation --- + if (!apikey || apikey !== config.Api.bApiKey) { + return res.status(401).json({ + code: "401", + error: "Invalid or missing API key." + }); + } + + if (!username) { + return res.status(400).json({ + code: "400", + error: "Missing username." + }); + } + + if (!reason) { + return res.status(400).json({ + code: "400", + error: "Missing reason." + }); + } + + const validReasons = config.Api.battlepass || {}; + const tiersToAdd = validReasons[reason]; + + if (typeof tiersToAdd !== "number" || tiersToAdd <= 0) { + return res.status(400).json({ + code: "400", + error: `Invalid reason. Allowed values: ${Object.keys(validReasons).join(", ")}` + }); + } + + if (!config.bEnableBattlepass) { + return res.status(400).json({ + code: "400", + error: "Battle Pass is disabled in the server config." + }); + } + + // --- Find user --- + const usernameLower = username.trim().toLowerCase(); + const user = await User.findOne({ username_lower: usernameLower }); + + if (!user) { + return res.status(404).json({ + code: "404", + error: "User not found." + }); + } + + // --- Load profiles --- + const profilesDoc = await Profile.findOne({ accountId: user.accountId }); + if (!profilesDoc || !profilesDoc.profiles) { + return res.status(404).json({ + code: "404", + error: "Profiles document not found." + }); + } + + const profiles = profilesDoc.profiles; + + // profileId is either "common_core" or "profile0" there. + let profile = profiles["common_core"] || profiles["profile0"]; + let profile0 = profiles["profile0"] || profile; + let athena = profiles["athena"]; + + if (!profile || !profile0 || !athena) { + return res.status(404).json({ + code: "404", + error: "Required profiles (common_core/profile0/athena) not found." + }); + } + + // Ensure structures exist + profile.items = profile.items || {}; + profile0.items = profile0.items || {}; + athena.items = athena.items || {}; + athena.stats = athena.stats || {}; + athena.stats.attributes = athena.stats.attributes || {}; + + // defaults + if (typeof athena.stats.attributes.season_match_boost !== "number") { + athena.stats.attributes.season_match_boost = 0; + } + if (typeof athena.stats.attributes.season_friend_match_boost !== "number") { + athena.stats.attributes.season_friend_match_boost = 0; + } + if (typeof athena.stats.attributes.book_level !== "number") { + athena.stats.attributes.book_level = 1; + } + if (typeof athena.stats.attributes.book_purchased !== "boolean") { + athena.stats.attributes.book_purchased = false; + } + + // --- Load Battle Pass config --- + const seasonName = `Season${config.bBattlePassSeason}`; + const bpPath = path.join( + __dirname, + "../responses/Athena/BattlePass/", + `${seasonName}.json` + ); + + let BattlePass; + try { + BattlePass = JSON.parse(fs.readFileSync(bpPath, "utf8")); + } catch (e) { + log.error("bptiers: Failed to load BattlePass JSON:", e); + return res.status(500).json({ + code: "500", + error: "Battle Pass configuration not found for this season." + }); + } + + let lootList = []; + let ItemExists = false; + + const startingTier = athena.stats.attributes.book_level; + let endingTier; + + athena.stats.attributes.book_level += tiersToAdd; + endingTier = athena.stats.attributes.book_level; + + // Loop through each tier and grant rewards + for (let i = startingTier; i < endingTier; i++) { + const FreeTier = BattlePass.freeRewards[i] || {}; + const PaidTier = BattlePass.paidRewards[i] || {}; + + // -------- FREE TRACK -------- + for (let item in FreeTier) { + if (!Object.prototype.hasOwnProperty.call(FreeTier, item)) continue; + + // XP boosts + if (item.toLowerCase() == "token:athenaseasonxpboost") { + athena.stats.attributes.season_match_boost += FreeTier[item]; + } + if (item.toLowerCase() == "token:athenaseasonfriendxpboost") { + athena.stats.attributes.season_friend_match_boost += FreeTier[item]; + } + + // V-Bucks: update MTX currency items on correct platform + if (item.toLowerCase().startsWith("currency:mtx")) { + for (let key in profile.items) { + if (!Object.prototype.hasOwnProperty.call(profile.items, key)) continue; + if (!profile.items[key].templateId) continue; + if (!profile.items[key].templateId.toLowerCase().startsWith("currency:mtx")) continue; + + // Free track V-Bucks always + profile.items[key].quantity += FreeTier[item]; + profile0.items[key].quantity += FreeTier[item]; + break; + } + } + + // Homebase banners go to profile/common_core + if (item.toLowerCase().startsWith("homebasebanner")) { + for (let key in profile.items) { + if (!Object.prototype.hasOwnProperty.call(profile.items, key)) continue; + if (!profile.items[key].templateId) continue; + + if (profile.items[key].templateId.toLowerCase() == item.toLowerCase()) { + profile.items[key].attributes = + profile.items[key].attributes || {}; + profile.items[key].attributes.item_seen = false; + ItemExists = true; + } + } + if (ItemExists == false) { + const ItemID = functions.MakeID(); + const Item = { + templateId: item, + attributes: { item_seen: false }, + quantity: 1 + }; + profile.items[ItemID] = Item; + } + ItemExists = false; + } + + // Athena cosmetics (skins, emotes, etc.) + if (item.toLowerCase().startsWith("athena")) { + for (let key in athena.items) { + if (!Object.prototype.hasOwnProperty.call(athena.items, key)) + continue; + if (!athena.items[key].templateId) continue; + + if (athena.items[key].templateId.toLowerCase() == item.toLowerCase()) { + athena.items[key].attributes = + athena.items[key].attributes || {}; + athena.items[key].attributes.item_seen = false; + ItemExists = true; + } + } + if (ItemExists == false) { + const ItemID = functions.MakeID(); + const Item = { + templateId: item, + attributes: { + max_level_bonus: 0, + level: 1, + item_seen: false, + xp: 0, + variants: [], + favorite: false + }, + quantity: FreeTier[item] + }; + athena.items[ItemID] = Item; + } + ItemExists = false; + } + + lootList.push({ + itemType: item, + itemGuid: item, + quantity: FreeTier[item] + }); + } + + // -------- PAID TRACK -------- + if (athena.stats.attributes.book_purchased) { + for (let item in PaidTier) { + if (!Object.prototype.hasOwnProperty.call(PaidTier, item)) continue; + + // XP boosts + if (item.toLowerCase() == "token:athenaseasonxpboost") { + athena.stats.attributes.season_match_boost += PaidTier[item]; + } + if (item.toLowerCase() == "token:athenaseasonfriendxpboost") { + athena.stats.attributes.season_friend_match_boost += PaidTier[item]; + } + + // V-Bucks + if (item.toLowerCase().startsWith("currency:mtx")) { + for (let key in profile.items) { + if (!Object.prototype.hasOwnProperty.call(profile.items, key)) continue; + if (!profile.items[key].templateId) continue; + if (!profile.items[key].templateId.toLowerCase().startsWith("currency:mtx")) continue; + + profile.items[key].quantity += PaidTier[item]; + profile0.items[key].quantity += PaidTier[item]; + break; + } + } + + // Homebase banners + if (item.toLowerCase().startsWith("homebasebanner")) { + for (let key in profile.items) { + if (!Object.prototype.hasOwnProperty.call(profile.items, key)) continue; + if (!profile.items[key].templateId) continue; + + if (profile.items[key].templateId.toLowerCase() == item.toLowerCase()) { + profile.items[key].attributes = + profile.items[key].attributes || {}; + profile.items[key].attributes.item_seen = false; + ItemExists = true; + } + } + if (ItemExists == false) { + const ItemID = functions.MakeID(); + const Item = { + templateId: item, + attributes: { item_seen: false }, + quantity: 1 + }; + profile.items[ItemID] = Item; + } + ItemExists = false; + } + + // Athena cosmetics + if (item.toLowerCase().startsWith("athena")) { + for (let key in athena.items) { + if (!Object.prototype.hasOwnProperty.call(athena.items, key)) + continue; + if (!athena.items[key].templateId) continue; + + if (athena.items[key].templateId.toLowerCase() == item.toLowerCase()) { + athena.items[key].attributes = + athena.items[key].attributes || {}; + athena.items[key].attributes.item_seen = false; + ItemExists = true; + } + } + if (ItemExists == false) { + const ItemID = functions.MakeID(); + const Item = { + templateId: item, + attributes: { + max_level_bonus: 0, + level: 1, + item_seen: false, + xp: 0, + variants: [], + favorite: false + }, + quantity: PaidTier[item] + }; + athena.items[ItemID] = Item; + } + ItemExists = false; + } + + lootList.push({ + itemType: item, + itemGuid: item, + quantity: PaidTier[item] + }); + } + } + } + + // --- Gift box --- + if (lootList.length > 0) { + const GiftBoxID = functions.MakeID(); + const GiftBox = { + templateId: "GiftBox:gb_battlepass", + attributes: { + max_level_bonus: 0, + fromAccountId: "", + lootList: lootList + }, + quantity: 1 + }; + profile.items[GiftBoxID] = GiftBox; + } + + // --- Bump revisions & save --- + athena.rvn = (athena.rvn || 0) + 1; + athena.commandRevision = (athena.commandRevision || 0) + 1; + athena.updated = new Date().toISOString(); + + profile.rvn = (profile.rvn || 0) + 1; + profile.commandRevision = (profile.commandRevision || 0) + 1; + profile.updated = new Date().toISOString(); + + profile0.rvn = (profile0.rvn || 0) + 1; + profile0.commandRevision = (profile0.commandRevision || 0) + 1; + profile0.updated = new Date().toISOString(); + + // Put modified profiles back and save + profiles["athena"] = athena; + if (profiles["common_core"]) profiles["common_core"] = profile; + if (profiles["profile0"]) profiles["profile0"] = profile0; + + await Profile.updateOne( + { accountId: user.accountId }, + { $set: { profiles: profiles } } + ); + + return res.status(200).json({ + message: "Battle Pass tiers (and cosmetics) successfully added.", + username: user.username, + reason, + tiersAdded: tiersToAdd, + book_level_before: startingTier, + book_level_after: endingTier, + lootListCount: lootList.length + }); + } catch (err) { + log.error("bptiers: unexpected error:", err); + return res.status(500).json({ + code: "500", + error: "Server error. Check logs for details." + }); + } +}); + +module.exports = app; \ No newline at end of file diff --git a/Api/vbucks.js b/Api/vbucks.js index 7ac60fa..da78695 100644 --- a/Api/vbucks.js +++ b/Api/vbucks.js @@ -20,7 +20,7 @@ app.get("/api/reload/vbucks", async (req, res) => { return res.status(400).json({ code: "400", error: "Missing reason." }); } - const validReasons = config.Api.reasons; + const validReasons = config.Api.vbucks; const addValue = validReasons[reason]; if (addValue === undefined) { diff --git a/Config/config.json b/Config/config.json index 95cef10..6cbd5d2 100644 --- a/Config/config.json +++ b/Config/config.json @@ -23,10 +23,13 @@ "Api": { "bApiKey": "ur-api-key", - "reasons": { - "Kill": 25, - "Win": 50 + "vbucks": { + "Win": 950 + }, + "battlepass": { + "Kill": 1 } + }, "Website": { diff --git a/routes/main.js b/routes/main.js index bc77ac8..e66de29 100644 --- a/routes/main.js +++ b/routes/main.js @@ -159,12 +159,6 @@ app.get("/d98eeaac-2bfa-4bf4-8a59-bdc95469c693", async (req, res) => { }) }) -app.post("/fortnite/api/feedback/*", (req, res) => { - log.debug("POST /fortnite/api/feedback/* called"); - res.status(200); - res.end(); -}); - app.post("/fortnite/api/statsv2/query", (req, res) => { log.debug("POST /fortnite/api/statsv2/query called"); res.json([]); diff --git a/routes/reports.js b/routes/reports.js index 7324542..58d6f36 100644 --- a/routes/reports.js +++ b/routes/reports.js @@ -1,16 +1,226 @@ const express = require("express"); -const app = express.Router(); +const router = express.Router(); + +const multer = require("multer"); +const { Client, Intents, MessageAttachment } = require("discord.js"); + const { verifyToken } = require("../tokenManager/tokenVerify.js"); const User = require("../model/user.js"); const Profiles = require("../model/profiles.js"); -const { Client, Intents, TextChannel } = require('discord.js'); -const config = require('../Config/config.json'); + +const config = require("../Config/config.json"); const log = require("../structs/log.js"); -app.post("/fortnite/api/game/v2/toxicity/account/:unsafeReporter/report/:reportedPlayer", verifyToken, async (req, res) => { - if (config.bEnableReports === true) { +// Fortnite sends feedback as multipart/form-data (Bug/Comment/Player). +// express.json/urlencoded cannot parse it, so we need multer. +const upload = multer({ + storage: multer.memoryStorage(), + limits: { + // Lobby bug reports can include Screenshot.jpg + clientlog.log.gz. + // Keep this generous but bounded. + fileSize: 25 * 1024 * 1024, + files: 10, + fieldSize: 2 * 1024 * 1024 + } +}); + +// Simple in-memory cooldown to prevent spamming. +// Key: : +const lastReportAt = new Map(); +const REPORT_COOLDOWN_MS = 15 * 1000; + +let discordClient; +let discordClientReadyPromise; + +async function getDiscordClient() { + if (!config?.discord?.bUseDiscordBot) return null; + if (!config?.discord?.bot_token) { + log.warn("Reports: Discord bot is enabled but config.discord.bot_token is empty."); + return null; + } + if (!config?.bReportChannelId || config.bReportChannelId === "your-discord-channel-id-here") { + log.warn("Reports: config.bReportChannelId is not configured."); + return null; + } + + if (discordClient && discordClient.isReady?.()) return discordClient; + if (discordClientReadyPromise) { + await discordClientReadyPromise; + return discordClient; + } + + discordClient = new Client({ + intents: [ + Intents.FLAGS.GUILDS, + Intents.FLAGS.GUILD_MESSAGES + ] + }); + + discordClientReadyPromise = (async () => { + await discordClient.login(config.discord.bot_token); + if (!discordClient.isReady?.()) { + await new Promise((resolve) => discordClient.once("ready", resolve)); + } + })().catch((err) => { + log.error("Reports: failed to login Discord client", err); + // Reset so next request can retry. + discordClientReadyPromise = null; + discordClient = null; + throw err; + }); + + await discordClientReadyPromise; + return discordClient; +} + +function normalizeType(urlType, bodyType) { + const t = (urlType || bodyType || "").toString().trim(); + const lower = t.toLowerCase(); + if (lower === "bug") return "Bug"; + if (lower === "comment") return "Comment"; + if (lower === "player") return "Player"; + return t || "Unknown"; +} + +function canAcceptReport(accountId, type) { + const key = `${accountId}:${type}`; + const now = Date.now(); + const last = lastReportAt.get(key) || 0; + if (now - last < REPORT_COOLDOWN_MS) return false; + lastReportAt.set(key, now); + return true; +} + +async function sendToDiscord({ type, reporter, fields, files }) { + const client = await getDiscordClient(); + if (!client) return; + + const channel = await client.channels.fetch(config.bReportChannelId).catch(() => null); + if (!channel || !channel.send) { + log.warn("Reports: Discord channel not found or not sendable."); + return; + } + + const embed = { + title: `New Feedback Report (${type})`, + description: "A new lobby / in-game feedback report arrived.", + color: 0xFFA500, + fields + }; + + const attachments = (files || []).slice(0, 10).map((f) => { + // discord.js v13 can send Buffers as attachments. + const name = f.originalname || "attachment.bin"; + return new MessageAttachment(f.buffer, name); + }); + + await channel.send({ + embeds: [embed], + files: attachments + }).catch((err) => { + log.error("Reports: failed to send report to Discord", err); + }); +} + +/** + * LOBBY + IN-GAME FEEDBACK REPORTS + * + * Seen in your captures: + * - POST /fortnite/api/feedback/Bug + * - POST /fortnite/api/feedback/Comment + * - POST /fortnite/api/feedback/Player + * + * These are multipart/form-data requests with fields like: + * feedbacktype, displayname, email, accountid, engineversion, platform, + * gamebackend, gamename, subgamename, subject, feedbackbody + * + * Bug reports can also include files: Screenshot.jpg, clientlog.log.gz, etc. + */ +router.post( + "/fortnite/api/feedback/:type", + verifyToken, + upload.any(), + async (req, res) => { + log.debug(`POST /fortnite/api/feedback/${req.params.type} called`); + + // Even when disabled, respond OK so the client doesn't hang. + if (config.bEnableReports !== true) { + return res.status(200).end(); + } + + const type = normalizeType(req.params.type, req.body?.feedbacktype); + const accountId = req.user?.accountId || req.body?.accountid || "unknown"; + + if (!canAcceptReport(accountId, type)) { + // Prevent spam (client doesn't need a special error) + return res.status(200).end(); + } + + const reporterDisplayName = req.body?.displayname || req.user?.username || "Unknown"; + const reporterEmail = req.body?.email || ""; + const subject = req.body?.subject || ""; + const feedbackBody = req.body?.feedbackbody || ""; + const engineVersion = req.body?.engineversion || ""; + const platform = req.body?.platform || ""; + const gameBackend = req.body?.gamebackend || ""; + const gameName = req.body?.gamename || ""; + const subGameName = req.body?.subgamename || ""; + const correlationId = req.headers["x-epic-correlation-id"] || ""; + + const fields = [ + { name: "Reporter", value: reporterDisplayName || "-", inline: true }, + { name: "AccountId", value: accountId || "-", inline: true }, + { name: "Type", value: type, inline: true }, + { name: "Subject", value: subject || "-", inline: false }, + { name: "Body", value: (feedbackBody || "-").slice(0, 1024), inline: false }, + { name: "Platform", value: platform || "-", inline: true }, + { name: "EngineVersion", value: engineVersion || "-", inline: true }, + { name: "Game", value: `${gameBackend || "-"} / ${gameName || "-"} / ${subGameName || "-"}`, inline: false } + ]; + + if (reporterEmail) { + // Discord embeds have a max total size; keep this small. + fields.push({ name: "Email", value: reporterEmail.slice(0, 256), inline: false }); + } + if (correlationId) { + fields.push({ name: "X-Epic-Correlation-ID", value: correlationId.toString().slice(0, 256), inline: false }); + } + + // Optional: include username from DB if it differs. + try { + const reporterData = await User.findOne({ accountId }).lean(); + if (reporterData?.username && reporterData.username !== reporterDisplayName) { + fields.unshift({ name: "DB Username", value: reporterData.username, inline: true }); + } + } catch (e) { + // Non-fatal + } + + await sendToDiscord({ + type, + reporter: { accountId, displayName: reporterDisplayName }, + fields, + files: req.files || [] + }); + + return res.status(200).end(); + } +); + +/** + * IN-GAME TOXICITY REPORTS + * (Existing endpoint kept as-is) + */ +router.post( + "/fortnite/api/game/v2/toxicity/account/:unsafeReporter/report/:reportedPlayer", + verifyToken, + async (req, res) => { + if (config.bEnableReports !== true) return res.status(200).end(); + try { - log.debug(`POST /fortnite/api/game/v2/toxicity/account/${req.params.unsafeReporter}/report/${req.params.reportedPlayer} called`); + log.debug( + `POST /fortnite/api/game/v2/toxicity/account/${req.params.unsafeReporter}/report/${req.params.reportedPlayer} called` + ); const reporter = req.user.accountId; const reportedPlayer = req.params.reportedPlayer; @@ -24,107 +234,49 @@ app.post("/fortnite/api/game/v2/toxicity/account/:unsafeReporter/report/:reporte if (!reportedPlayerData) { log.error(`Reported player with accountId: ${reportedPlayer} not found in the database`); - return res.status(404).send({ "error": "Player not found" }); + return res.status(404).send({ error: "Player not found" }); } - const reason = req.body.reason || 'No reason provided'; - const details = req.body.details || 'No details provided'; - const playerAlreadyReported = reportedPlayerDataProfile.profiles?.totalReports ? 'Yes' : 'No'; + const reason = req.body.reason || "No reason provided"; + const details = req.body.details || "No details provided"; + const playerAlreadyReported = reportedPlayerDataProfile?.profiles?.totalReports ? "Yes" : "No"; log.debug(`Player already reported: ${playerAlreadyReported}`); - const client = new Client({ - intents: [ - Intents.FLAGS.GUILDS, - Intents.FLAGS.GUILD_MESSAGES, - Intents.FLAGS.GUILD_MEMBERS, - Intents.FLAGS.DIRECT_MESSAGES, - Intents.FLAGS.DIRECT_MESSAGE_TYPING, - Intents.FLAGS.DIRECT_MESSAGE_REACTIONS - ] - }); - await Profiles.findOneAndUpdate( { accountId: reportedPlayer }, - { $inc: { 'profiles.totalReports': 1 } }, + { $inc: { "profiles.totalReports": 1 } }, { new: true, upsert: true } - ).then((updatedProfile) => { - log.debug(`Successfully updated totalReports to ${updatedProfile.profiles.totalReports} for accountId: ${reportedPlayer}`); - }).catch((err) => { - log.error(`Error updating totalReports for accountId: ${reportedPlayer}`, err); - return res.status(500).send({ "error": "Database update error" }); - }); - - await new Promise((resolve, reject) => { - client.once('ready', async () => { - - try { - const payload = { - embeds: [{ - title: 'New User Report', - description: 'A new report has arrived!', - color: 0xFFA500, - fields: [ - { - name: "Reporting Player", - value: reporterData.username, - inline: true - }, - { - name: "Reported Player", - value: reportedPlayerData.username, - inline: true - }, - { - name: "Player already reported", - value: playerAlreadyReported, - inline: false - }, - { - name: "Reason", - value: reason, - inline: true - }, - { - name: "Additional Details", - value: details, - inline: true - } - ] - }] - }; - - const channel = await client.channels.fetch(config.bReportChannelId); - - if (channel instanceof TextChannel) { - log.debug(`Sending embed to channel with ID: ${channel.id}`); - const message = await channel.send({ - embeds: [payload.embeds[0]] - }); - log.debug(`Message sent with ID: ${message.id}`); - } else { - log.error("The channel is not a valid text channel or couldn't be found."); - } - - resolve(); - } catch (error) { - log.error('Error sending message:', error); - reject(error); - } + ) + .then((updatedProfile) => { + log.debug( + `Successfully updated totalReports to ${updatedProfile.profiles.totalReports} for accountId: ${reportedPlayer}` + ); + }) + .catch((err) => { + log.error(`Error updating totalReports for accountId: ${reportedPlayer}`, err); + return res.status(500).send({ error: "Database update error" }); }); - client.login(config.discord.bot_token).catch((err) => { - log.error("Error logging in Discord bot:", err); - reject(err); - }); + // Forward to Discord, if configured. + await sendToDiscord({ + type: "Toxicity", + reporter: { accountId: reporter, displayName: reporterData?.username || reporter }, + fields: [ + { name: "Reporting Player", value: reporterData?.username || reporter, inline: true }, + { name: "Reported Player", value: reportedPlayerData?.username || reportedPlayer, inline: true }, + { name: "Player already reported", value: playerAlreadyReported, inline: false }, + { name: "Reason", value: reason, inline: true }, + { name: "Additional Details", value: details, inline: true } + ] }); - return res.status(200).send({ "success": true }); + return res.status(200).send({ success: true }); } catch (error) { log.error(error); - return res.status(500).send({ "error": "Internal server error" }); + return res.status(500).send({ error: "Internal server error" }); } } -}); +); -module.exports = app; \ No newline at end of file +module.exports = router;