first commit

This commit is contained in:
2026-01-10 04:57:43 +00:00
parent 16a76a2cd6
commit 232968de1e
131 changed files with 43262 additions and 0 deletions

1118
routes/admin.js Normal file

File diff suppressed because it is too large Load Diff

497
routes/auth.js Normal file
View 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
View 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
View 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',
});
}
});
}

View 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
View 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
View 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",
});
}
}
);
}