first commit
This commit is contained in:
1118
routes/admin.js
Normal file
1118
routes/admin.js
Normal file
File diff suppressed because it is too large
Load Diff
497
routes/auth.js
Normal file
497
routes/auth.js
Normal file
@@ -0,0 +1,497 @@
|
||||
import { generateTokenPair } from "../utils/jwt.js";
|
||||
import {
|
||||
authenticate,
|
||||
verifyRefreshTokenMiddleware,
|
||||
} from "../middleware/auth.js";
|
||||
import { config } from "../config/index.js";
|
||||
import Session from "../models/Session.js";
|
||||
|
||||
/**
|
||||
* Authentication routes
|
||||
* @param {FastifyInstance} fastify - Fastify instance
|
||||
*/
|
||||
export default async function authRoutes(fastify, options) {
|
||||
// Debug endpoint to check cookies and headers (development only)
|
||||
fastify.get("/debug-cookies", async (request, reply) => {
|
||||
if (config.isProduction) {
|
||||
return reply.status(404).send({ error: "Not found" });
|
||||
}
|
||||
|
||||
// Parse the raw cookie header manually to compare
|
||||
const rawCookieHeader = request.headers.cookie || "";
|
||||
const manualParsedCookies = {};
|
||||
if (rawCookieHeader) {
|
||||
rawCookieHeader.split(";").forEach((cookie) => {
|
||||
const [name, ...valueParts] = cookie.trim().split("=");
|
||||
if (name) {
|
||||
manualParsedCookies[name] = valueParts.join("=");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
cookies: request.cookies || {},
|
||||
manualParsedCookies: manualParsedCookies,
|
||||
rawCookieHeader: rawCookieHeader,
|
||||
headers: {
|
||||
authorization: request.headers.authorization || null,
|
||||
origin: request.headers.origin || null,
|
||||
referer: request.headers.referer || null,
|
||||
cookie: request.headers.cookie ? "Present" : "Missing",
|
||||
host: request.headers.host || null,
|
||||
},
|
||||
hasAccessToken: !!request.cookies?.accessToken,
|
||||
hasRefreshToken: !!request.cookies?.refreshToken,
|
||||
manualHasAccessToken: !!manualParsedCookies.accessToken,
|
||||
manualHasRefreshToken: !!manualParsedCookies.refreshToken,
|
||||
config: {
|
||||
cookieDomain: config.cookie.domain,
|
||||
cookieSecure: config.cookie.secure,
|
||||
cookieSameSite: config.cookie.sameSite,
|
||||
corsOrigin: config.cors.origin,
|
||||
},
|
||||
note: "If request.cookies is empty but manualParsedCookies has data, then @fastify/cookie isn't parsing correctly",
|
||||
});
|
||||
});
|
||||
|
||||
// Test endpoint to verify Steam configuration
|
||||
fastify.get("/steam/test", async (request, reply) => {
|
||||
return reply.send({
|
||||
success: true,
|
||||
steamConfig: {
|
||||
apiKeySet: !!config.steam.apiKey,
|
||||
realm: config.steam.realm,
|
||||
returnURL: config.steam.returnURL,
|
||||
},
|
||||
message: "Steam authentication is configured. Try /auth/steam to login.",
|
||||
});
|
||||
});
|
||||
|
||||
// Steam login - initiate OAuth flow
|
||||
fastify.get("/steam", async (request, reply) => {
|
||||
try {
|
||||
// Manually construct the Steam OpenID URL
|
||||
const returnURL = encodeURIComponent(config.steam.returnURL);
|
||||
const realm = encodeURIComponent(config.steam.realm);
|
||||
|
||||
const steamOpenIDURL =
|
||||
`https://steamcommunity.com/openid/login?` +
|
||||
`openid.mode=checkid_setup&` +
|
||||
`openid.ns=http://specs.openid.net/auth/2.0&` +
|
||||
`openid.identity=http://specs.openid.net/auth/2.0/identifier_select&` +
|
||||
`openid.claimed_id=http://specs.openid.net/auth/2.0/identifier_select&` +
|
||||
`openid.return_to=${returnURL}&` +
|
||||
`openid.realm=${realm}`;
|
||||
|
||||
return reply.redirect(steamOpenIDURL);
|
||||
} catch (error) {
|
||||
console.error("❌ Steam authentication error:", error);
|
||||
return reply.status(500).send({
|
||||
error: "AuthenticationError",
|
||||
message: "Failed to initiate Steam login",
|
||||
details: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Steam OAuth callback - Manual verification
|
||||
fastify.get("/steam/return", async (request, reply) => {
|
||||
try {
|
||||
const query = request.query;
|
||||
|
||||
// Verify this is a valid OpenID response
|
||||
if (query["openid.mode"] !== "id_res") {
|
||||
return reply.status(400).send({
|
||||
error: "AuthenticationError",
|
||||
message: "Invalid OpenID response mode",
|
||||
});
|
||||
}
|
||||
|
||||
// Verify the response with Steam
|
||||
const verifyParams = new URLSearchParams();
|
||||
for (const key in query) {
|
||||
verifyParams.append(key, query[key]);
|
||||
}
|
||||
verifyParams.set("openid.mode", "check_authentication");
|
||||
|
||||
const verifyResponse = await fetch(
|
||||
"https://steamcommunity.com/openid/login",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: verifyParams.toString(),
|
||||
}
|
||||
);
|
||||
|
||||
const verifyText = await verifyResponse.text();
|
||||
|
||||
if (!verifyText.includes("is_valid:true")) {
|
||||
console.error("❌ Steam OpenID verification failed");
|
||||
return reply.status(401).send({
|
||||
error: "AuthenticationError",
|
||||
message: "Steam authentication verification failed",
|
||||
});
|
||||
}
|
||||
|
||||
// Extract Steam ID from the claimed_id
|
||||
const claimedId = query["openid.claimed_id"];
|
||||
const steamIdMatch = claimedId.match(/(\d+)$/);
|
||||
|
||||
if (!steamIdMatch) {
|
||||
return reply.status(400).send({
|
||||
error: "AuthenticationError",
|
||||
message: "Could not extract Steam ID",
|
||||
});
|
||||
}
|
||||
|
||||
const steamId = steamIdMatch[1];
|
||||
|
||||
// Get user profile from Steam API
|
||||
const steamApiUrl = `http://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/?key=${config.steam.apiKey}&steamids=${steamId}`;
|
||||
const profileResponse = await fetch(steamApiUrl);
|
||||
const profileData = await profileResponse.json();
|
||||
|
||||
if (
|
||||
!profileData.response ||
|
||||
!profileData.response.players ||
|
||||
profileData.response.players.length === 0
|
||||
) {
|
||||
return reply.status(400).send({
|
||||
error: "AuthenticationError",
|
||||
message: "Could not fetch Steam profile",
|
||||
});
|
||||
}
|
||||
|
||||
const profile = profileData.response.players[0];
|
||||
|
||||
// Import User model
|
||||
const User = (await import("../models/User.js")).default;
|
||||
|
||||
// Find or create user
|
||||
let user = await User.findOne({ steamId });
|
||||
|
||||
if (user) {
|
||||
// Update existing user
|
||||
user.username = profile.personaname;
|
||||
user.avatar =
|
||||
profile.avatarfull || profile.avatarmedium || profile.avatar;
|
||||
user.communityvisibilitystate = profile.communityvisibilitystate;
|
||||
await user.save();
|
||||
console.log(
|
||||
`✅ Existing user logged in: ${user.username} (${steamId})`
|
||||
);
|
||||
} else {
|
||||
// Create new user
|
||||
user = new User({
|
||||
username: profile.personaname,
|
||||
steamId: steamId,
|
||||
avatar: profile.avatarfull || profile.avatarmedium || profile.avatar,
|
||||
account_creation:
|
||||
profile.timecreated || Math.floor(Date.now() / 1000),
|
||||
communityvisibilitystate: profile.communityvisibilitystate,
|
||||
balance: 0,
|
||||
staffLevel: 0,
|
||||
});
|
||||
await user.save();
|
||||
console.log(`✅ New user registered: ${user.username} (${steamId})`);
|
||||
}
|
||||
|
||||
// Generate JWT tokens
|
||||
const { accessToken, refreshToken } = generateTokenPair(user);
|
||||
|
||||
// Extract device information
|
||||
const userAgent = request.headers["user-agent"] || "Unknown";
|
||||
const ip =
|
||||
request.ip ||
|
||||
request.headers["x-forwarded-for"] ||
|
||||
request.headers["x-real-ip"] ||
|
||||
"Unknown";
|
||||
|
||||
// Simple device detection
|
||||
let device = "Desktop";
|
||||
let browser = "Unknown";
|
||||
let os = "Unknown";
|
||||
|
||||
if (
|
||||
userAgent.includes("Mobile") ||
|
||||
userAgent.includes("Android") ||
|
||||
userAgent.includes("iPhone")
|
||||
) {
|
||||
device = "Mobile";
|
||||
} else if (userAgent.includes("Tablet") || userAgent.includes("iPad")) {
|
||||
device = "Tablet";
|
||||
}
|
||||
|
||||
if (userAgent.includes("Chrome")) browser = "Chrome";
|
||||
else if (userAgent.includes("Firefox")) browser = "Firefox";
|
||||
else if (userAgent.includes("Safari")) browser = "Safari";
|
||||
else if (userAgent.includes("Edge")) browser = "Edge";
|
||||
|
||||
if (userAgent.includes("Windows")) os = "Windows";
|
||||
else if (userAgent.includes("Mac")) os = "macOS";
|
||||
else if (userAgent.includes("Linux")) os = "Linux";
|
||||
else if (userAgent.includes("Android")) os = "Android";
|
||||
else if (userAgent.includes("iOS") || userAgent.includes("iPhone"))
|
||||
os = "iOS";
|
||||
|
||||
// Create session
|
||||
try {
|
||||
const session = new Session({
|
||||
userId: user._id,
|
||||
steamId: user.steamId,
|
||||
token: accessToken,
|
||||
refreshToken: refreshToken,
|
||||
ip: ip,
|
||||
userAgent: userAgent,
|
||||
device: device,
|
||||
browser: browser,
|
||||
os: os,
|
||||
location: {
|
||||
country: null, // Can integrate with IP geolocation service
|
||||
city: null,
|
||||
region: null,
|
||||
},
|
||||
isActive: true,
|
||||
lastActivity: new Date(),
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
|
||||
});
|
||||
|
||||
await session.save();
|
||||
console.log(
|
||||
`📱 Session created for ${user.username} from ${ip} (${device}/${browser})`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to create session:", error);
|
||||
// Don't fail login if session creation fails
|
||||
}
|
||||
|
||||
// Set cookies
|
||||
reply
|
||||
.setCookie("accessToken", accessToken, {
|
||||
httpOnly: true,
|
||||
secure: config.cookie.secure,
|
||||
sameSite: config.cookie.sameSite,
|
||||
path: "/",
|
||||
maxAge: 15 * 60,
|
||||
domain: config.cookie.domain,
|
||||
})
|
||||
.setCookie("refreshToken", refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: config.cookie.secure,
|
||||
sameSite: config.cookie.sameSite,
|
||||
path: "/",
|
||||
maxAge: 7 * 24 * 60 * 60,
|
||||
domain: config.cookie.domain,
|
||||
});
|
||||
|
||||
console.log(`✅ User ${user.username} logged in successfully`);
|
||||
|
||||
// Redirect to frontend
|
||||
return reply.redirect(`${config.cors.origin}/`);
|
||||
} catch (error) {
|
||||
console.error("❌ Steam callback error:", error);
|
||||
return reply.status(500).send({
|
||||
error: "AuthenticationError",
|
||||
message: "Steam authentication failed",
|
||||
details: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Get current user
|
||||
fastify.get(
|
||||
"/me",
|
||||
{
|
||||
preHandler: authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
// Remove sensitive data
|
||||
const user = request.user.toObject();
|
||||
delete user.twoFactor.secret;
|
||||
delete user.email.emailToken;
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
user: user,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// Decode JWT token (for debugging - shows what's in the token)
|
||||
fastify.get("/decode-token", async (request, reply) => {
|
||||
try {
|
||||
let token = null;
|
||||
|
||||
// Try to get token from Authorization header
|
||||
const authHeader = request.headers.authorization;
|
||||
if (authHeader && authHeader.startsWith("Bearer ")) {
|
||||
token = authHeader.substring(7);
|
||||
}
|
||||
|
||||
// If not in header, try cookies
|
||||
if (!token && request.cookies && request.cookies.accessToken) {
|
||||
token = request.cookies.accessToken;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return reply.status(400).send({
|
||||
error: "NoToken",
|
||||
message: "No token provided. Send in Authorization header or cookie.",
|
||||
});
|
||||
}
|
||||
|
||||
// Import JWT utilities
|
||||
const { decodeToken } = await import("../utils/jwt.js");
|
||||
const decoded = decodeToken(token);
|
||||
|
||||
if (!decoded) {
|
||||
return reply.status(400).send({
|
||||
error: "InvalidToken",
|
||||
message: "Could not decode token",
|
||||
});
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
decoded: decoded,
|
||||
message: "This shows what data is stored in your JWT token",
|
||||
});
|
||||
} catch (error) {
|
||||
return reply.status(500).send({
|
||||
error: "DecodeError",
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Refresh access token using refresh token
|
||||
fastify.post(
|
||||
"/refresh",
|
||||
{
|
||||
preHandler: verifyRefreshTokenMiddleware,
|
||||
},
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const user = request.user;
|
||||
|
||||
// Generate new token pair
|
||||
const { accessToken, refreshToken } = generateTokenPair(user);
|
||||
|
||||
// Update existing session with new tokens
|
||||
try {
|
||||
const oldSession = await Session.findOne({
|
||||
refreshToken: request.refreshToken,
|
||||
userId: user._id,
|
||||
});
|
||||
|
||||
if (oldSession) {
|
||||
oldSession.token = accessToken;
|
||||
oldSession.refreshToken = refreshToken;
|
||||
oldSession.lastActivity = new Date();
|
||||
await oldSession.save();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to update session:", error);
|
||||
}
|
||||
|
||||
// Set new cookies
|
||||
reply
|
||||
.setCookie("accessToken", accessToken, {
|
||||
httpOnly: true,
|
||||
secure: config.cookie.secure,
|
||||
sameSite: config.cookie.sameSite,
|
||||
path: "/",
|
||||
maxAge: 15 * 60, // 15 minutes
|
||||
domain: config.cookie.domain,
|
||||
})
|
||||
.setCookie("refreshToken", refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: config.cookie.secure,
|
||||
sameSite: config.cookie.sameSite,
|
||||
path: "/",
|
||||
maxAge: 7 * 24 * 60 * 60, // 7 days
|
||||
domain: config.cookie.domain,
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: "Tokens refreshed successfully",
|
||||
accessToken,
|
||||
refreshToken,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Token refresh error:", error);
|
||||
return reply.status(500).send({
|
||||
error: "InternalServerError",
|
||||
message: "Failed to refresh tokens",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Logout - clear cookies and invalidate session
|
||||
fastify.post(
|
||||
"/logout",
|
||||
{
|
||||
preHandler: authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
try {
|
||||
// Deactivate current session
|
||||
try {
|
||||
const session = await Session.findOne({
|
||||
token: request.token,
|
||||
userId: request.user._id,
|
||||
});
|
||||
|
||||
if (session) {
|
||||
await session.deactivate();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to deactivate session:", error);
|
||||
}
|
||||
|
||||
// Clear cookies
|
||||
reply
|
||||
.clearCookie("accessToken", {
|
||||
path: "/",
|
||||
domain: config.cookie.domain,
|
||||
})
|
||||
.clearCookie("refreshToken", {
|
||||
path: "/",
|
||||
domain: config.cookie.domain,
|
||||
});
|
||||
|
||||
console.log(`👋 User ${request.user.username} logged out`);
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: "Logged out successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Logout error:", error);
|
||||
return reply.status(500).send({
|
||||
error: "InternalServerError",
|
||||
message: "Failed to logout",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Verify access token endpoint (useful for checking if token is still valid)
|
||||
fastify.get(
|
||||
"/verify",
|
||||
{
|
||||
preHandler: authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
return reply.send({
|
||||
success: true,
|
||||
valid: true,
|
||||
userId: request.user._id,
|
||||
steamId: request.user.steamId,
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
495
routes/inventory.js
Normal file
495
routes/inventory.js
Normal file
@@ -0,0 +1,495 @@
|
||||
import axios from "axios";
|
||||
import { authenticate } from "../middleware/auth.js";
|
||||
import Item from "../models/Item.js";
|
||||
import { config } from "../config/index.js";
|
||||
import pricingService from "../services/pricing.js";
|
||||
import marketPriceService from "../services/marketPrice.js";
|
||||
|
||||
/**
|
||||
* Inventory routes for fetching and listing Steam items
|
||||
* @param {FastifyInstance} fastify
|
||||
* @param {Object} options
|
||||
*/
|
||||
export default async function inventoryRoutes(fastify, options) {
|
||||
// GET /inventory/steam - Fetch user's Steam inventory
|
||||
fastify.get(
|
||||
"/steam",
|
||||
{
|
||||
preHandler: authenticate,
|
||||
schema: {
|
||||
querystring: {
|
||||
type: "object",
|
||||
properties: {
|
||||
game: { type: "string", enum: ["cs2", "rust"], default: "cs2" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const { game = "cs2" } = request.query;
|
||||
const steamId = request.user.steamId;
|
||||
|
||||
if (!steamId) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
message: "Steam ID not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Map game to Steam app ID
|
||||
const appIds = {
|
||||
cs2: 730, // Counter-Strike 2
|
||||
rust: 252490, // Rust
|
||||
};
|
||||
|
||||
const appId = appIds[game];
|
||||
const contextId = 2; // Standard Steam inventory context
|
||||
|
||||
console.log(
|
||||
`🎮 Fetching ${game.toUpperCase()} inventory for Steam ID: ${steamId}`
|
||||
);
|
||||
|
||||
// Get Steam API key from environment (check both possible env var names)
|
||||
const steamApiKey =
|
||||
process.env.STEAM_APIS_KEY ||
|
||||
process.env.STEAM_API_KEY ||
|
||||
config.steam?.apiKey;
|
||||
|
||||
if (!steamApiKey) {
|
||||
console.error("❌ STEAM_API_KEY or STEAM_APIS_KEY not configured");
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
message: "Steam API is not configured. Please contact support.",
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch from SteamAPIs.com
|
||||
const steamApiUrl = `https://api.steamapis.com/steam/inventory/${steamId}/${appId}/${contextId}`;
|
||||
|
||||
console.log(`📡 Calling: ${steamApiUrl}`);
|
||||
|
||||
const response = await axios.get(steamApiUrl, {
|
||||
params: {
|
||||
api_key: steamApiKey,
|
||||
},
|
||||
timeout: 15000,
|
||||
headers: {
|
||||
"User-Agent": "TurboTrades/1.0",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.data || !response.data.assets) {
|
||||
console.log("⚠️ Empty inventory or private profile");
|
||||
return reply.send({
|
||||
success: true,
|
||||
items: [],
|
||||
message: "Inventory is empty or private",
|
||||
});
|
||||
}
|
||||
|
||||
const { assets, descriptions } = response.data;
|
||||
|
||||
// Create a map of descriptions for quick lookup
|
||||
const descMap = new Map();
|
||||
descriptions.forEach((desc) => {
|
||||
const key = `${desc.classid}_${desc.instanceid}`;
|
||||
descMap.set(key, desc);
|
||||
});
|
||||
|
||||
// Process items
|
||||
const items = assets
|
||||
.map((asset) => {
|
||||
const key = `${asset.classid}_${asset.instanceid}`;
|
||||
const desc = descMap.get(key);
|
||||
|
||||
if (!desc) return null;
|
||||
|
||||
// Parse item details
|
||||
const item = {
|
||||
assetid: asset.assetid,
|
||||
classid: asset.classid,
|
||||
instanceid: asset.instanceid,
|
||||
name: desc.market_hash_name || desc.name || "Unknown Item",
|
||||
type: desc.type || "",
|
||||
image: desc.icon_url
|
||||
? `https://community.cloudflare.steamstatic.com/economy/image/${desc.icon_url}`
|
||||
: null,
|
||||
nameColor: desc.name_color || null,
|
||||
backgroundColor: desc.background_color || null,
|
||||
marketable: desc.marketable === 1,
|
||||
tradable: desc.tradable === 1,
|
||||
commodity: desc.commodity === 1,
|
||||
tags: desc.tags || [],
|
||||
descriptions: desc.descriptions || [],
|
||||
};
|
||||
|
||||
// Extract rarity from tags
|
||||
const rarityTag = item.tags.find(
|
||||
(tag) => tag.category === "Rarity"
|
||||
);
|
||||
if (rarityTag) {
|
||||
item.rarity =
|
||||
rarityTag.internal_name || rarityTag.localized_tag_name;
|
||||
}
|
||||
|
||||
// Extract exterior (wear) from tags for CS2
|
||||
if (game === "cs2") {
|
||||
const exteriorTag = item.tags.find(
|
||||
(tag) => tag.category === "Exterior"
|
||||
);
|
||||
if (exteriorTag) {
|
||||
const wearMap = {
|
||||
"Factory New": "fn",
|
||||
"Minimal Wear": "mw",
|
||||
"Field-Tested": "ft",
|
||||
"Well-Worn": "ww",
|
||||
"Battle-Scarred": "bs",
|
||||
};
|
||||
item.wear = wearMap[exteriorTag.localized_tag_name] || null;
|
||||
item.wearName = exteriorTag.localized_tag_name;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract category
|
||||
const categoryTag = item.tags.find(
|
||||
(tag) => tag.category === "Type" || tag.category === "Weapon"
|
||||
);
|
||||
if (categoryTag) {
|
||||
item.category =
|
||||
categoryTag.internal_name || categoryTag.localized_tag_name;
|
||||
}
|
||||
|
||||
// Check if StatTrak or Souvenir
|
||||
item.statTrak = item.name.includes("StatTrak™");
|
||||
item.souvenir = item.name.includes("Souvenir");
|
||||
|
||||
// Detect phase for Doppler items
|
||||
const descriptionText = item.descriptions
|
||||
.map((d) => d.value || "")
|
||||
.join(" ");
|
||||
item.phase = pricingService.detectPhase(item.name, descriptionText);
|
||||
|
||||
return item;
|
||||
})
|
||||
.filter((item) => item !== null && item.marketable && item.tradable);
|
||||
|
||||
console.log(`✅ Found ${items.length} marketable items in inventory`);
|
||||
|
||||
// Enrich items with market prices (fast database lookup)
|
||||
console.log(`💰 Adding market prices...`);
|
||||
|
||||
// Get all item names for batch lookup
|
||||
const itemNames = items.map((item) => item.name);
|
||||
console.log(`📋 Looking up prices for ${itemNames.length} items`);
|
||||
console.log(`🎮 Game: ${game}`);
|
||||
console.log(`📝 First 3 item names:`, itemNames.slice(0, 3));
|
||||
|
||||
const priceMap = await marketPriceService.getPrices(itemNames, game);
|
||||
const foundPrices = Object.keys(priceMap).length;
|
||||
console.log(
|
||||
`💰 Found prices for ${foundPrices}/${itemNames.length} items`
|
||||
);
|
||||
|
||||
// Add prices to items
|
||||
const enrichedItems = items.map((item) => ({
|
||||
...item,
|
||||
marketPrice: priceMap[item.name] || null,
|
||||
hasPriceData: !!priceMap[item.name],
|
||||
}));
|
||||
|
||||
// Log items without prices
|
||||
const itemsWithoutPrices = enrichedItems.filter(
|
||||
(item) => !item.marketPrice
|
||||
);
|
||||
if (itemsWithoutPrices.length > 0) {
|
||||
console.log(`⚠️ ${itemsWithoutPrices.length} items without prices:`);
|
||||
itemsWithoutPrices.slice(0, 5).forEach((item) => {
|
||||
console.log(` - ${item.name}`);
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`✅ Prices added to ${enrichedItems.length} items`);
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
items: enrichedItems,
|
||||
total: enrichedItems.length,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("❌ Error fetching Steam inventory:", error.message);
|
||||
console.error("Error details:", error.response?.data || error.message);
|
||||
|
||||
if (error.response?.status === 401) {
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
message: "Steam API authentication failed. Please contact support.",
|
||||
});
|
||||
}
|
||||
|
||||
if (error.response?.status === 403) {
|
||||
return reply.status(403).send({
|
||||
success: false,
|
||||
message:
|
||||
"Steam inventory is private. Please make your inventory public in Steam settings.",
|
||||
});
|
||||
}
|
||||
|
||||
if (error.response?.status === 404) {
|
||||
return reply.status(404).send({
|
||||
success: false,
|
||||
message: "Steam profile not found or inventory is empty.",
|
||||
});
|
||||
}
|
||||
|
||||
if (error.response?.status === 429) {
|
||||
return reply.status(429).send({
|
||||
success: false,
|
||||
message:
|
||||
"Steam API rate limit exceeded. Please try again in a few moments.",
|
||||
});
|
||||
}
|
||||
|
||||
if (error.code === "ECONNABORTED" || error.code === "ETIMEDOUT") {
|
||||
return reply.status(504).send({
|
||||
success: false,
|
||||
message: "Steam API request timed out. Please try again.",
|
||||
});
|
||||
}
|
||||
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
message: "Failed to fetch Steam inventory. Please try again later.",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// POST /inventory/price - Get pricing for items
|
||||
fastify.post(
|
||||
"/price",
|
||||
{
|
||||
preHandler: authenticate,
|
||||
schema: {
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["items"],
|
||||
properties: {
|
||||
items: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
required: ["name"],
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
assetid: { type: "string" },
|
||||
wear: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const { items } = request.body;
|
||||
|
||||
// Use fast market price database for instant lookups
|
||||
const pricedItems = await Promise.all(
|
||||
items.map(async (item) => {
|
||||
try {
|
||||
// Use market price service for instant lookup (<1ms)
|
||||
const marketPrice = await marketPriceService.getPrice(
|
||||
item.name,
|
||||
"cs2" // TODO: Get from request or item
|
||||
);
|
||||
|
||||
return {
|
||||
...item,
|
||||
estimatedPrice: marketPrice,
|
||||
currency: "USD",
|
||||
hasPriceData: marketPrice !== null,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(`Error pricing item ${item.name}:`, err.message);
|
||||
return {
|
||||
...item,
|
||||
estimatedPrice: null,
|
||||
currency: "USD",
|
||||
hasPriceData: false,
|
||||
error: "Price data not available",
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Filter out items without price data
|
||||
const itemsWithPrices = pricedItems.filter(
|
||||
(item) => item.estimatedPrice !== null
|
||||
);
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
items: itemsWithPrices,
|
||||
total: items.length,
|
||||
priced: itemsWithPrices.length,
|
||||
noPriceData: items.length - itemsWithPrices.length,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error pricing items:", error);
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
message: "Failed to calculate item prices",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// POST /inventory/sell - Sell items to the site
|
||||
fastify.post(
|
||||
"/sell",
|
||||
{
|
||||
preHandler: authenticate,
|
||||
schema: {
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["items"],
|
||||
properties: {
|
||||
items: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
required: ["assetid", "name", "price", "image"],
|
||||
properties: {
|
||||
assetid: { type: "string" },
|
||||
name: { type: "string" },
|
||||
price: { type: "number" },
|
||||
image: { type: "string" },
|
||||
wear: { type: "string" },
|
||||
rarity: { type: "string" },
|
||||
category: { type: "string" },
|
||||
statTrak: { type: "boolean" },
|
||||
souvenir: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const { items } = request.body;
|
||||
const userId = request.user._id;
|
||||
const steamId = request.user.steamId;
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
message: "No items selected",
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate total value
|
||||
const totalValue = items.reduce((sum, item) => sum + item.price, 0);
|
||||
|
||||
// Add items to marketplace
|
||||
const createdItems = [];
|
||||
for (const item of items) {
|
||||
// Determine game based on item characteristics
|
||||
const game = item.category?.includes("Rust") ? "rust" : "cs2";
|
||||
|
||||
// Map category
|
||||
let categoryMapped = "other";
|
||||
if (item.category) {
|
||||
const cat = item.category.toLowerCase();
|
||||
if (cat.includes("rifle")) categoryMapped = "rifles";
|
||||
else if (cat.includes("pistol")) categoryMapped = "pistols";
|
||||
else if (cat.includes("knife")) categoryMapped = "knives";
|
||||
else if (cat.includes("glove")) categoryMapped = "gloves";
|
||||
else if (cat.includes("smg")) categoryMapped = "smgs";
|
||||
else if (cat.includes("sticker")) categoryMapped = "stickers";
|
||||
}
|
||||
|
||||
// Map rarity
|
||||
let rarityMapped = "common";
|
||||
if (item.rarity) {
|
||||
const rar = item.rarity.toLowerCase();
|
||||
if (rar.includes("contraband") || rar.includes("ancient"))
|
||||
rarityMapped = "exceedingly";
|
||||
else if (rar.includes("covert") || rar.includes("legendary"))
|
||||
rarityMapped = "legendary";
|
||||
else if (rar.includes("classified") || rar.includes("mythical"))
|
||||
rarityMapped = "mythical";
|
||||
else if (rar.includes("restricted") || rar.includes("rare"))
|
||||
rarityMapped = "rare";
|
||||
else if (rar.includes("mil-spec") || rar.includes("uncommon"))
|
||||
rarityMapped = "uncommon";
|
||||
}
|
||||
|
||||
const newItem = new Item({
|
||||
name: item.name,
|
||||
description: `Listed from Steam inventory`,
|
||||
image: item.image,
|
||||
game: game,
|
||||
category: categoryMapped,
|
||||
rarity: rarityMapped,
|
||||
wear: item.wear || null,
|
||||
statTrak: item.statTrak || false,
|
||||
souvenir: item.souvenir || false,
|
||||
price: item.price,
|
||||
seller: userId,
|
||||
status: "active",
|
||||
featured: false,
|
||||
});
|
||||
|
||||
await newItem.save();
|
||||
createdItems.push(newItem);
|
||||
}
|
||||
|
||||
// Update user balance
|
||||
request.user.balance += totalValue;
|
||||
await request.user.save();
|
||||
|
||||
console.log(
|
||||
`✅ User ${request.user.username} sold ${
|
||||
items.length
|
||||
} items for $${totalValue.toFixed(2)}`
|
||||
);
|
||||
|
||||
// Broadcast to WebSocket if available
|
||||
if (fastify.websocketManager) {
|
||||
// Update user's balance
|
||||
fastify.websocketManager.sendToUser(steamId, {
|
||||
type: "balance_update",
|
||||
data: {
|
||||
balance: request.user.balance,
|
||||
},
|
||||
});
|
||||
|
||||
// Broadcast new items to marketplace
|
||||
fastify.websocketManager.broadcastPublic("new_items", {
|
||||
count: createdItems.length,
|
||||
});
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: `Successfully sold ${items.length} item${
|
||||
items.length > 1 ? "s" : ""
|
||||
} for $${totalValue.toFixed(2)}`,
|
||||
itemsListed: createdItems.length,
|
||||
totalEarned: totalValue,
|
||||
newBalance: request.user.balance,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error selling items:", error);
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
message: "Failed to process sale. Please try again.",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
442
routes/market.js
Normal file
442
routes/market.js
Normal file
@@ -0,0 +1,442 @@
|
||||
import Fastify from 'fastify';
|
||||
import Item from '../models/Item.js';
|
||||
import { authenticate, optionalAuthenticate } from '../middleware/auth.js';
|
||||
|
||||
/**
|
||||
* Market routes for browsing and purchasing items
|
||||
* @param {FastifyInstance} fastify
|
||||
* @param {Object} options
|
||||
*/
|
||||
export default async function marketRoutes(fastify, options) {
|
||||
|
||||
// GET /market/items - Browse marketplace items
|
||||
fastify.get('/items', {
|
||||
preHandler: optionalAuthenticate,
|
||||
schema: {
|
||||
querystring: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
page: { type: 'integer', minimum: 1, default: 1 },
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 24 },
|
||||
search: { type: 'string' },
|
||||
game: { type: 'string', enum: ['cs2', 'rust'] },
|
||||
category: { type: 'string' },
|
||||
rarity: { type: 'string' },
|
||||
wear: { type: 'string' },
|
||||
minPrice: { type: 'number', minimum: 0 },
|
||||
maxPrice: { type: 'number', minimum: 0 },
|
||||
sortBy: { type: 'string', enum: ['price_asc', 'price_desc', 'name_asc', 'name_desc', 'date_new', 'date_old'], default: 'date_new' },
|
||||
statTrak: { type: 'boolean' },
|
||||
souvenir: { type: 'boolean' },
|
||||
}
|
||||
}
|
||||
}
|
||||
}, async (request, reply) => {
|
||||
try {
|
||||
const {
|
||||
page = 1,
|
||||
limit = 24,
|
||||
search,
|
||||
game,
|
||||
category,
|
||||
rarity,
|
||||
wear,
|
||||
minPrice,
|
||||
maxPrice,
|
||||
sortBy = 'date_new',
|
||||
statTrak,
|
||||
souvenir,
|
||||
} = request.query;
|
||||
|
||||
// Build query
|
||||
const query = { status: 'active' };
|
||||
|
||||
if (search) {
|
||||
query.$or = [
|
||||
{ name: { $regex: search, $options: 'i' } },
|
||||
{ description: { $regex: search, $options: 'i' } }
|
||||
];
|
||||
}
|
||||
|
||||
if (game) query.game = game;
|
||||
if (category && category !== 'all') query.category = category;
|
||||
if (rarity) query.rarity = rarity;
|
||||
if (wear) query.wear = wear;
|
||||
if (statTrak !== undefined) query.statTrak = statTrak;
|
||||
if (souvenir !== undefined) query.souvenir = souvenir;
|
||||
|
||||
if (minPrice !== undefined || maxPrice !== undefined) {
|
||||
query.price = {};
|
||||
if (minPrice !== undefined) query.price.$gte = minPrice;
|
||||
if (maxPrice !== undefined) query.price.$lte = maxPrice;
|
||||
}
|
||||
|
||||
// Build sort
|
||||
let sort = {};
|
||||
switch (sortBy) {
|
||||
case 'price_asc':
|
||||
sort = { price: 1 };
|
||||
break;
|
||||
case 'price_desc':
|
||||
sort = { price: -1 };
|
||||
break;
|
||||
case 'name_asc':
|
||||
sort = { name: 1 };
|
||||
break;
|
||||
case 'name_desc':
|
||||
sort = { name: -1 };
|
||||
break;
|
||||
case 'date_new':
|
||||
sort = { listedAt: -1 };
|
||||
break;
|
||||
case 'date_old':
|
||||
sort = { listedAt: 1 };
|
||||
break;
|
||||
default:
|
||||
sort = { listedAt: -1 };
|
||||
}
|
||||
|
||||
// Execute query with pagination
|
||||
const skip = (page - 1) * limit;
|
||||
const [items, total] = await Promise.all([
|
||||
Item.find(query)
|
||||
.sort(sort)
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.populate('seller', 'username avatar steamId')
|
||||
.lean(),
|
||||
Item.countDocuments(query)
|
||||
]);
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
items,
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
fastify.log.error(error);
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
message: 'Failed to fetch items',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /market/featured - Get featured items
|
||||
fastify.get('/featured', {
|
||||
preHandler: optionalAuthenticate,
|
||||
}, async (request, reply) => {
|
||||
try {
|
||||
const items = await Item.find({
|
||||
status: 'active',
|
||||
featured: true
|
||||
})
|
||||
.sort({ listedAt: -1 })
|
||||
.limit(12)
|
||||
.populate('seller', 'username avatar steamId')
|
||||
.lean();
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
items,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
fastify.log.error(error);
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
message: 'Failed to fetch featured items',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /market/recent-sales - Get recent sales
|
||||
fastify.get('/recent-sales', {
|
||||
preHandler: optionalAuthenticate,
|
||||
schema: {
|
||||
querystring: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
limit: { type: 'integer', minimum: 1, maximum: 50, default: 10 },
|
||||
}
|
||||
}
|
||||
}
|
||||
}, async (request, reply) => {
|
||||
try {
|
||||
const { limit = 10 } = request.query;
|
||||
|
||||
const sales = await Item.find({ status: 'sold' })
|
||||
.sort({ soldAt: -1 })
|
||||
.limit(limit)
|
||||
.populate('seller', 'username avatar steamId')
|
||||
.populate('buyer', 'username avatar steamId')
|
||||
.lean();
|
||||
|
||||
// Format for frontend
|
||||
const formattedSales = sales.map(sale => ({
|
||||
id: sale._id,
|
||||
itemName: sale.name,
|
||||
itemImage: sale.image,
|
||||
wear: sale.wear,
|
||||
price: sale.price,
|
||||
soldAt: sale.soldAt,
|
||||
seller: sale.seller,
|
||||
buyer: sale.buyer,
|
||||
}));
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
sales: formattedSales,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
fastify.log.error(error);
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
message: 'Failed to fetch recent sales',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /market/items/:id - Get single item details
|
||||
fastify.get('/items/:id', {
|
||||
preHandler: optionalAuthenticate,
|
||||
schema: {
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: {
|
||||
id: { type: 'string' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}, async (request, reply) => {
|
||||
try {
|
||||
const { id } = request.params;
|
||||
|
||||
const item = await Item.findById(id)
|
||||
.populate('seller', 'username avatar steamId')
|
||||
.lean();
|
||||
|
||||
if (!item) {
|
||||
return reply.status(404).send({
|
||||
success: false,
|
||||
message: 'Item not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Increment views (don't await)
|
||||
Item.findByIdAndUpdate(id, { $inc: { views: 1 } }).exec();
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
item,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
fastify.log.error(error);
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
message: 'Failed to fetch item',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /market/purchase/:id - Purchase an item
|
||||
fastify.post('/purchase/:id', {
|
||||
preHandler: authenticate,
|
||||
schema: {
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: {
|
||||
id: { type: 'string' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}, async (request, reply) => {
|
||||
try {
|
||||
const { id } = request.params;
|
||||
const userId = request.user.userId;
|
||||
|
||||
// Find item
|
||||
const item = await Item.findById(id);
|
||||
|
||||
if (!item) {
|
||||
return reply.status(404).send({
|
||||
success: false,
|
||||
message: 'Item not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (item.status !== 'active') {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
message: 'Item is not available for purchase',
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user is trying to buy their own item
|
||||
if (item.seller.toString() === userId) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
message: 'You cannot purchase your own item',
|
||||
});
|
||||
}
|
||||
|
||||
// Get user to check balance
|
||||
const User = fastify.mongoose.model('User');
|
||||
const user = await User.findById(userId);
|
||||
|
||||
if (!user) {
|
||||
return reply.status(404).send({
|
||||
success: false,
|
||||
message: 'User not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (user.balance < item.price) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
message: 'Insufficient balance',
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user has trade URL set
|
||||
if (!user.tradeUrl) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
message: 'Please set your trade URL in profile settings',
|
||||
});
|
||||
}
|
||||
|
||||
// Process purchase (in a transaction would be better)
|
||||
// Deduct from buyer
|
||||
user.balance -= item.price;
|
||||
await user.save();
|
||||
|
||||
// Add to seller
|
||||
const seller = await User.findById(item.seller);
|
||||
if (seller) {
|
||||
seller.balance += item.price;
|
||||
await seller.save();
|
||||
}
|
||||
|
||||
// Mark item as sold
|
||||
await item.markAsSold(userId);
|
||||
|
||||
// TODO: Send trade offer via Steam bot
|
||||
// TODO: Send notifications via WebSocket
|
||||
|
||||
// Broadcast to WebSocket clients
|
||||
if (fastify.websocketManager) {
|
||||
fastify.websocketManager.broadcastPublic('item_sold', {
|
||||
itemId: item._id,
|
||||
itemName: item.name,
|
||||
price: item.price,
|
||||
});
|
||||
|
||||
// Send notification to buyer
|
||||
fastify.websocketManager.sendToUser(user.steamId, {
|
||||
type: 'item_purchased',
|
||||
data: {
|
||||
itemId: item._id,
|
||||
itemName: item.name,
|
||||
price: item.price,
|
||||
}
|
||||
});
|
||||
|
||||
// Send notification to seller
|
||||
if (seller) {
|
||||
fastify.websocketManager.sendToUser(seller.steamId, {
|
||||
type: 'item_sold',
|
||||
data: {
|
||||
itemId: item._id,
|
||||
itemName: item.name,
|
||||
price: item.price,
|
||||
buyer: user.username,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update balance for buyer
|
||||
fastify.websocketManager.sendToUser(user.steamId, {
|
||||
type: 'balance_update',
|
||||
data: {
|
||||
balance: user.balance,
|
||||
}
|
||||
});
|
||||
|
||||
// Update balance for seller
|
||||
if (seller) {
|
||||
fastify.websocketManager.sendToUser(seller.steamId, {
|
||||
type: 'balance_update',
|
||||
data: {
|
||||
balance: seller.balance,
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: 'Purchase successful! You will receive a trade offer shortly.',
|
||||
item,
|
||||
newBalance: user.balance,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
fastify.log.error(error);
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
message: 'Failed to process purchase',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /market/stats - Get marketplace statistics
|
||||
fastify.get('/stats', {
|
||||
preHandler: optionalAuthenticate,
|
||||
}, async (request, reply) => {
|
||||
try {
|
||||
const [
|
||||
totalActive,
|
||||
totalSold,
|
||||
totalValue,
|
||||
averagePrice,
|
||||
] = await Promise.all([
|
||||
Item.countDocuments({ status: 'active' }),
|
||||
Item.countDocuments({ status: 'sold' }),
|
||||
Item.aggregate([
|
||||
{ $match: { status: 'active' } },
|
||||
{ $group: { _id: null, total: { $sum: '$price' } } }
|
||||
]),
|
||||
Item.aggregate([
|
||||
{ $match: { status: 'active' } },
|
||||
{ $group: { _id: null, avg: { $avg: '$price' } } }
|
||||
]),
|
||||
]);
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
stats: {
|
||||
totalActive,
|
||||
totalSold,
|
||||
totalValue: totalValue[0]?.total || 0,
|
||||
averagePrice: averagePrice[0]?.avg || 0,
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
fastify.log.error(error);
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
message: 'Failed to fetch statistics',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
474
routes/marketplace.example.js
Normal file
474
routes/marketplace.example.js
Normal file
@@ -0,0 +1,474 @@
|
||||
import { authenticate, optionalAuthenticate } from "../middleware/auth.js";
|
||||
import { wsManager } from "../utils/websocket.js";
|
||||
|
||||
/**
|
||||
* Example marketplace routes demonstrating WebSocket broadcasting
|
||||
* This shows how to integrate real-time updates for listings, prices, etc.
|
||||
* @param {FastifyInstance} fastify - Fastify instance
|
||||
*/
|
||||
export default async function marketplaceRoutes(fastify, options) {
|
||||
// Get all listings (public endpoint with optional auth for user-specific data)
|
||||
fastify.get(
|
||||
"/marketplace/listings",
|
||||
{
|
||||
preHandler: optionalAuthenticate,
|
||||
schema: {
|
||||
querystring: {
|
||||
type: "object",
|
||||
properties: {
|
||||
game: { type: "string", enum: ["cs2", "rust"] },
|
||||
minPrice: { type: "number" },
|
||||
maxPrice: { type: "number" },
|
||||
search: { type: "string" },
|
||||
page: { type: "number", default: 1 },
|
||||
limit: { type: "number", default: 20, maximum: 100 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const { game, minPrice, maxPrice, search, page = 1, limit = 20 } = request.query;
|
||||
|
||||
// TODO: Implement actual database query
|
||||
// This is a placeholder showing the structure
|
||||
const listings = {
|
||||
items: [
|
||||
{
|
||||
id: "listing_123",
|
||||
itemName: "AK-47 | Redline",
|
||||
game: "cs2",
|
||||
price: 45.99,
|
||||
seller: {
|
||||
steamId: "76561198012345678",
|
||||
username: "TraderPro",
|
||||
},
|
||||
condition: "Field-Tested",
|
||||
float: 0.23,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
],
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: 1,
|
||||
pages: 1,
|
||||
},
|
||||
};
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
listings: listings.items,
|
||||
pagination: listings.pagination,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching listings:", error);
|
||||
return reply.status(500).send({
|
||||
error: "InternalServerError",
|
||||
message: "Failed to fetch listings",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Create new listing (authenticated users only)
|
||||
fastify.post(
|
||||
"/marketplace/listings",
|
||||
{
|
||||
preHandler: authenticate,
|
||||
schema: {
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["itemName", "game", "price"],
|
||||
properties: {
|
||||
itemName: { type: "string" },
|
||||
game: { type: "string", enum: ["cs2", "rust"] },
|
||||
price: { type: "number", minimum: 0.01 },
|
||||
description: { type: "string", maxLength: 500 },
|
||||
assetId: { type: "string" },
|
||||
condition: { type: "string" },
|
||||
float: { type: "number" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const { itemName, game, price, description, assetId, condition, float } = request.body;
|
||||
const user = request.user;
|
||||
|
||||
// Verify user has verified email (optional security check)
|
||||
if (!user.email?.verified) {
|
||||
return reply.status(403).send({
|
||||
error: "Forbidden",
|
||||
message: "Email verification required to create listings",
|
||||
});
|
||||
}
|
||||
|
||||
// Verify user has trade URL set
|
||||
if (!user.tradeUrl) {
|
||||
return reply.status(400).send({
|
||||
error: "ValidationError",
|
||||
message: "Trade URL must be set before creating listings",
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: Implement actual listing creation in database
|
||||
const newListing = {
|
||||
id: `listing_${Date.now()}`,
|
||||
itemName,
|
||||
game,
|
||||
price,
|
||||
description,
|
||||
assetId,
|
||||
condition,
|
||||
float,
|
||||
seller: {
|
||||
id: user._id,
|
||||
steamId: user.steamId,
|
||||
username: user.username,
|
||||
avatar: user.avatar,
|
||||
},
|
||||
status: "active",
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
// Broadcast new listing to all connected clients
|
||||
wsManager.broadcastPublic("new_listing", {
|
||||
listing: newListing,
|
||||
message: `New ${game.toUpperCase()} item listed: ${itemName}`,
|
||||
});
|
||||
|
||||
console.log(`📢 Broadcasted new listing: ${itemName} by ${user.username}`);
|
||||
|
||||
return reply.status(201).send({
|
||||
success: true,
|
||||
message: "Listing created successfully",
|
||||
listing: newListing,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error creating listing:", error);
|
||||
return reply.status(500).send({
|
||||
error: "InternalServerError",
|
||||
message: "Failed to create listing",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Update listing price (seller only)
|
||||
fastify.patch(
|
||||
"/marketplace/listings/:listingId/price",
|
||||
{
|
||||
preHandler: authenticate,
|
||||
schema: {
|
||||
params: {
|
||||
type: "object",
|
||||
required: ["listingId"],
|
||||
properties: {
|
||||
listingId: { type: "string" },
|
||||
},
|
||||
},
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["price"],
|
||||
properties: {
|
||||
price: { type: "number", minimum: 0.01 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const { listingId } = request.params;
|
||||
const { price } = request.body;
|
||||
const user = request.user;
|
||||
|
||||
// TODO: Fetch listing from database and verify ownership
|
||||
// This is a placeholder
|
||||
const listing = {
|
||||
id: listingId,
|
||||
itemName: "AK-47 | Redline",
|
||||
game: "cs2",
|
||||
oldPrice: 45.99,
|
||||
seller: {
|
||||
id: user._id.toString(),
|
||||
steamId: user.steamId,
|
||||
username: user.username,
|
||||
},
|
||||
};
|
||||
|
||||
// Verify user owns the listing
|
||||
if (listing.seller.id !== user._id.toString()) {
|
||||
return reply.status(403).send({
|
||||
error: "Forbidden",
|
||||
message: "You can only update your own listings",
|
||||
});
|
||||
}
|
||||
|
||||
// Update price (TODO: Update in database)
|
||||
const updatedListing = {
|
||||
...listing,
|
||||
price,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// Broadcast price update to all clients
|
||||
wsManager.broadcastPublic("price_update", {
|
||||
listingId,
|
||||
itemName: listing.itemName,
|
||||
oldPrice: listing.oldPrice,
|
||||
newPrice: price,
|
||||
percentChange: ((price - listing.oldPrice) / listing.oldPrice * 100).toFixed(2),
|
||||
});
|
||||
|
||||
console.log(`📢 Broadcasted price update for listing ${listingId}`);
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: "Price updated successfully",
|
||||
listing: updatedListing,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error updating listing price:", error);
|
||||
return reply.status(500).send({
|
||||
error: "InternalServerError",
|
||||
message: "Failed to update listing price",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Purchase item
|
||||
fastify.post(
|
||||
"/marketplace/listings/:listingId/purchase",
|
||||
{
|
||||
preHandler: authenticate,
|
||||
schema: {
|
||||
params: {
|
||||
type: "object",
|
||||
required: ["listingId"],
|
||||
properties: {
|
||||
listingId: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const { listingId } = request.params;
|
||||
const buyer = request.user;
|
||||
|
||||
// TODO: Fetch listing from database
|
||||
const listing = {
|
||||
id: listingId,
|
||||
itemName: "AK-47 | Redline",
|
||||
game: "cs2",
|
||||
price: 45.99,
|
||||
seller: {
|
||||
id: "different_user_id",
|
||||
steamId: "76561198012345678",
|
||||
username: "TraderPro",
|
||||
},
|
||||
status: "active",
|
||||
};
|
||||
|
||||
// Prevent self-purchase
|
||||
if (listing.seller.id === buyer._id.toString()) {
|
||||
return reply.status(400).send({
|
||||
error: "ValidationError",
|
||||
message: "You cannot purchase your own listing",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if listing is still active
|
||||
if (listing.status !== "active") {
|
||||
return reply.status(400).send({
|
||||
error: "ValidationError",
|
||||
message: "This listing is no longer available",
|
||||
});
|
||||
}
|
||||
|
||||
// Check buyer balance
|
||||
if (buyer.balance < listing.price) {
|
||||
return reply.status(400).send({
|
||||
error: "InsufficientFunds",
|
||||
message: `Insufficient balance. Required: $${listing.price}, Available: $${buyer.balance}`,
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: Process transaction in database
|
||||
// - Deduct from buyer balance
|
||||
// - Add to seller balance
|
||||
// - Create transaction record
|
||||
// - Update listing status to "sold"
|
||||
// - Create trade offer via Steam API
|
||||
|
||||
const transaction = {
|
||||
id: `tx_${Date.now()}`,
|
||||
listingId,
|
||||
itemName: listing.itemName,
|
||||
price: listing.price,
|
||||
buyer: {
|
||||
id: buyer._id,
|
||||
steamId: buyer.steamId,
|
||||
username: buyer.username,
|
||||
},
|
||||
seller: listing.seller,
|
||||
status: "processing",
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
// Notify seller via WebSocket
|
||||
wsManager.sendToUser(listing.seller.id, {
|
||||
type: "item_sold",
|
||||
data: {
|
||||
transaction,
|
||||
message: `Your ${listing.itemName} has been sold for $${listing.price}!`,
|
||||
},
|
||||
});
|
||||
|
||||
// Notify buyer
|
||||
wsManager.sendToUser(buyer._id.toString(), {
|
||||
type: "purchase_confirmed",
|
||||
data: {
|
||||
transaction,
|
||||
message: `Purchase confirmed! Trade offer will be sent shortly.`,
|
||||
},
|
||||
});
|
||||
|
||||
// Broadcast listing removal to all clients
|
||||
wsManager.broadcastPublic("listing_sold", {
|
||||
listingId,
|
||||
itemName: listing.itemName,
|
||||
price: listing.price,
|
||||
});
|
||||
|
||||
console.log(`💰 Item sold: ${listing.itemName} - Buyer: ${buyer.username}, Seller: ${listing.seller.username}`);
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: "Purchase successful! Trade offer will be sent to your Steam account.",
|
||||
transaction,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error processing purchase:", error);
|
||||
return reply.status(500).send({
|
||||
error: "InternalServerError",
|
||||
message: "Failed to process purchase",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Delete listing (seller or admin)
|
||||
fastify.delete(
|
||||
"/marketplace/listings/:listingId",
|
||||
{
|
||||
preHandler: authenticate,
|
||||
schema: {
|
||||
params: {
|
||||
type: "object",
|
||||
required: ["listingId"],
|
||||
properties: {
|
||||
listingId: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const { listingId } = request.params;
|
||||
const user = request.user;
|
||||
|
||||
// TODO: Fetch listing from database
|
||||
const listing = {
|
||||
id: listingId,
|
||||
itemName: "AK-47 | Redline",
|
||||
seller: {
|
||||
id: user._id.toString(),
|
||||
steamId: user.steamId,
|
||||
username: user.username,
|
||||
},
|
||||
};
|
||||
|
||||
// Check permissions (owner or admin)
|
||||
if (listing.seller.id !== user._id.toString() && user.staffLevel < 3) {
|
||||
return reply.status(403).send({
|
||||
error: "Forbidden",
|
||||
message: "You can only delete your own listings",
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: Delete from database
|
||||
|
||||
// Broadcast listing removal
|
||||
wsManager.broadcastPublic("listing_removed", {
|
||||
listingId,
|
||||
itemName: listing.itemName,
|
||||
reason: user.staffLevel >= 3 ? "Removed by admin" : "Removed by seller",
|
||||
});
|
||||
|
||||
console.log(`🗑️ Listing deleted: ${listingId} by ${user.username}`);
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: "Listing deleted successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error deleting listing:", error);
|
||||
return reply.status(500).send({
|
||||
error: "InternalServerError",
|
||||
message: "Failed to delete listing",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get user's own listings
|
||||
fastify.get(
|
||||
"/marketplace/my-listings",
|
||||
{
|
||||
preHandler: authenticate,
|
||||
schema: {
|
||||
querystring: {
|
||||
type: "object",
|
||||
properties: {
|
||||
status: { type: "string", enum: ["active", "sold", "cancelled"] },
|
||||
page: { type: "number", default: 1 },
|
||||
limit: { type: "number", default: 20, maximum: 100 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const { status, page = 1, limit = 20 } = request.query;
|
||||
const user = request.user;
|
||||
|
||||
// TODO: Fetch user's listings from database
|
||||
const listings = {
|
||||
items: [],
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: 0,
|
||||
pages: 0,
|
||||
},
|
||||
};
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
listings: listings.items,
|
||||
pagination: listings.pagination,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching user listings:", error);
|
||||
return reply.status(500).send({
|
||||
error: "InternalServerError",
|
||||
message: "Failed to fetch your listings",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
824
routes/user.js
Normal file
824
routes/user.js
Normal file
@@ -0,0 +1,824 @@
|
||||
import { authenticate, requireVerifiedEmail } from "../middleware/auth.js";
|
||||
import User from "../models/User.js";
|
||||
import speakeasy from "speakeasy";
|
||||
import qrcode from "qrcode";
|
||||
import { sendVerificationEmail, send2FASetupEmail } from "../utils/email.js";
|
||||
|
||||
/**
|
||||
* User routes for profile and settings management
|
||||
* @param {FastifyInstance} fastify - Fastify instance
|
||||
*/
|
||||
export default async function userRoutes(fastify, options) {
|
||||
// Get user profile
|
||||
fastify.get(
|
||||
"/profile",
|
||||
{
|
||||
preHandler: authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const user = request.user.toObject();
|
||||
|
||||
// Remove sensitive data
|
||||
delete user.twoFactor.secret;
|
||||
delete user.email.emailToken;
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
user: user,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// Update trade URL (PATCH method)
|
||||
fastify.patch(
|
||||
"/trade-url",
|
||||
{
|
||||
preHandler: authenticate,
|
||||
schema: {
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["tradeUrl"],
|
||||
properties: {
|
||||
tradeUrl: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const { tradeUrl } = request.body;
|
||||
|
||||
// Basic validation for Steam trade URL
|
||||
const tradeUrlRegex =
|
||||
/^https?:\/\/steamcommunity\.com\/tradeoffer\/new\/\?partner=\d+&token=[a-zA-Z0-9_-]+$/;
|
||||
|
||||
if (!tradeUrlRegex.test(tradeUrl)) {
|
||||
return reply.status(400).send({
|
||||
error: "ValidationError",
|
||||
message: "Invalid Steam trade URL format",
|
||||
});
|
||||
}
|
||||
|
||||
request.user.tradeUrl = tradeUrl;
|
||||
await request.user.save();
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: "Trade URL updated successfully",
|
||||
tradeUrl: request.user.tradeUrl,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error updating trade URL:", error);
|
||||
return reply.status(500).send({
|
||||
error: "InternalServerError",
|
||||
message: "Failed to update trade URL",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Update trade URL (PUT method) - same as PATCH for convenience
|
||||
fastify.put(
|
||||
"/trade-url",
|
||||
{
|
||||
preHandler: authenticate,
|
||||
schema: {
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["tradeUrl"],
|
||||
properties: {
|
||||
tradeUrl: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const { tradeUrl } = request.body;
|
||||
|
||||
// Basic validation for Steam trade URL
|
||||
const tradeUrlRegex =
|
||||
/^https?:\/\/steamcommunity\.com\/tradeoffer\/new\/\?partner=\d+&token=[a-zA-Z0-9_-]+$/;
|
||||
|
||||
if (!tradeUrlRegex.test(tradeUrl)) {
|
||||
return reply.status(400).send({
|
||||
error: "ValidationError",
|
||||
message: "Invalid Steam trade URL format",
|
||||
});
|
||||
}
|
||||
|
||||
request.user.tradeUrl = tradeUrl;
|
||||
await request.user.save();
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: "Trade URL updated successfully",
|
||||
tradeUrl: request.user.tradeUrl,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error updating trade URL:", error);
|
||||
return reply.status(500).send({
|
||||
error: "InternalServerError",
|
||||
message: "Failed to update trade URL",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Update email
|
||||
fastify.patch(
|
||||
"/email",
|
||||
{
|
||||
preHandler: authenticate,
|
||||
schema: {
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["email"],
|
||||
properties: {
|
||||
email: { type: "string", format: "email" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const { email } = request.body;
|
||||
|
||||
// Basic email validation
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
return reply.status(400).send({
|
||||
error: "ValidationError",
|
||||
message: "Invalid email format",
|
||||
});
|
||||
}
|
||||
|
||||
// Generate verification token
|
||||
const emailToken =
|
||||
Math.random().toString(36).substring(2, 15) +
|
||||
Math.random().toString(36).substring(2, 15);
|
||||
|
||||
request.user.email = {
|
||||
address: email,
|
||||
verified: false,
|
||||
emailToken: emailToken,
|
||||
};
|
||||
|
||||
await request.user.save();
|
||||
|
||||
// Send verification email
|
||||
try {
|
||||
await sendVerificationEmail(email, request.user.username, emailToken);
|
||||
console.log(`📧 Verification email sent to ${email}`);
|
||||
} catch (error) {
|
||||
console.error("Failed to send verification email:", error);
|
||||
// Don't fail the request if email sending fails
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
message:
|
||||
"Email updated. Please check your inbox for verification link.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error updating email:", error);
|
||||
return reply.status(500).send({
|
||||
error: "InternalServerError",
|
||||
message: "Failed to update email",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Verify email
|
||||
fastify.get("/verify-email/:token", async (request, reply) => {
|
||||
try {
|
||||
const { token } = request.params;
|
||||
|
||||
const User = (await import("../models/User.js")).default;
|
||||
const user = await User.findOne({ "email.emailToken": token });
|
||||
|
||||
if (!user) {
|
||||
return reply.status(404).send({
|
||||
error: "NotFound",
|
||||
message: "Invalid verification token",
|
||||
});
|
||||
}
|
||||
|
||||
user.email.verified = true;
|
||||
user.email.emailToken = null;
|
||||
await user.save();
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: "Email verified successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error verifying email:", error);
|
||||
return reply.status(500).send({
|
||||
error: "InternalServerError",
|
||||
message: "Failed to verify email",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Get user balance
|
||||
fastify.get(
|
||||
"/balance",
|
||||
{
|
||||
preHandler: authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
return reply.send({
|
||||
success: true,
|
||||
balance: request.user.balance || 0,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// Get user stats
|
||||
fastify.get(
|
||||
"/stats",
|
||||
{
|
||||
preHandler: authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
try {
|
||||
// TODO: Implement actual stats calculations from orders/trades
|
||||
const stats = {
|
||||
balance: request.user.balance || 0,
|
||||
totalSpent: 0,
|
||||
totalEarned: 0,
|
||||
totalTrades: 0,
|
||||
accountAge: Date.now() - new Date(request.user.createdAt).getTime(),
|
||||
verified: {
|
||||
email: request.user.email?.verified || false,
|
||||
twoFactor: request.user.twoFactor?.enabled || false,
|
||||
},
|
||||
};
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
stats: stats,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching user stats:", error);
|
||||
return reply.status(500).send({
|
||||
error: "InternalServerError",
|
||||
message: "Failed to fetch user stats",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Update intercom ID
|
||||
fastify.patch(
|
||||
"/intercom",
|
||||
{
|
||||
preHandler: authenticate,
|
||||
schema: {
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["intercom"],
|
||||
properties: {
|
||||
intercom: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const { intercom } = request.body;
|
||||
|
||||
request.user.intercom = intercom;
|
||||
await request.user.save();
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: "Intercom ID updated successfully",
|
||||
intercom: request.user.intercom,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error updating intercom:", error);
|
||||
return reply.status(500).send({
|
||||
error: "InternalServerError",
|
||||
message: "Failed to update intercom ID",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get public user profile (for viewing other users)
|
||||
fastify.get("/:steamId", async (request, reply) => {
|
||||
try {
|
||||
const { steamId } = request.params;
|
||||
|
||||
const User = (await import("../models/User.js")).default;
|
||||
const user = await User.findOne({ steamId }).select(
|
||||
"username steamId avatar account_creation communityvisibilitystate staffLevel createdAt"
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
return reply.status(404).send({
|
||||
error: "NotFound",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
user: user,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching user:", error);
|
||||
return reply.status(500).send({
|
||||
error: "InternalServerError",
|
||||
message: "Failed to fetch user",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== 2FA ROUTES ====================
|
||||
|
||||
// Setup 2FA - Generate QR code and secret
|
||||
fastify.post(
|
||||
"/2fa/setup",
|
||||
{
|
||||
preHandler: authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
try {
|
||||
// Check if 2FA is already enabled
|
||||
if (request.user.twoFactor?.enabled) {
|
||||
return reply.status(400).send({
|
||||
error: "BadRequest",
|
||||
message: "Two-factor authentication is already enabled",
|
||||
});
|
||||
}
|
||||
|
||||
// Generate secret
|
||||
const secret = speakeasy.generateSecret({
|
||||
name: `TurboTrades (${request.user.username})`,
|
||||
issuer: "TurboTrades",
|
||||
});
|
||||
|
||||
// Generate revocation code (for recovery)
|
||||
const revocationCode = Math.random()
|
||||
.toString(36)
|
||||
.substring(2, 10)
|
||||
.toUpperCase();
|
||||
|
||||
// Generate QR code
|
||||
const qrCodeUrl = await qrcode.toDataURL(secret.otpauth_url);
|
||||
|
||||
// Save to user (but don't enable yet - need verification)
|
||||
request.user.twoFactor = {
|
||||
enabled: false,
|
||||
secret: secret.base32,
|
||||
qrCode: qrCodeUrl,
|
||||
revocationCode: revocationCode,
|
||||
};
|
||||
await request.user.save();
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
secret: secret.base32,
|
||||
qrCode: qrCodeUrl,
|
||||
revocationCode: revocationCode,
|
||||
message:
|
||||
"Scan the QR code with your authenticator app and verify with a code",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error setting up 2FA:", error);
|
||||
return reply.status(500).send({
|
||||
error: "InternalServerError",
|
||||
message: "Failed to setup 2FA",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Verify 2FA code and enable 2FA
|
||||
fastify.post(
|
||||
"/2fa/verify",
|
||||
{
|
||||
preHandler: authenticate,
|
||||
schema: {
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["token"],
|
||||
properties: {
|
||||
token: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const { token } = request.body;
|
||||
|
||||
console.log(
|
||||
"🔐 2FA Verify - Starting verification for user:",
|
||||
request.user._id
|
||||
);
|
||||
|
||||
// Refresh user data to get the latest 2FA secret
|
||||
const freshUser = await User.findById(request.user._id);
|
||||
|
||||
if (!freshUser) {
|
||||
console.error("❌ 2FA Verify - User not found:", request.user._id);
|
||||
return reply.status(401).send({
|
||||
error: "Unauthorized",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
console.log("✅ 2FA Verify - User found:", freshUser.username);
|
||||
console.log(" Has 2FA secret:", !!freshUser.twoFactor?.secret);
|
||||
|
||||
if (!freshUser.twoFactor?.secret) {
|
||||
console.error(
|
||||
"❌ 2FA Verify - No 2FA secret found for user:",
|
||||
freshUser.username
|
||||
);
|
||||
return reply.status(400).send({
|
||||
error: "BadRequest",
|
||||
message: "2FA setup not initiated. Call /2fa/setup first",
|
||||
});
|
||||
}
|
||||
|
||||
console.log("🔍 2FA Verify - Verifying token...");
|
||||
|
||||
// Verify the token
|
||||
const verified = speakeasy.totp.verify({
|
||||
secret: freshUser.twoFactor.secret,
|
||||
encoding: "base32",
|
||||
token: token,
|
||||
window: 2, // Allow 2 time steps before/after
|
||||
});
|
||||
|
||||
console.log(" Token verification result:", verified);
|
||||
|
||||
if (!verified) {
|
||||
console.error("❌ 2FA Verify - Invalid token provided");
|
||||
return reply.status(400).send({
|
||||
error: "InvalidToken",
|
||||
message: "Invalid 2FA code",
|
||||
});
|
||||
}
|
||||
|
||||
console.log("✅ 2FA Verify - Token valid, enabling 2FA...");
|
||||
|
||||
// Enable 2FA
|
||||
freshUser.twoFactor.enabled = true;
|
||||
await freshUser.save();
|
||||
|
||||
console.log("✅ 2FA Verify - 2FA enabled in database");
|
||||
|
||||
// Send confirmation email
|
||||
if (freshUser.email?.address) {
|
||||
try {
|
||||
await send2FASetupEmail(
|
||||
freshUser.email.address,
|
||||
freshUser.username
|
||||
);
|
||||
console.log("✅ 2FA Verify - Confirmation email sent");
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"⚠️ 2FA Verify - Failed to send confirmation email:",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ 2FA enabled for user: ${freshUser.username}`);
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: "Two-factor authentication enabled successfully",
|
||||
revocationCode: freshUser.twoFactor.revocationCode,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("❌ Error verifying 2FA:", error);
|
||||
console.error(" Error name:", error.name);
|
||||
console.error(" Error message:", error.message);
|
||||
console.error(" Error stack:", error.stack);
|
||||
return reply.status(500).send({
|
||||
error: "InternalServerError",
|
||||
message: "Failed to verify 2FA",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Disable 2FA
|
||||
fastify.post(
|
||||
"/2fa/disable",
|
||||
{
|
||||
preHandler: authenticate,
|
||||
schema: {
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["password"],
|
||||
properties: {
|
||||
password: { type: "string" }, // Can be 2FA code or revocation code
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const { password } = request.body;
|
||||
|
||||
if (!request.user.twoFactor?.enabled) {
|
||||
return reply.status(400).send({
|
||||
error: "BadRequest",
|
||||
message: "Two-factor authentication is not enabled",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if password is revocation code or 2FA token
|
||||
const isRevocationCode =
|
||||
password === request.user.twoFactor.revocationCode;
|
||||
const isValidToken = speakeasy.totp.verify({
|
||||
secret: request.user.twoFactor.secret,
|
||||
encoding: "base32",
|
||||
token: password,
|
||||
window: 2,
|
||||
});
|
||||
|
||||
if (!isRevocationCode && !isValidToken) {
|
||||
return reply.status(400).send({
|
||||
error: "InvalidCredentials",
|
||||
message: "Invalid 2FA code or revocation code",
|
||||
});
|
||||
}
|
||||
|
||||
// Disable 2FA
|
||||
request.user.twoFactor = {
|
||||
enabled: false,
|
||||
secret: null,
|
||||
qrCode: null,
|
||||
revocationCode: null,
|
||||
};
|
||||
await request.user.save();
|
||||
|
||||
console.log(`⚠️ 2FA disabled for user: ${request.user.username}`);
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: "Two-factor authentication disabled successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error disabling 2FA:", error);
|
||||
return reply.status(500).send({
|
||||
error: "InternalServerError",
|
||||
message: "Failed to disable 2FA",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ==================== SESSION MANAGEMENT ROUTES ====================
|
||||
|
||||
// Get active sessions
|
||||
fastify.get(
|
||||
"/sessions",
|
||||
{
|
||||
preHandler: authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const Session = (await import("../models/Session.js")).default;
|
||||
const sessions = await Session.getActiveSessions(request.user._id);
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
sessions: sessions.map((s) => ({
|
||||
id: s._id,
|
||||
ip: s.ip,
|
||||
device: s.device,
|
||||
browser: s.browser,
|
||||
os: s.os,
|
||||
location: s.location,
|
||||
lastActivity: s.lastActivity,
|
||||
createdAt: s.createdAt,
|
||||
isCurrent: s.token === request.token, // Mark current session
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching sessions:", error);
|
||||
return reply.status(500).send({
|
||||
error: "InternalServerError",
|
||||
message: "Failed to fetch sessions",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Revoke a specific session
|
||||
fastify.delete(
|
||||
"/sessions/:sessionId",
|
||||
{
|
||||
preHandler: authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const { sessionId } = request.params;
|
||||
const Session = (await import("../models/Session.js")).default;
|
||||
|
||||
const session = await Session.findOne({
|
||||
_id: sessionId,
|
||||
userId: request.user._id,
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return reply.status(404).send({
|
||||
error: "NotFound",
|
||||
message: "Session not found",
|
||||
});
|
||||
}
|
||||
|
||||
await session.deactivate();
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: "Session revoked successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error revoking session:", error);
|
||||
return reply.status(500).send({
|
||||
error: "InternalServerError",
|
||||
message: "Failed to revoke session",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Revoke all sessions except current
|
||||
fastify.post(
|
||||
"/sessions/revoke-all",
|
||||
{
|
||||
preHandler: authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const Session = (await import("../models/Session.js")).default;
|
||||
|
||||
// Find current session
|
||||
const currentSession = await Session.findOne({
|
||||
token: request.token,
|
||||
userId: request.user._id,
|
||||
});
|
||||
|
||||
if (currentSession) {
|
||||
await Session.revokeAllExcept(request.user._id, currentSession._id);
|
||||
} else {
|
||||
await Session.revokeAll(request.user._id);
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: "All other sessions revoked successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error revoking all sessions:", error);
|
||||
return reply.status(500).send({
|
||||
error: "InternalServerError",
|
||||
message: "Failed to revoke sessions",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ==================== TRANSACTION ROUTES ====================
|
||||
|
||||
// Get user's transaction history
|
||||
fastify.get(
|
||||
"/transactions",
|
||||
{
|
||||
preHandler: authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const Transaction = (await import("../models/Transaction.js")).default;
|
||||
|
||||
console.log("📊 Fetching transactions for user:", request.user._id);
|
||||
|
||||
// Get query parameters
|
||||
const { limit = 50, skip = 0, type, status } = request.query;
|
||||
|
||||
const transactions = await Transaction.getUserTransactions(
|
||||
request.user._id,
|
||||
{
|
||||
limit: parseInt(limit),
|
||||
skip: parseInt(skip),
|
||||
type,
|
||||
status,
|
||||
}
|
||||
);
|
||||
|
||||
console.log(`✅ Found ${transactions.length} transactions`);
|
||||
|
||||
// Get user stats
|
||||
const stats = await Transaction.getUserStats(request.user._id);
|
||||
|
||||
console.log("📈 Stats:", stats);
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
transactions: transactions.map((t) => ({
|
||||
id: t._id,
|
||||
type: t.type,
|
||||
status: t.status,
|
||||
amount: t.amount,
|
||||
currency: t.currency,
|
||||
description: t.description,
|
||||
balanceBefore: t.balanceBefore,
|
||||
balanceAfter: t.balanceAfter,
|
||||
sessionIdShort: t.sessionIdShort,
|
||||
device: t.sessionId?.device || null,
|
||||
browser: t.sessionId?.browser || null,
|
||||
os: t.sessionId?.os || null,
|
||||
ip: t.sessionId?.ip || null,
|
||||
itemName: t.itemName,
|
||||
itemImage: t.itemImage,
|
||||
paymentMethod: t.paymentMethod,
|
||||
fee: t.fee,
|
||||
direction: t.direction,
|
||||
createdAt: t.createdAt,
|
||||
completedAt: t.completedAt,
|
||||
})),
|
||||
stats: stats,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("❌ Error fetching transactions:", error);
|
||||
console.error("User ID:", request.user?._id);
|
||||
return reply.status(500).send({
|
||||
error: "InternalServerError",
|
||||
message: "Failed to fetch transactions",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get single transaction details
|
||||
fastify.get(
|
||||
"/transactions/:transactionId",
|
||||
{
|
||||
preHandler: authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const { transactionId } = request.params;
|
||||
const Transaction = (await import("../models/Transaction.js")).default;
|
||||
|
||||
const transaction = await Transaction.findOne({
|
||||
_id: transactionId,
|
||||
userId: request.user._id,
|
||||
}).populate("sessionId", "device browser os ip");
|
||||
|
||||
if (!transaction) {
|
||||
return reply.status(404).send({
|
||||
error: "NotFound",
|
||||
message: "Transaction not found",
|
||||
});
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
transaction: {
|
||||
id: transaction._id,
|
||||
type: transaction.type,
|
||||
status: transaction.status,
|
||||
amount: transaction.amount,
|
||||
currency: transaction.currency,
|
||||
description: transaction.description,
|
||||
balanceBefore: transaction.balanceBefore,
|
||||
balanceAfter: transaction.balanceAfter,
|
||||
sessionIdShort: transaction.sessionIdShort,
|
||||
session: transaction.sessionId,
|
||||
device: transaction.device,
|
||||
ip: transaction.ip,
|
||||
itemName: transaction.itemName,
|
||||
paymentMethod: transaction.paymentMethod,
|
||||
fee: transaction.fee,
|
||||
feePercentage: transaction.feePercentage,
|
||||
direction: transaction.direction,
|
||||
metadata: transaction.metadata,
|
||||
createdAt: transaction.createdAt,
|
||||
completedAt: transaction.completedAt,
|
||||
failedAt: transaction.failedAt,
|
||||
cancelledAt: transaction.cancelledAt,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching transaction:", error);
|
||||
return reply.status(500).send({
|
||||
error: "InternalServerError",
|
||||
message: "Failed to fetch transaction",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
176
routes/websocket.js
Normal file
176
routes/websocket.js
Normal file
@@ -0,0 +1,176 @@
|
||||
import { wsManager } from "../utils/websocket.js";
|
||||
import { authenticate } from "../middleware/auth.js";
|
||||
|
||||
/**
|
||||
* WebSocket routes and handlers
|
||||
* @param {FastifyInstance} fastify - Fastify instance
|
||||
*/
|
||||
export default async function websocketRoutes(fastify, options) {
|
||||
// WebSocket endpoint
|
||||
fastify.get("/ws", { websocket: true }, (connection, request) => {
|
||||
// In @fastify/websocket, the connection parameter IS the WebSocket directly
|
||||
// It has properties like _socket, _readyState, etc.
|
||||
wsManager.handleConnection(connection, request.raw || request);
|
||||
});
|
||||
|
||||
// Get WebSocket stats (authenticated endpoint)
|
||||
fastify.get(
|
||||
"/ws/stats",
|
||||
{
|
||||
preHandler: authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const stats = {
|
||||
totalConnections: wsManager.getTotalSocketCount(),
|
||||
authenticatedUsers: wsManager.getAuthenticatedUserCount(),
|
||||
userConnected: wsManager.isUserConnected(request.user._id.toString()),
|
||||
};
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
stats: stats,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// Broadcast to all users (admin only - requires staff level 3+)
|
||||
fastify.post(
|
||||
"/ws/broadcast",
|
||||
{
|
||||
preHandler: [
|
||||
authenticate,
|
||||
async (request, reply) => {
|
||||
if (request.user.staffLevel < 3) {
|
||||
return reply.status(403).send({
|
||||
error: "Forbidden",
|
||||
message: "Insufficient permissions",
|
||||
});
|
||||
}
|
||||
},
|
||||
],
|
||||
schema: {
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["type", "data"],
|
||||
properties: {
|
||||
type: { type: "string" },
|
||||
data: { type: "object" },
|
||||
excludeUsers: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const { type, data, excludeUsers = [] } = request.body;
|
||||
|
||||
const count = wsManager.broadcastToAll(
|
||||
{
|
||||
type,
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
excludeUsers
|
||||
);
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: `Broadcast sent to ${count} clients`,
|
||||
recipientCount: count,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error broadcasting:", error);
|
||||
return reply.status(500).send({
|
||||
error: "InternalServerError",
|
||||
message: "Failed to broadcast message",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Send message to specific user (admin only)
|
||||
fastify.post(
|
||||
"/ws/send/:userId",
|
||||
{
|
||||
preHandler: [
|
||||
authenticate,
|
||||
async (request, reply) => {
|
||||
if (request.user.staffLevel < 2) {
|
||||
return reply.status(403).send({
|
||||
error: "Forbidden",
|
||||
message: "Insufficient permissions",
|
||||
});
|
||||
}
|
||||
},
|
||||
],
|
||||
schema: {
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["type", "data"],
|
||||
properties: {
|
||||
type: { type: "string" },
|
||||
data: { type: "object" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const { userId } = request.params;
|
||||
const { type, data } = request.body;
|
||||
|
||||
const sent = wsManager.sendToUser(userId, {
|
||||
type,
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
if (!sent) {
|
||||
return reply.status(404).send({
|
||||
error: "NotFound",
|
||||
message: "User not connected",
|
||||
});
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: "Message sent to user",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error sending message:", error);
|
||||
return reply.status(500).send({
|
||||
error: "InternalServerError",
|
||||
message: "Failed to send message",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Check if user is online
|
||||
fastify.get(
|
||||
"/ws/status/:userId",
|
||||
{
|
||||
preHandler: authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const { userId } = request.params;
|
||||
const isConnected = wsManager.isUserConnected(userId);
|
||||
const metadata = wsManager.getUserMetadata(userId);
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
userId: userId,
|
||||
online: isConnected,
|
||||
metadata: metadata || null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error checking user status:", error);
|
||||
return reply.status(500).send({
|
||||
error: "InternalServerError",
|
||||
message: "Failed to check user status",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user