mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-19 07:08:33 +02:00
The reverse proxy (Nginx Proxy Manager) is containerized/off-host and reaches the app over a non-loopback address, so binding to 127.0.0.1 made play.wowne.ro return 502. Revert the default to bind all interfaces (original behavior). Direct-exposure hardening is a firewall rule + the already-applied X-Forwarded-For rightmost-hop validation, not the bind address. Operators with a same-host proxy can still set HOST=127.0.0.1.
1098 lines
41 KiB
JavaScript
1098 lines
41 KiB
JavaScript
const express = require('express');
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
var app = express();
|
|
// Trust the reverse proxy (for correct req.ip / X-Forwarded-For handling) only when
|
|
// explicitly enabled, so client IPs can't be spoofed in a direct-exposure deployment.
|
|
if (process.env.TRUST_PROXY === 'true') {
|
|
app.set('trust proxy', true);
|
|
}
|
|
var http = require('http').Server(app);
|
|
var io = require('socket.io')(http);
|
|
|
|
// Load environment configuration
|
|
require('dotenv').config();
|
|
|
|
// Import payment system components
|
|
const DatabaseManager = require('./db/databaseManager');
|
|
const WalletRPCService = require('./payments/walletRPCService');
|
|
const PayoutRetryService = require('./payments/payoutRetryService');
|
|
const AlertService = require('./services/alertService');
|
|
const GameModeManager = require('./game/gameModeManager');
|
|
const RpcService = require('./rpc/rpcService');
|
|
const PaymentConfigManager = require('./config/paymentConfig');
|
|
const EnvironmentValidator = require('./config/environmentValidator');
|
|
const asyncHandler = require('./middleware/asyncHandler');
|
|
const createErrorMiddleware = require('./middleware/errorHandler');
|
|
const { AppError, ValidationError, NotFoundError } = require('./utils/errors');
|
|
|
|
// Import modular components
|
|
const BroadcastManager = require('./network/broadcastManager');
|
|
const DebugManager = require('./debug/debugManager');
|
|
const SocketHandlers = require('./network/socketHandlers');
|
|
const { verifyGame, hashSeed } = require('./game/provablyFair');
|
|
const { renderVerifyPage } = require('./views/verifyPage');
|
|
const createAdminRoutes = require('./routes/admin');
|
|
const createAuthRoutes = require('./routes/auth');
|
|
|
|
// Initialize payment configuration
|
|
const paymentConfigManager = new PaymentConfigManager({ logger: console });
|
|
const environmentValidator = new EnvironmentValidator({ logger: console });
|
|
environmentValidator.validate(paymentConfigManager.getConfig());
|
|
process.env.GAME_MODE = paymentConfigManager.getLegacyGameMode();
|
|
|
|
// Initialize modular components first
|
|
const broadcastManager = new BroadcastManager(io);
|
|
const debugManager = new DebugManager(broadcastManager);
|
|
broadcastManager.setDebugManager(debugManager);
|
|
|
|
// Initialize payment system components (debugManager is now available)
|
|
const databaseManager = new DatabaseManager();
|
|
const db = databaseManager; // Alias for convenience in API endpoints
|
|
const rpcService = new RpcService();
|
|
const walletRPCService = new WalletRPCService(debugManager);
|
|
const gameModeManager = new GameModeManager(databaseManager, walletRPCService, debugManager, paymentConfigManager);
|
|
// Provide io reference so GameModeManager can emit events (e.g., credits_update)
|
|
gameModeManager.io = io;
|
|
|
|
// Initialize remaining components
|
|
const activeGames = new Map(); // Maps socketId to Game objects
|
|
let alertService = null; // Initialized later when payment system starts
|
|
let payoutRetryService = null; // Initialized later when payment system starts
|
|
let batchPayoutInterval = null; // Initialized later when payment system starts
|
|
let paymentExpiryInterval = null; // Initialized later when payment system starts
|
|
const socketHandlers = new SocketHandlers(io, activeGames, broadcastManager, debugManager, gameModeManager, walletRPCService);
|
|
// Security headers (defense in depth alongside output escaping).
|
|
// CSP locks down where scripts/styles/connections may come from. Inline scripts/styles
|
|
// are still permitted ('unsafe-inline') because the current pages rely on them heavily;
|
|
// removing that is tracked as Phase 4.4 (nonce-based CSP). connect-src 'self' covers
|
|
// same-origin Socket.IO websockets under CSP Level 3. blob: is limited to render-kit
|
|
// GLB texture decoding; GLTFLoader turns embedded textures into blob URLs.
|
|
app.use((req, res, next) => {
|
|
res.setHeader('Content-Security-Policy', [
|
|
"default-src 'self'",
|
|
"script-src 'self' 'unsafe-inline' https://cdn.socket.io https://cdn.jsdelivr.net",
|
|
"style-src 'self' 'unsafe-inline'",
|
|
"img-src 'self' data: blob:",
|
|
"font-src 'self'",
|
|
"connect-src 'self' blob: https://smirk.cash https://cdn.socket.io https://cdn.jsdelivr.net",
|
|
"object-src 'none'",
|
|
"base-uri 'self'",
|
|
"frame-ancestors 'self'",
|
|
"form-action 'self'"
|
|
].join('; '));
|
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
|
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
next();
|
|
});
|
|
|
|
// Configure static file serving
|
|
const htmlPath = path.join(__dirname, '../html');
|
|
app.use(express.static(htmlPath, {
|
|
etag: true,
|
|
lastModified: true,
|
|
setHeaders: (res, filePath) => {
|
|
if (filePath.endsWith('.html') || filePath.endsWith('.js')) {
|
|
res.setHeader('Cache-Control', 'no-cache');
|
|
}
|
|
}
|
|
}));
|
|
app.use(express.json()); // Parse JSON for API endpoints
|
|
|
|
// Serve main page
|
|
app.get('/', function(req, res) {
|
|
res.sendFile('index.html', { root: htmlPath });
|
|
});
|
|
|
|
// Human-friendly tavern route. The static file remains /tavern.html for direct asset serving.
|
|
app.get(['/tavern', '/tavern/'], function(req, res) {
|
|
res.sendFile('tavern.html', { root: htmlPath });
|
|
});
|
|
|
|
// Serve admin panel
|
|
app.get('/admin', function(req, res) {
|
|
res.sendFile('admin.html', { root: htmlPath });
|
|
});
|
|
|
|
// Debug endpoint to receive client-side debug info
|
|
app.post('/debug', (req, res) => {
|
|
if (debugManager.CONSOLE_LOGGING) {
|
|
console.log('📝 CLIENT DEBUG INFO:', req.body);
|
|
}
|
|
res.json({ received: true, timestamp: Date.now() });
|
|
});
|
|
|
|
// Payment system API endpoints
|
|
app.post('/api/payment/create', asyncHandler(async (req, res) => {
|
|
const { userId, gameMode } = req.body || {};
|
|
if (!userId || !gameMode) {
|
|
throw new ValidationError('Missing userId or gameMode', {
|
|
safeMessage: 'userId and gameMode are required to create a payment.'
|
|
});
|
|
}
|
|
|
|
throw new AppError('REST payment creation endpoint is not implemented for unified payments', {
|
|
statusCode: 501,
|
|
code: 'NOT_IMPLEMENTED',
|
|
safeMessage: 'Payment creation via REST API is not available. Please use the in-game flow.'
|
|
});
|
|
}));
|
|
|
|
const restNotImplemented = () => new AppError('Endpoint not available in unified payment system', {
|
|
statusCode: 501,
|
|
code: 'NOT_IMPLEMENTED',
|
|
safeMessage: 'This API endpoint is not available. Please use the supported in-game flow.'
|
|
});
|
|
|
|
app.post('/api/payment/callback', asyncHandler(async (req, res) => {
|
|
throw restNotImplemented();
|
|
}));
|
|
|
|
app.get('/api/payment/status/:paymentId', asyncHandler(async (req, res) => {
|
|
throw restNotImplemented();
|
|
}));
|
|
|
|
// =============================================================================
|
|
// /api/user/* protection (S1 / S2 / S3, contract C2)
|
|
// =============================================================================
|
|
|
|
// S3: simple in-memory IP rate limiter for the /api/user/* REST surface. Each of these
|
|
// endpoints hits the DB, so cap requests per IP over a short window. Best-effort only (the
|
|
// socket layer has its own limiter and nginx can add another); the map is pruned lazily so
|
|
// it can't grow unbounded.
|
|
const _userApiHits = new Map(); // ip -> { count, windowStart }
|
|
const USER_API_WINDOW_MS = 60 * 1000;
|
|
const USER_API_MAX = Number(process.env.USER_API_RATE_MAX) || 120;
|
|
function userApiRateLimit(req, res, next) {
|
|
const ip = req.ip || req.socket?.remoteAddress || 'unknown';
|
|
const now = Date.now();
|
|
let rec = _userApiHits.get(ip);
|
|
if (!rec || (now - rec.windowStart) > USER_API_WINDOW_MS) {
|
|
rec = { count: 0, windowStart: now };
|
|
_userApiHits.set(ip, rec);
|
|
}
|
|
rec.count += 1;
|
|
if (_userApiHits.size > 5000) {
|
|
for (const [k, v] of _userApiHits) {
|
|
if ((now - v.windowStart) > USER_API_WINDOW_MS) _userApiHits.delete(k);
|
|
}
|
|
}
|
|
if (rec.count > USER_API_MAX) {
|
|
return res.status(429).json({ error: 'Too many requests' });
|
|
}
|
|
next();
|
|
}
|
|
app.use('/api/user', userApiRateLimit);
|
|
|
|
// C2: session-ownership guard for /api/user/:socketId/*. The caller must present the session
|
|
// token (users.anon_token — emitted to the client as the 'session_token'/'session_resumed'
|
|
// event and stored as localStorage['wownerogue_token']) via the 'X-Session-Token' header
|
|
// (fallback ?t=). We only proceed for the row whose socket_id AND anon_token both match, so
|
|
// one client can't read or mutate another player's payments/payouts/address. failStatus is
|
|
// 403 for mutations, 401 for reads.
|
|
function requireSessionOwnership(failStatus) {
|
|
return async (req, res, next) => {
|
|
try {
|
|
const { socketId } = req.params;
|
|
const token = req.get('X-Session-Token') || req.query.t;
|
|
if (!socketId || !token) {
|
|
return res.status(failStatus).json({ error: 'Session token required' });
|
|
}
|
|
const result = await databaseManager.query(
|
|
'SELECT id FROM users WHERE socket_id = $1 AND anon_token = $2',
|
|
[socketId, token]
|
|
);
|
|
if (result.rows.length === 0) {
|
|
return res.status(failStatus).json({ error: 'Session ownership verification failed' });
|
|
}
|
|
req.sessionUserId = result.rows[0].id;
|
|
next();
|
|
} catch (err) {
|
|
console.error('[requireSessionOwnership] verification error:', err.message);
|
|
return res.status(failStatus).json({ error: 'Session verification failed' });
|
|
}
|
|
};
|
|
}
|
|
|
|
app.get('/api/user/:socketId/credits', requireSessionOwnership(401), asyncHandler(async (req, res) => {
|
|
const { socketId } = req.params;
|
|
if (!socketId) {
|
|
throw new ValidationError('Missing socketId', {
|
|
safeMessage: 'socketId parameter is required.'
|
|
});
|
|
}
|
|
|
|
try {
|
|
// C1: read-only route — never mint a user row for a stranger's socketId.
|
|
const user = await gameModeManager.getOrCreateUser(socketId, { create: false });
|
|
if (!user) {
|
|
return res.json({});
|
|
}
|
|
res.json({
|
|
socketId,
|
|
credits: user.credits || 0,
|
|
totalCreditsPurchased: user.total_credits_purchased || 0
|
|
});
|
|
} catch (error) {
|
|
throw new AppError('Failed to retrieve credits', {
|
|
statusCode: 500,
|
|
safeMessage: 'Unable to retrieve credit balance.',
|
|
cause: error
|
|
});
|
|
}
|
|
}));
|
|
|
|
app.get('/api/user/:socketId/mode', requireSessionOwnership(401), asyncHandler(async (req, res) => {
|
|
const { socketId } = req.params;
|
|
if (!socketId) {
|
|
throw new ValidationError('Missing socketId', {
|
|
safeMessage: 'socketId parameter is required.'
|
|
});
|
|
}
|
|
|
|
try {
|
|
// C1: read-only route — do not create a user row here.
|
|
const user = await gameModeManager.getOrCreateUser(socketId, { create: false });
|
|
if (!user) {
|
|
return res.json({});
|
|
}
|
|
res.json({
|
|
socketId,
|
|
preferredPaymentMode: user.preferred_payment_mode || 'direct',
|
|
hasPayoutAddress: !!user.payout_address,
|
|
paymentsEnabled: gameModeManager.paymentsEnabled,
|
|
directModeEnabled: gameModeManager.directModeEnabled,
|
|
creditsModeEnabled: gameModeManager.creditsModeEnabled
|
|
});
|
|
} catch (error) {
|
|
throw new AppError('Failed to retrieve user mode', {
|
|
statusCode: 500,
|
|
safeMessage: 'Unable to retrieve user mode.',
|
|
cause: error
|
|
});
|
|
}
|
|
}));
|
|
|
|
app.post('/api/user/:socketId/address', requireSessionOwnership(403), asyncHandler(async (req, res) => {
|
|
const { socketId } = req.params;
|
|
const { address } = req.body || {};
|
|
|
|
if (!socketId) {
|
|
throw new ValidationError('Missing socketId', {
|
|
safeMessage: 'socketId parameter is required.'
|
|
});
|
|
}
|
|
|
|
if (!address || typeof address !== 'string') {
|
|
throw new ValidationError('Missing or invalid address', {
|
|
safeMessage: 'A valid payout address is required.'
|
|
});
|
|
}
|
|
|
|
// Basic address validation (XMR/WOW addresses)
|
|
const ADDRESS_REGEX = /((?:4|8)[1-9A-HJ-NP-Za-km-z]{90,110}|(?:Wo|WO|ww|WW)[0-9A-Za-z]{88,112}|W[0-9A-Za-z]{90,112})/;
|
|
if (!ADDRESS_REGEX.test(address.trim())) {
|
|
throw new ValidationError('Invalid address format', {
|
|
safeMessage: 'The provided address does not appear to be a valid XMR/WOW address.'
|
|
});
|
|
}
|
|
|
|
try {
|
|
const success = await gameModeManager.setUserPayoutAddress(socketId, address.trim());
|
|
if (!success) {
|
|
throw new AppError('Failed to save address', {
|
|
statusCode: 500,
|
|
safeMessage: 'Unable to save payout address.'
|
|
});
|
|
}
|
|
res.json({
|
|
success: true,
|
|
message: 'Payout address saved successfully.'
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof AppError || error instanceof ValidationError) {
|
|
throw error;
|
|
}
|
|
throw new AppError('Failed to save address', {
|
|
statusCode: 500,
|
|
safeMessage: 'Unable to save payout address.',
|
|
cause: error
|
|
});
|
|
}
|
|
}));
|
|
|
|
// Health check endpoint - enhanced for monitoring dashboard
|
|
app.get('/health', async (req, res) => {
|
|
const memUsage = process.memoryUsage();
|
|
|
|
// Get wallet balance - always attempt
|
|
let walletBalance = null;
|
|
let lowBalanceWarning = false;
|
|
const lowBalanceThreshold = parseInt(process.env.LOW_BALANCE_THRESHOLD) || 100000000000; // 0.1 XMR default
|
|
|
|
try {
|
|
walletBalance = await walletRPCService.getBalance();
|
|
if (walletBalance.error) {
|
|
console.warn('Wallet balance returned error:', walletBalance.error);
|
|
} else {
|
|
lowBalanceWarning = walletBalance.unlocked_balance < lowBalanceThreshold;
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to get wallet balance for health check:', e.message);
|
|
walletBalance = { balance: 0, unlocked_balance: 0, error: e.message };
|
|
}
|
|
|
|
const health = {
|
|
status: 'ok',
|
|
timestamp: new Date().toISOString(),
|
|
uptime: process.uptime(),
|
|
nodeVersion: process.version,
|
|
environment: process.env.NODE_ENV || 'development',
|
|
|
|
// Memory details
|
|
memory: {
|
|
heapUsed: memUsage.heapUsed,
|
|
heapTotal: memUsage.heapTotal,
|
|
rss: memUsage.rss,
|
|
external: memUsage.external || 0
|
|
},
|
|
|
|
// Game statistics
|
|
games: {
|
|
active: activeGames.size,
|
|
queued: socketHandlers?.queueManager?.getQueueLength?.() || 0,
|
|
connected: io.sockets.sockets.size || 0,
|
|
mode: gameModeManager.gameMode
|
|
},
|
|
|
|
// Blockchain
|
|
blockHeight: debugManager.getCurrentBlockHeight(),
|
|
blockSource: process.env.BLOCK_SOURCE || 'daemon',
|
|
network: process.env.MONERO_NETWORK || 'mainnet',
|
|
|
|
// Wallet status
|
|
wallet: {
|
|
available: gameModeManager.paymentsEnabled,
|
|
status: walletRPCService.isHealthy ? 'connected' : 'disconnected',
|
|
endpoint: process.env.PRIMARY_WALLET_ENDPOINT ? '...' + process.env.PRIMARY_WALLET_ENDPOINT.slice(-15) : null,
|
|
pendingPayouts: 0,
|
|
balance: walletBalance,
|
|
lowBalanceWarning
|
|
},
|
|
|
|
// Rate limiter stats (if available)
|
|
rateLimiter: socketHandlers?.rateLimiter ? {
|
|
trackedEntries: socketHandlers.rateLimiter.getTrackedCount?.() || 0,
|
|
blockedCount: socketHandlers.rateLimiter.getBlockedCount?.() || 0
|
|
} : null,
|
|
|
|
// Limits
|
|
limits: {
|
|
maxGamesPerHour: parseInt(process.env.MAX_GAMES_PER_HOUR) || 60,
|
|
maxPayoutsPerDay: parseInt(process.env.MAX_PAYOUTS_PER_DAY) || 100
|
|
},
|
|
|
|
// Legacy fields for compatibility
|
|
gameMode: gameModeManager.gameMode,
|
|
paymentsEnabled: gameModeManager.paymentsEnabled,
|
|
directModeEnabled: gameModeManager.directModeEnabled,
|
|
creditsModeEnabled: gameModeManager.creditsModeEnabled,
|
|
walletHealthy: walletRPCService.isHealthy,
|
|
activeGames: activeGames.size,
|
|
debugMode: debugManager.getDebugStatus().debugMode
|
|
};
|
|
res.json(health);
|
|
});
|
|
|
|
// Get payment options for a user (mixed mode support)
|
|
app.get('/api/user/:socketId/payment-options', requireSessionOwnership(401), asyncHandler(async (req, res) => {
|
|
const { socketId } = req.params;
|
|
if (!socketId) {
|
|
throw new ValidationError('Missing socketId', {
|
|
safeMessage: 'socketId parameter is required.'
|
|
});
|
|
}
|
|
|
|
try {
|
|
const options = await gameModeManager.getPaymentOptionsForUser(socketId);
|
|
res.json(options);
|
|
} catch (error) {
|
|
throw new AppError('Failed to retrieve payment options', {
|
|
statusCode: 500,
|
|
safeMessage: 'Unable to retrieve payment options.',
|
|
cause: error
|
|
});
|
|
}
|
|
}));
|
|
|
|
// Get payment history for a user
|
|
app.get('/api/user/:socketId/payments', requireSessionOwnership(401), asyncHandler(async (req, res) => {
|
|
const { socketId } = req.params;
|
|
const limit = Math.min(parseInt(req.query.limit, 10) || 20, 100);
|
|
const offset = parseInt(req.query.offset, 10) || 0;
|
|
|
|
if (!socketId) {
|
|
throw new ValidationError('Missing socketId', {
|
|
safeMessage: 'socketId parameter is required.'
|
|
});
|
|
}
|
|
|
|
try {
|
|
// C1: read-only route — do not create a user row here.
|
|
const user = await gameModeManager.getOrCreateUser(socketId, { create: false });
|
|
if (!user) {
|
|
return res.json({ payments: [], total: 0, totalPaid: 0, limit, offset });
|
|
}
|
|
const result = await db.query(`
|
|
SELECT
|
|
id,
|
|
payment_type,
|
|
expected_amount,
|
|
status,
|
|
credits_purchased,
|
|
created_at,
|
|
confirmed_at,
|
|
description
|
|
FROM payments
|
|
WHERE user_id = $1
|
|
ORDER BY created_at DESC
|
|
LIMIT $2 OFFSET $3
|
|
`, [user.id, limit, offset]);
|
|
|
|
const countResult = await db.query(
|
|
`SELECT COUNT(*) as total,
|
|
COALESCE(SUM(CASE WHEN status = 'confirmed' THEN expected_amount ELSE 0 END), 0) as total_paid
|
|
FROM payments WHERE user_id = $1`,
|
|
[user.id]
|
|
);
|
|
|
|
const totalPaid = parseInt(countResult.rows[0].total_paid, 10) || 0;
|
|
|
|
res.json({
|
|
payments: result.rows.map(row => ({
|
|
id: row.id,
|
|
type: row.payment_type,
|
|
amount: row.expected_amount,
|
|
amountFormatted: gameModeManager.formatAtomicHuman(row.expected_amount, 4),
|
|
status: row.status,
|
|
creditsReceived: row.credits_purchased || 0,
|
|
createdAt: row.created_at,
|
|
confirmedAt: row.confirmed_at,
|
|
description: row.description
|
|
})),
|
|
total: parseInt(countResult.rows[0].total, 10),
|
|
totalPaid: totalPaid,
|
|
totalPaidFormatted: gameModeManager.formatAtomicHuman(totalPaid, 4),
|
|
limit,
|
|
offset,
|
|
currency: gameModeManager.cryptoType
|
|
});
|
|
} catch (error) {
|
|
throw new AppError('Failed to retrieve payment history', {
|
|
statusCode: 500,
|
|
safeMessage: 'Unable to retrieve payment history.',
|
|
cause: error
|
|
});
|
|
}
|
|
}));
|
|
|
|
// Get payout history for a user
|
|
app.get('/api/user/:socketId/payouts', requireSessionOwnership(401), asyncHandler(async (req, res) => {
|
|
const { socketId } = req.params;
|
|
const limit = Math.min(parseInt(req.query.limit, 10) || 20, 100);
|
|
const offset = parseInt(req.query.offset, 10) || 0;
|
|
|
|
if (!socketId) {
|
|
throw new ValidationError('Missing socketId', {
|
|
safeMessage: 'socketId parameter is required.'
|
|
});
|
|
}
|
|
|
|
try {
|
|
// C1: read-only route — do not create a user row here.
|
|
const user = await gameModeManager.getOrCreateUser(socketId, { create: false });
|
|
if (!user) {
|
|
return res.json({ payouts: [], total: 0, totalReceived: 0, limit, offset });
|
|
}
|
|
const result = await db.query(`
|
|
SELECT
|
|
p.id,
|
|
p.amount,
|
|
p.multiplier,
|
|
p.reason,
|
|
p.status,
|
|
p.tx_hash,
|
|
p.created_at,
|
|
p.processed_at,
|
|
g.outcome as game_outcome
|
|
FROM payouts p
|
|
LEFT JOIN games g ON p.game_id = g.id
|
|
WHERE p.user_id = $1
|
|
ORDER BY p.created_at DESC
|
|
LIMIT $2 OFFSET $3
|
|
`, [user.id, limit, offset]);
|
|
|
|
const countResult = await db.query(
|
|
`SELECT COUNT(*) as total FROM payouts WHERE user_id = $1`,
|
|
[user.id]
|
|
);
|
|
|
|
// Calculate total received (confirmed payouts)
|
|
const totalResult = await db.query(
|
|
`SELECT COALESCE(SUM(amount), 0) as total_received FROM payouts WHERE user_id = $1 AND status = 'completed'`,
|
|
[user.id]
|
|
);
|
|
|
|
res.json({
|
|
payouts: result.rows.map(row => ({
|
|
id: row.id,
|
|
amount: row.amount,
|
|
amountFormatted: gameModeManager.formatAtomicHuman(row.amount, 4),
|
|
multiplier: parseFloat(row.multiplier) || 0,
|
|
reason: row.reason,
|
|
status: row.status,
|
|
txHash: row.tx_hash,
|
|
createdAt: row.created_at,
|
|
processedAt: row.processed_at,
|
|
gameOutcome: row.game_outcome
|
|
})),
|
|
total: parseInt(countResult.rows[0].total, 10),
|
|
totalReceived: parseInt(totalResult.rows[0].total_received, 10),
|
|
totalReceivedFormatted: gameModeManager.formatAtomicHuman(totalResult.rows[0].total_received, 4),
|
|
limit,
|
|
offset,
|
|
currency: gameModeManager.cryptoType
|
|
});
|
|
} catch (error) {
|
|
throw new AppError('Failed to retrieve payout history', {
|
|
statusCode: 500,
|
|
safeMessage: 'Unable to retrieve payout history.',
|
|
cause: error
|
|
});
|
|
}
|
|
}));
|
|
|
|
// =============================================================================
|
|
// Smirk Wallet Authentication Endpoints
|
|
// =============================================================================
|
|
// Smirk is a browser extension wallet for Wownero/Monero (mainnet only).
|
|
// Forced off on any test network (stagenet/testnet), regardless of SMIRK_ENABLED.
|
|
const { isTestNetworkFor } = require('./game/helpers/gameModeUtils');
|
|
const smirkEnabled = process.env.SMIRK_ENABLED !== 'false' && !isTestNetworkFor(process.env.MONERO_NETWORK);
|
|
if (smirkEnabled) {
|
|
app.use(createAuthRoutes({ db: databaseManager }));
|
|
}
|
|
|
|
|
|
// =============================================================================
|
|
// Admin API Endpoints (see src/routes/admin.js) — DI ctx; alertService is set later
|
|
// =============================================================================
|
|
const adminRouteCtx = { db, gameModeManager, walletRPCService, socketHandlers, io, alertService: null };
|
|
app.use(createAdminRoutes(adminRouteCtx));
|
|
|
|
// =============================================================================
|
|
// LEADERBOARD
|
|
// =============================================================================
|
|
app.get('/api/leaderboard', asyncHandler(async (req, res) => {
|
|
const period = req.query.period || 'all';
|
|
const limit = Math.min(parseInt(req.query.limit) || 20, 50);
|
|
|
|
let timeFilter = '';
|
|
if (period === 'week') timeFilter = "AND g.completed_at > NOW() - INTERVAL '7 days'";
|
|
else if (period === 'month') timeFilter = "AND g.completed_at > NOW() - INTERVAL '30 days'";
|
|
|
|
// Per-game leaderboard split (whitelisted -> safe to interpolate):
|
|
// champions = games played with credits/entry-fee (Hall of Champions)
|
|
// pleb = free games (Pleb board)
|
|
// prestige = credit-prestige match races
|
|
// all = everyone (default, backward compatible)
|
|
const board = req.query.board || 'all';
|
|
let boardFilter = '';
|
|
if (board === 'champions') boardFilter = "AND g.game_mode IN ('PAID_SINGLE','PAID_CREDITS')";
|
|
else if (board === 'pleb') boardFilter = "AND g.game_mode = 'FREE'";
|
|
|
|
if (board === 'prestige') {
|
|
const result = await db.query(`
|
|
SELECT
|
|
user_id as id,
|
|
COALESCE(display_name, 'Anon#' || user_id) as name,
|
|
best_score,
|
|
wins,
|
|
races as games_played
|
|
FROM prestige_leaderboard
|
|
ORDER BY best_score DESC
|
|
LIMIT $1
|
|
`, [limit]);
|
|
return res.json({ leaderboard: result.rows, period, board });
|
|
}
|
|
|
|
const result = await db.query(`
|
|
SELECT
|
|
u.id,
|
|
COALESCE(u.display_name,
|
|
CASE WHEN u.payout_address IS NOT NULL
|
|
THEN LEFT(u.payout_address, 4) || '...' || RIGHT(u.payout_address, 4)
|
|
ELSE 'Anon#' || u.id
|
|
END
|
|
) as name,
|
|
MAX(g.score) as best_score,
|
|
COUNT(*) FILTER (WHERE g.status = 'won') as wins,
|
|
COUNT(*) as games_played
|
|
FROM games g
|
|
JOIN users u ON g.user_id = u.id
|
|
WHERE g.status IN ('won', 'lost') AND g.score > 0 ${timeFilter} ${boardFilter}
|
|
GROUP BY u.id, u.display_name, u.payout_address
|
|
ORDER BY best_score DESC
|
|
LIMIT $1
|
|
`, [limit]);
|
|
|
|
res.json({ leaderboard: result.rows, period, board });
|
|
}));
|
|
|
|
// Public "social proof" stats strip: players online, games + escapes in the last 24h, and
|
|
// total paid out. Cached in-memory for 10s so a public, unauthenticated endpoint can't hammer
|
|
// the DB (one query set per 10s regardless of traffic).
|
|
let _statsCache = { at: 0, data: null };
|
|
app.get('/api/stats', asyncHandler(async (req, res) => {
|
|
const now = Date.now();
|
|
const online = io.sockets.sockets.size || io.engine.clientsCount || 0;
|
|
|
|
if (_statsCache.data && (now - _statsCache.at) < 10000) {
|
|
return res.json({ ..._statsCache.data, online });
|
|
}
|
|
|
|
const config = paymentConfigManager.getConfig();
|
|
const decimals = Number(config.currency?.decimals ?? 12);
|
|
const divisor = Number.isFinite(decimals) ? Math.pow(10, decimals) : 1;
|
|
const currencyLabel = gameModeManager.currencyLabel || config.currency?.symbol || 'WOW';
|
|
|
|
let gamesToday = 0, escapesToday = 0, totalPaidOut = 0;
|
|
try {
|
|
const [games, payouts] = await Promise.all([
|
|
db.query(`SELECT COUNT(*) AS games,
|
|
COUNT(*) FILTER (WHERE status = 'won') AS escapes
|
|
FROM games WHERE created_at > NOW() - INTERVAL '24 hours'`),
|
|
// Only count payouts that actually left the wallet.
|
|
db.query(`SELECT COALESCE(SUM(amount), 0) AS total FROM payouts WHERE status = 'completed'`)
|
|
]);
|
|
gamesToday = parseInt(games.rows[0]?.games || 0, 10);
|
|
escapesToday = parseInt(games.rows[0]?.escapes || 0, 10);
|
|
// amount is atomic units (BIGINT); divide for display. Number() is safe at these magnitudes.
|
|
totalPaidOut = Number(payouts.rows[0]?.total || 0) / divisor;
|
|
} catch (e) {
|
|
// Best-effort; serve whatever we have rather than 500 on a public widget.
|
|
}
|
|
|
|
const data = {
|
|
gamesToday,
|
|
escapesToday,
|
|
totalPaidOut,
|
|
currencyLabel,
|
|
// Paid out is only meaningful where payouts are enabled (stagenet / payout instances).
|
|
payoutsEnabled: !!(gameModeManager.directPayoutEnabled || gameModeManager.creditsPayoutEnabled)
|
|
};
|
|
_statsCache = { at: now, data };
|
|
res.json({ ...data, online });
|
|
}));
|
|
|
|
app.get('/api/game-modes', (req, res) => {
|
|
const config = paymentConfigManager.getConfig();
|
|
const decimals = Number(config.currency?.decimals ?? 12);
|
|
const divisor = Number.isFinite(decimals) ? Math.pow(10, decimals) : 1;
|
|
const toDisplay = (value) => {
|
|
if (value === null || value === undefined) {
|
|
return 0;
|
|
}
|
|
const numeric = typeof value === 'bigint' ? Number(value) : Number(value);
|
|
return Number.isFinite(numeric) ? numeric / divisor : 0;
|
|
};
|
|
|
|
const directMode = config.modes.direct;
|
|
const creditsMode = config.modes.credits;
|
|
|
|
// Include hosted by info if configured
|
|
let hostedBy = null;
|
|
if (process.env.HOSTED_BY) {
|
|
let hostname = process.env.HOSTED_BY_NAME;
|
|
if (!hostname) {
|
|
try {
|
|
hostname = new URL(process.env.HOSTED_BY).hostname;
|
|
} catch (e) {
|
|
hostname = process.env.HOSTED_BY; // Use raw value if URL parsing fails
|
|
}
|
|
}
|
|
hostedBy = {
|
|
url: process.env.HOSTED_BY,
|
|
name: hostname
|
|
};
|
|
}
|
|
|
|
res.json({
|
|
// Free play is available when the instance is free-only OR when free play is offered
|
|
// as a choice alongside paid options (FREE_PLAY_ENABLED).
|
|
freePlayEnabled: !!gameModeManager.freePlayEnabled,
|
|
FREE: {
|
|
name: 'Free Play',
|
|
cost: 0,
|
|
payoutMultiplier: 0,
|
|
enabled: !!gameModeManager.freePlayEnabled
|
|
},
|
|
PAID_SINGLE: {
|
|
name: 'Paid Single Game',
|
|
cost: toDisplay(directMode.price),
|
|
enabled: !!directMode.enabled,
|
|
payoutMultiplier: (directMode.enabled && config.payouts.rules.direct.enabled)
|
|
? config.payouts.rules.direct.multipliers
|
|
: 0
|
|
},
|
|
PAID_CREDITS: {
|
|
name: 'Credits Package',
|
|
cost: toDisplay(creditsMode.packages?.[0]?.price ?? 0),
|
|
credits: creditsMode.packages?.[0]?.credits ?? creditsMode.creditsPerGame,
|
|
enabled: !!creditsMode.enabled,
|
|
payoutMultiplier: creditsMode.enabled && config.payouts.rules.credits.enabled
|
|
? config.payouts.rules.credits.multipliers
|
|
: 0
|
|
},
|
|
hostedBy
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// Provably Fair Verification Endpoint
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Verify a game's fairness by checking if the seed matches the commitment hash
|
|
*
|
|
* GET /verify/:gameId - Returns HTML verification page
|
|
* GET /api/verify - Verify seed and commitment (query params: seed, commitment)
|
|
*/
|
|
app.get('/verify/:gameId', asyncHandler(async (req, res) => {
|
|
const { gameId } = req.params;
|
|
|
|
// Try to look up the game in the database
|
|
let gameRecord = null;
|
|
try {
|
|
const result = await databaseManager.query(
|
|
'SELECT dungeon_seed, status, treasure_found, moves_made, duration_seconds, created_at FROM games WHERE dungeon_seed = $1',
|
|
[gameId]
|
|
);
|
|
if (result.rows.length > 0) {
|
|
gameRecord = result.rows[0];
|
|
}
|
|
} catch (err) {
|
|
// Database lookup failed, continue without game record
|
|
}
|
|
|
|
// Return the server-rendered HTML verification page (see src/views/verifyPage.js).
|
|
// Pass an absolute base URL (honours the trusted reverse proxy) + a brand-specific social
|
|
// card image so shared /verify links unfurl with a preview on Twitter/Discord/etc.
|
|
const baseUrl = `${req.protocol}://${req.get('host')}`;
|
|
const ogImage = (process.env.CRYPTO_TYPE === 'XMR') ? 'og-card-xmr.png' : 'og-card-wow.png';
|
|
res.send(renderVerifyPage(gameId, gameRecord, {
|
|
gameName: gameModeManager.gameName,
|
|
baseUrl,
|
|
ogImage
|
|
}));
|
|
}));
|
|
|
|
// API endpoint for programmatic verification
|
|
app.get('/api/verify', (req, res) => {
|
|
const { seed, commitment } = req.query;
|
|
|
|
if (!seed || !commitment) {
|
|
return res.status(400).json({
|
|
valid: false,
|
|
error: 'Missing required parameters: seed and commitment'
|
|
});
|
|
}
|
|
|
|
const result = verifyGame(seed, commitment);
|
|
|
|
// PROVABLY FAIR (Phase 0.2): when the seed matches the commitment, regenerate the
|
|
// dungeon from the seed and return a layout fingerprint so the player can confirm
|
|
// the layout is a deterministic function of the committed seed (not crafted after payment).
|
|
if (result.valid) {
|
|
try {
|
|
const DungeonGenerator = require('./game/dungeon');
|
|
const dungeon = DungeonGenerator.regenerateFromSeed(seed, process.env.CRYPTO_TYPE || 'WOW');
|
|
result.layoutFingerprint = DungeonGenerator.layoutFingerprint(dungeon);
|
|
result.dungeonSize = { width: dungeon.map[0].length, height: dungeon.map.length };
|
|
result.entrance = dungeon.entrance;
|
|
result.exit = dungeon.exit;
|
|
result.treasure = dungeon.treasure;
|
|
} catch (e) {
|
|
// Regeneration is best-effort; the hash check above is the primary verification.
|
|
}
|
|
}
|
|
|
|
res.json(result);
|
|
});
|
|
|
|
// Initialize debug manager with new block callbacks
|
|
debugManager.onNewBlockCallback((blockHeight) => {
|
|
// Start games for waiting players when new block detected
|
|
socketHandlers.startGamesForWaiting(blockHeight);
|
|
// Check active games for timeout
|
|
Promise.resolve(socketHandlers.checkGamesTimeout(blockHeight)).catch(err => {
|
|
console.error('❌ checkGamesTimeout error:', err.message);
|
|
});
|
|
// Flush any pending payouts (safety net for batch timer)
|
|
gameModeManager._processPendingPayouts().catch(err => {
|
|
console.error('❌ Block-triggered payout processing error:', err.message);
|
|
});
|
|
});
|
|
|
|
// Initialize payment system
|
|
async function initializePaymentSystem() {
|
|
try {
|
|
console.log('🔧 Initializing payment system...');
|
|
const walletInitialized = await walletRPCService.initialize();
|
|
if (!walletInitialized) {
|
|
console.log('⚠️ Wallet RPC not available - falling back to FREE mode');
|
|
process.env.GAME_MODE = 'FREE';
|
|
paymentConfigManager.refresh();
|
|
gameModeManager.setLegacyGameMode('FREE');
|
|
gameModeManager.paymentsEnabled = false;
|
|
gameModeManager.directModeEnabled = false;
|
|
gameModeManager.creditsModeEnabled = false;
|
|
}
|
|
|
|
// Initialize database connection and run migrations
|
|
await databaseManager.initialize();
|
|
console.log('✅ Database initialized successfully');
|
|
|
|
// Test RPC service connectivity
|
|
const rpcHealth = await rpcService.healthCheck();
|
|
console.log(`✅ RPC Service: ${rpcHealth.healthy ? 'Connected' : 'Warning - using fallback'}`);
|
|
|
|
console.log('🚀 Payment system ready!');
|
|
return true;
|
|
} catch (error) {
|
|
console.error('❌ Failed to initialize payment system:', error);
|
|
console.log('⚠️ Server will continue in FREE mode only');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Start the debug/production system
|
|
debugManager.initialize();
|
|
|
|
// Debug function for registered users
|
|
function debugRegisteredUsers() {
|
|
if (debugManager.CONSOLE_LOGGING) {
|
|
console.log("📊 REGISTERED USERS DEBUG:");
|
|
const allUsers = require('./db/user').getAllUsers();
|
|
console.log(`Total registered users: ${allUsers.length}`);
|
|
allUsers.forEach(u => {
|
|
// u.id is the socket/server id; there is no separate socketId property on User
|
|
console.log(` - User ID: ${u.id}, Socket: ${u.id}, Client: ${u.clientId}`);
|
|
});
|
|
console.log(`Active games: ${activeGames.size}`);
|
|
}
|
|
}
|
|
|
|
// Regular debug logging
|
|
setInterval(debugRegisteredUsers, 10000);
|
|
|
|
// Start server
|
|
async function startServer() {
|
|
try {
|
|
// Initialize payment system first
|
|
const paymentSystemReady = await initializePaymentSystem();
|
|
|
|
// C6: right after payment/recovery init at startup, finalize/refund any games left
|
|
// status='active' by a previous crash or restart. recoverOrphanedGames() is idempotent
|
|
// so it is safe to run on every boot; failures here must not block server startup.
|
|
try {
|
|
const sm = socketHandlers.sessionManager;
|
|
if (sm && typeof sm.recoverOrphanedGames === 'function') {
|
|
await sm.recoverOrphanedGames();
|
|
}
|
|
} catch (recoverErr) {
|
|
console.error('❌ recoverOrphanedGames failed at startup:', recoverErr.message);
|
|
}
|
|
|
|
// Socket.io connection handler - ONLY after payment system is ready
|
|
io.on('connection', function(socket) {
|
|
socketHandlers.handleConnection(socket);
|
|
});
|
|
|
|
// Start HTTP server.
|
|
// Bind all interfaces by default: the reverse proxy (Nginx Proxy Manager) is off-host /
|
|
// containerized and reaches this app over a non-loopback address, so a loopback-only bind
|
|
// makes the public site 502. Operators whose proxy runs on THIS host can set HOST=127.0.0.1.
|
|
// The real "don't expose :3000" hardening is a firewall rule limiting the port to the
|
|
// proxy's source, not the bind address; and X-Forwarded-For is already validated (rightmost
|
|
// hop) so a direct hit can't spoof the client IP.
|
|
const PORT = process.env.PORT || 3000;
|
|
const HOST = process.env.HOST || '0.0.0.0';
|
|
http.listen(PORT, HOST, function() {
|
|
console.log(`🚀 Wownerogue server listening on ${HOST}:${PORT}`);
|
|
console.log(`🐛 Debug mode: ${debugManager.getDebugStatus().debugMode ? 'ENABLED' : 'DISABLED'}`);
|
|
console.log(`💰 Payment system: ${paymentSystemReady ? 'ENABLED' : 'FREE MODE ONLY'}`);
|
|
const summary = paymentConfigManager.summarize();
|
|
const enabledModes = [];
|
|
if (!summary.paymentsEnabled || !paymentSystemReady) {
|
|
enabledModes.push('FREE');
|
|
}
|
|
if (summary.directEnabled && paymentSystemReady) {
|
|
enabledModes.push('PAID_SINGLE');
|
|
}
|
|
if (summary.creditsEnabled && paymentSystemReady) {
|
|
enabledModes.push('PAID_CREDITS');
|
|
}
|
|
if (enabledModes.length === 0) {
|
|
enabledModes.push(summary.legacyMode || 'FREE');
|
|
}
|
|
console.log(`🎮 Available game modes: ${enabledModes.join(', ')}`);
|
|
});
|
|
|
|
// Start batch payout processing if payment system is ready
|
|
if (paymentSystemReady) {
|
|
const payoutConfig = paymentConfigManager.getConfig().payouts?.processing || {};
|
|
const payoutIntervalSeconds = Math.max(1, Number(payoutConfig.batchInterval || 300));
|
|
const payoutIntervalMs = payoutIntervalSeconds * 1000;
|
|
batchPayoutInterval = setInterval(async () => {
|
|
try {
|
|
await gameModeManager._processPendingPayouts();
|
|
} catch (error) {
|
|
console.error('Error in batch payout processing:', error);
|
|
}
|
|
}, payoutIntervalMs);
|
|
|
|
// Start payout retry service for failed/stuck payouts
|
|
const maxRetries = Number(process.env.PAYOUT_MAX_RETRIES) || 3;
|
|
const retryIntervalMs = Number(process.env.PAYOUT_RETRY_INTERVAL_MS) || 300000; // 5 minutes
|
|
// Initialize alert service for email notifications
|
|
alertService = new AlertService({
|
|
walletService: walletRPCService,
|
|
db: databaseManager,
|
|
debugManager
|
|
});
|
|
|
|
// Make alertService available to gameModeManager/paymentHandlers for balance checks
|
|
gameModeManager.alertService = alertService;
|
|
// And to the admin routes (late-bound via the shared ctx object)
|
|
adminRouteCtx.alertService = alertService;
|
|
|
|
payoutRetryService = new PayoutRetryService({
|
|
db: databaseManager,
|
|
walletService: walletRPCService,
|
|
debugManager,
|
|
maxRetries,
|
|
retryIntervalMs,
|
|
alertService // Pass alert service for failure notifications
|
|
});
|
|
payoutRetryService.start();
|
|
|
|
// Start periodic alert checks (every 5 minutes)
|
|
alertService.startPeriodicChecks(300000);
|
|
}
|
|
|
|
// Periodic job: expire old pending payments (every 5 minutes)
|
|
paymentExpiryInterval = setInterval(async () => {
|
|
try {
|
|
const result = await databaseManager.query(`
|
|
UPDATE payments SET status = 'expired'
|
|
WHERE status = 'pending' AND expires_at < NOW()
|
|
RETURNING id, subaddress
|
|
`);
|
|
if (result.rows.length > 0) {
|
|
for (const row of result.rows) {
|
|
walletRPCService.stopPaymentMonitoring(row.subaddress);
|
|
walletRPCService.addressToUser.delete(row.subaddress);
|
|
walletRPCService.addressToSocket.delete(row.subaddress);
|
|
}
|
|
console.log(`[PaymentExpiry] Expired ${result.rows.length} stale payment(s)`);
|
|
}
|
|
} catch (e) {
|
|
console.error('[PaymentExpiry] Error:', e.message);
|
|
}
|
|
}, 5 * 60 * 1000);
|
|
|
|
} catch (error) {
|
|
console.error('❌ Failed to start server:', error);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// Attach 404 and error handlers last
|
|
app.use((req, res, next) => {
|
|
next(new NotFoundError(`Route not found: ${req.method} ${req.originalUrl}`, {
|
|
safeMessage: 'The requested resource was not found.'
|
|
}));
|
|
});
|
|
|
|
app.use(createErrorMiddleware({ logger: console }));
|
|
|
|
startServer();
|
|
|
|
// Graceful shutdown — clean up timers, wait for in-flight payouts, close DB pool
|
|
async function gracefulShutdown(signal) {
|
|
console.log(`\n${signal} received. Shutting down gracefully...`);
|
|
|
|
// Stop accepting new connections
|
|
http.close();
|
|
|
|
// Stop scheduled timers
|
|
if (batchPayoutInterval) clearInterval(batchPayoutInterval);
|
|
if (paymentExpiryInterval) clearInterval(paymentExpiryInterval);
|
|
if (gameModeManager._batchPayoutTimer) clearTimeout(gameModeManager._batchPayoutTimer);
|
|
if (payoutRetryService) payoutRetryService.stop();
|
|
if (alertService) alertService.stopPeriodicChecks?.();
|
|
|
|
// Wait for in-flight batch processing to finish (up to 10 seconds)
|
|
let waitMs = 0;
|
|
while (gameModeManager._isBatchProcessing && waitMs < 10000) {
|
|
await new Promise(r => setTimeout(r, 500));
|
|
waitMs += 500;
|
|
}
|
|
if (waitMs > 0) {
|
|
console.log(` Waited ${waitMs}ms for in-flight payout processing`);
|
|
}
|
|
|
|
// Close database pool
|
|
try {
|
|
await databaseManager.close();
|
|
console.log(' Database pool closed');
|
|
} catch (err) {
|
|
console.error(' Error closing database pool:', err.message);
|
|
}
|
|
|
|
console.log('Shutdown complete.');
|
|
process.exit(0);
|
|
}
|
|
|
|
// Last-resort process guards. A single bad socket event or an unhandled promise rejection
|
|
// must NOT crash this process, because it holds the live wallet RPC session and in-flight
|
|
// payout/batch state — being killed mid-payout is a far worse failure than leaking one
|
|
// broken request. TRADEOFF: standard Node guidance is to exit on 'uncaughtException' (process
|
|
// state may be corrupt); here we deliberately stay up so payouts can settle and sockets stay
|
|
// alive, and we log (and alert, if the alert service is up) so nothing fails silently.
|
|
process.on('uncaughtException', (err) => {
|
|
console.error('❌ uncaughtException (kept alive):', err && err.stack ? err.stack : err);
|
|
try {
|
|
alertService?.sendAlert?.('process_uncaught_exception', {
|
|
subject: 'uncaughtException (server kept alive)',
|
|
body: String(err && err.stack ? err.stack : err),
|
|
level: 'error'
|
|
})?.catch?.(() => {});
|
|
} catch (_) {}
|
|
});
|
|
process.on('unhandledRejection', (reason) => {
|
|
console.error('❌ unhandledRejection (kept alive):', reason && reason.stack ? reason.stack : reason);
|
|
try {
|
|
alertService?.sendAlert?.('process_unhandled_rejection', {
|
|
subject: 'unhandledRejection (server kept alive)',
|
|
body: String(reason && reason.stack ? reason.stack : reason),
|
|
level: 'error'
|
|
})?.catch?.(() => {});
|
|
} catch (_) {}
|
|
});
|
|
|
|
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
|
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|