You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
2049 lines
67 KiB
2049 lines
67 KiB
const WebSocket = require('ws');
|
|
const http = require('http');
|
|
const express = require('express');
|
|
const path = require('path');
|
|
const fs = require('fs').promises;
|
|
const crypto = require('crypto');
|
|
const webpush = require('web-push');
|
|
const jwt = require('jsonwebtoken');
|
|
const HikeMapDB = require('./database');
|
|
require('dotenv').config();
|
|
|
|
// JWT configuration
|
|
const JWT_SECRET = process.env.JWT_SECRET || crypto.randomBytes(64).toString('hex');
|
|
const JWT_ACCESS_EXPIRY = process.env.JWT_ACCESS_EXPIRY || '15m';
|
|
const JWT_REFRESH_EXPIRY = process.env.JWT_REFRESH_EXPIRY || '7d';
|
|
|
|
// Parse expiry string to milliseconds
|
|
function parseExpiry(expiry) {
|
|
const match = expiry.match(/^(\d+)([smhd])$/);
|
|
if (!match) return 15 * 60 * 1000; // default 15 minutes
|
|
const value = parseInt(match[1]);
|
|
const unit = match[2];
|
|
switch (unit) {
|
|
case 's': return value * 1000;
|
|
case 'm': return value * 60 * 1000;
|
|
case 'h': return value * 60 * 60 * 1000;
|
|
case 'd': return value * 24 * 60 * 60 * 1000;
|
|
default: return 15 * 60 * 1000;
|
|
}
|
|
}
|
|
|
|
// Database instance
|
|
let db = null;
|
|
|
|
// Configure web-push with VAPID keys
|
|
if (process.env.VAPID_PUBLIC_KEY && process.env.VAPID_PRIVATE_KEY) {
|
|
webpush.setVapidDetails(
|
|
process.env.VAPID_EMAIL || 'mailto:admin@maps.bibbit.duckdns.org',
|
|
process.env.VAPID_PUBLIC_KEY,
|
|
process.env.VAPID_PRIVATE_KEY
|
|
);
|
|
console.log('Push notifications configured');
|
|
} else {
|
|
console.log('Push notifications not configured - missing VAPID keys');
|
|
}
|
|
|
|
const app = express();
|
|
const server = http.createServer(app);
|
|
const wss = new WebSocket.Server({ server });
|
|
|
|
// Middleware to parse JSON and raw body for KML
|
|
app.use(express.json());
|
|
app.use(express.text({ type: 'application/xml', limit: '10mb' }));
|
|
|
|
// Disable caching for API routes
|
|
app.use('/api', (req, res, next) => {
|
|
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
|
res.set('Pragma', 'no-cache');
|
|
res.set('Expires', '0');
|
|
next();
|
|
});
|
|
|
|
// Serve static files - prioritize data directory for default.kml
|
|
app.get('/default.kml', async (req, res) => {
|
|
try {
|
|
// Try to serve from data directory first (mounted volume)
|
|
const dataPath = path.join('/app/data', 'default.kml');
|
|
await fs.access(dataPath);
|
|
console.log('Serving default.kml from data directory');
|
|
res.sendFile(dataPath);
|
|
} catch (err) {
|
|
// Fall back to app directory
|
|
const appPath = path.join(__dirname, 'default.kml');
|
|
console.log('Serving default.kml from app directory');
|
|
res.sendFile(appPath);
|
|
}
|
|
});
|
|
|
|
// Serve .well-known directory for app verification
|
|
app.use('/.well-known', express.static(path.join(__dirname, '.well-known')));
|
|
|
|
// Serve monster images
|
|
app.use('/mapgameimgs', express.static(path.join(__dirname, 'mapgameimgs')));
|
|
|
|
// Serve other static files
|
|
app.use(express.static(path.join(__dirname)));
|
|
|
|
// Store connected users
|
|
const users = new Map();
|
|
|
|
// Store geocaches
|
|
let geocaches = [];
|
|
|
|
// Store push subscriptions
|
|
let pushSubscriptions = [];
|
|
|
|
// Geocache file path
|
|
const getGeocachePath = async () => {
|
|
let dataDir = __dirname;
|
|
try {
|
|
await fs.access('/app/data');
|
|
dataDir = '/app/data';
|
|
} catch (err) {
|
|
// Use local directory if /app/data doesn't exist
|
|
}
|
|
return path.join(dataDir, 'geocaches.json');
|
|
};
|
|
|
|
// Load geocaches from file
|
|
const loadGeocaches = async () => {
|
|
try {
|
|
const geocachePath = await getGeocachePath();
|
|
const data = await fs.readFile(geocachePath, 'utf8');
|
|
geocaches = JSON.parse(data);
|
|
console.log(`Loaded ${geocaches.length} geocaches from file`);
|
|
} catch (err) {
|
|
if (err.code === 'ENOENT') {
|
|
console.log('No geocaches file found, starting fresh');
|
|
} else {
|
|
console.error('Error loading geocaches:', err);
|
|
}
|
|
geocaches = [];
|
|
}
|
|
};
|
|
|
|
// Save geocaches to file
|
|
const saveGeocaches = async () => {
|
|
try {
|
|
const geocachePath = await getGeocachePath();
|
|
await fs.writeFile(geocachePath, JSON.stringify(geocaches, null, 2), 'utf8');
|
|
console.log(`Saved ${geocaches.length} geocaches to file`);
|
|
} catch (err) {
|
|
console.error('Error saving geocaches:', err);
|
|
}
|
|
};
|
|
|
|
// KML save endpoint
|
|
app.post('/save-kml', async (req, res) => {
|
|
try {
|
|
const kmlContent = req.body;
|
|
// Always use data directory when it exists (Docker), otherwise local
|
|
let dataDir = __dirname;
|
|
try {
|
|
await fs.access('/app/data');
|
|
dataDir = '/app/data';
|
|
console.log('Using data directory for save');
|
|
} catch (err) {
|
|
console.log('Using app directory for save');
|
|
}
|
|
const defaultKmlPath = path.join(dataDir, 'default.kml');
|
|
|
|
// Create backup with timestamp
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
const backupPath = path.join(dataDir, `default.kml.backup.${timestamp}`);
|
|
|
|
try {
|
|
// Try to backup existing file
|
|
const existingContent = await fs.readFile(defaultKmlPath, 'utf8');
|
|
await fs.writeFile(backupPath, existingContent);
|
|
console.log(`Backed up existing KML to: ${backupPath}`);
|
|
} catch (err) {
|
|
// No existing file to backup, that's OK
|
|
console.log('No existing default.kml to backup');
|
|
}
|
|
|
|
// Write new content
|
|
await fs.writeFile(defaultKmlPath, kmlContent, 'utf8');
|
|
console.log('Saved new default.kml');
|
|
|
|
// Clean up old backups (keep only last 10)
|
|
const files = await fs.readdir(dataDir);
|
|
const backupFiles = files
|
|
.filter(f => f.startsWith('default.kml.backup.'))
|
|
.sort()
|
|
.reverse();
|
|
|
|
for (let i = 10; i < backupFiles.length; i++) {
|
|
await fs.unlink(path.join(dataDir, backupFiles[i]));
|
|
console.log(`Deleted old backup: ${backupFiles[i]}`);
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
message: 'Tracks saved to server successfully',
|
|
backup: backupPath.split('/').pop()
|
|
});
|
|
} catch (err) {
|
|
console.error('Error saving KML:', err);
|
|
res.status(500).send('Failed to save: ' + err.message);
|
|
}
|
|
});
|
|
|
|
// Push notification endpoints
|
|
app.get('/vapid-public-key', (req, res) => {
|
|
res.json({ publicKey: process.env.VAPID_PUBLIC_KEY || '' });
|
|
});
|
|
|
|
app.post('/subscribe', async (req, res) => {
|
|
try {
|
|
const subscription = req.body;
|
|
|
|
// Store subscription (in production, use a database)
|
|
const existingIndex = pushSubscriptions.findIndex(
|
|
sub => sub.endpoint === subscription.endpoint
|
|
);
|
|
|
|
if (existingIndex >= 0) {
|
|
pushSubscriptions[existingIndex] = subscription;
|
|
} else {
|
|
pushSubscriptions.push(subscription);
|
|
}
|
|
|
|
// Save subscriptions to file
|
|
try {
|
|
const dataDir = await getGeocachePath();
|
|
const subsPath = path.join(path.dirname(dataDir), 'push-subscriptions.json');
|
|
await fs.writeFile(subsPath, JSON.stringify(pushSubscriptions, null, 2));
|
|
} catch (err) {
|
|
console.error('Error saving subscriptions:', err);
|
|
}
|
|
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
console.error('Subscription error:', err);
|
|
res.status(500).json({ error: 'Failed to subscribe' });
|
|
}
|
|
});
|
|
|
|
app.post('/unsubscribe', async (req, res) => {
|
|
try {
|
|
const { endpoint } = req.body;
|
|
pushSubscriptions = pushSubscriptions.filter(sub => sub.endpoint !== endpoint);
|
|
|
|
// Save updated subscriptions
|
|
try {
|
|
const dataDir = await getGeocachePath();
|
|
const subsPath = path.join(path.dirname(dataDir), 'push-subscriptions.json');
|
|
await fs.writeFile(subsPath, JSON.stringify(pushSubscriptions, null, 2));
|
|
} catch (err) {
|
|
console.error('Error saving subscriptions:', err);
|
|
}
|
|
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
res.status(500).json({ error: 'Failed to unsubscribe' });
|
|
}
|
|
});
|
|
|
|
// Send test notification to all users
|
|
app.post('/test-notification', async (req, res) => {
|
|
try {
|
|
const { message } = req.body;
|
|
|
|
if (!pushSubscriptions.length) {
|
|
return res.json({ success: false, message: 'No users have notifications enabled' });
|
|
}
|
|
|
|
const payload = {
|
|
title: '🔔 HikeMap Test Notification',
|
|
body: message || 'This is a test notification from HikeMap',
|
|
icon: '/icon-192x192.png',
|
|
badge: '/icon-72x72.png',
|
|
vibrate: [200, 100, 200],
|
|
data: {
|
|
type: 'test',
|
|
timestamp: Date.now()
|
|
}
|
|
};
|
|
|
|
// Send to all subscriptions
|
|
const results = await Promise.allSettled(
|
|
pushSubscriptions.map(subscription =>
|
|
webpush.sendNotification(subscription, JSON.stringify(payload))
|
|
.catch(err => {
|
|
if (err.statusCode === 410) {
|
|
// Subscription expired, remove it
|
|
pushSubscriptions = pushSubscriptions.filter(sub =>
|
|
sub.endpoint !== subscription.endpoint
|
|
);
|
|
}
|
|
throw err;
|
|
})
|
|
)
|
|
);
|
|
|
|
const successful = results.filter(r => r.status === 'fulfilled').length;
|
|
console.log(`Test notification sent to ${successful}/${pushSubscriptions.length} subscribers`);
|
|
|
|
res.json({ success: true, sent: successful, total: pushSubscriptions.length });
|
|
} catch (err) {
|
|
console.error('Error sending test notification:', err);
|
|
res.status(500).json({ error: 'Failed to send test notification' });
|
|
}
|
|
});
|
|
|
|
// Send notification to a specific user
|
|
app.post('/send-notification', async (req, res) => {
|
|
try {
|
|
const { title, body, type, userId } = req.body;
|
|
|
|
if (!pushSubscriptions.length) {
|
|
return res.json({ success: false, message: 'No subscriptions' });
|
|
}
|
|
|
|
const payload = {
|
|
title: title || 'HikeMap Notification',
|
|
body: body || '',
|
|
icon: '/icon-192x192.png',
|
|
badge: '/icon-72x72.png',
|
|
data: {
|
|
type: type,
|
|
timestamp: Date.now()
|
|
}
|
|
};
|
|
|
|
// Send to all subscriptions (in a real app, filter by userId)
|
|
const results = await Promise.allSettled(
|
|
pushSubscriptions.map(subscription =>
|
|
webpush.sendNotification(subscription, JSON.stringify(payload))
|
|
.catch(err => {
|
|
if (err.statusCode === 410) {
|
|
// Subscription expired, remove it
|
|
pushSubscriptions = pushSubscriptions.filter(sub =>
|
|
sub.endpoint !== subscription.endpoint
|
|
);
|
|
}
|
|
throw err;
|
|
})
|
|
)
|
|
);
|
|
|
|
const successful = results.filter(r => r.status === 'fulfilled').length;
|
|
console.log(`Sent notification to ${successful}/${pushSubscriptions.length} subscribers`);
|
|
|
|
res.json({ success: true, sent: successful });
|
|
} catch (err) {
|
|
console.error('Error sending notification:', err);
|
|
res.status(500).json({ error: 'Failed to send notification' });
|
|
}
|
|
});
|
|
|
|
// ============================================
|
|
// Authentication Middleware
|
|
// ============================================
|
|
|
|
function generateTokens(user) {
|
|
const accessToken = jwt.sign(
|
|
{ userId: user.id, username: user.username },
|
|
JWT_SECRET,
|
|
{ expiresIn: JWT_ACCESS_EXPIRY }
|
|
);
|
|
|
|
const refreshToken = jwt.sign(
|
|
{ userId: user.id, type: 'refresh' },
|
|
JWT_SECRET,
|
|
{ expiresIn: JWT_REFRESH_EXPIRY }
|
|
);
|
|
|
|
return { accessToken, refreshToken };
|
|
}
|
|
|
|
function authenticateToken(req, res, next) {
|
|
const authHeader = req.headers['authorization'];
|
|
const token = authHeader && authHeader.split(' ')[1];
|
|
|
|
if (!token) {
|
|
return res.status(401).json({ error: 'Authentication required' });
|
|
}
|
|
|
|
jwt.verify(token, JWT_SECRET, (err, decoded) => {
|
|
if (err) {
|
|
if (err.name === 'TokenExpiredError') {
|
|
return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
|
|
}
|
|
return res.status(403).json({ error: 'Invalid token' });
|
|
}
|
|
req.user = decoded;
|
|
next();
|
|
});
|
|
}
|
|
|
|
function optionalAuth(req, res, next) {
|
|
const authHeader = req.headers['authorization'];
|
|
const token = authHeader && authHeader.split(' ')[1];
|
|
|
|
if (token) {
|
|
jwt.verify(token, JWT_SECRET, (err, decoded) => {
|
|
if (!err) {
|
|
req.user = decoded;
|
|
}
|
|
});
|
|
}
|
|
next();
|
|
}
|
|
|
|
// Admin-only middleware - requires valid auth AND admin status
|
|
function adminOnly(req, res, next) {
|
|
authenticateToken(req, res, () => {
|
|
const user = db.getUserById(req.user.userId);
|
|
if (!user || !user.is_admin) {
|
|
return res.status(403).json({ error: 'Admin access required' });
|
|
}
|
|
next();
|
|
});
|
|
}
|
|
|
|
// ============================================
|
|
// Authentication Endpoints
|
|
// ============================================
|
|
|
|
// Register new user
|
|
app.post('/api/register', async (req, res) => {
|
|
try {
|
|
const { username, email, password } = req.body;
|
|
|
|
// Validate input
|
|
if (!username || !email || !password) {
|
|
return res.status(400).json({ error: 'Username, email, and password are required' });
|
|
}
|
|
|
|
if (username.length < 3 || username.length > 20) {
|
|
return res.status(400).json({ error: 'Username must be 3-20 characters' });
|
|
}
|
|
|
|
if (!/^[a-zA-Z0-9_]+$/.test(username)) {
|
|
return res.status(400).json({ error: 'Username can only contain letters, numbers, and underscores' });
|
|
}
|
|
|
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
return res.status(400).json({ error: 'Invalid email format' });
|
|
}
|
|
|
|
if (password.length < 8) {
|
|
return res.status(400).json({ error: 'Password must be at least 8 characters' });
|
|
}
|
|
|
|
const user = await db.createUser(username, email, password);
|
|
const tokens = generateTokens(user);
|
|
|
|
// Store refresh token hash
|
|
const refreshTokenHash = crypto.createHash('sha256').update(tokens.refreshToken).digest('hex');
|
|
const expiresAt = new Date(Date.now() + parseExpiry(JWT_REFRESH_EXPIRY)).toISOString();
|
|
await db.storeRefreshToken(user.id, refreshTokenHash, expiresAt);
|
|
|
|
console.log(`New user registered: ${username}`);
|
|
|
|
res.status(201).json({
|
|
user: {
|
|
id: user.id,
|
|
username: user.username,
|
|
email: user.email,
|
|
total_points: 0,
|
|
finds_count: 0,
|
|
avatar_icon: 'account',
|
|
avatar_color: '#4CAF50'
|
|
},
|
|
...tokens
|
|
});
|
|
} catch (err) {
|
|
console.error('Registration error:', err);
|
|
if (err.message.includes('already exists')) {
|
|
return res.status(409).json({ error: err.message });
|
|
}
|
|
res.status(500).json({ error: 'Registration failed' });
|
|
}
|
|
});
|
|
|
|
// Login
|
|
app.post('/api/login', async (req, res) => {
|
|
try {
|
|
const { username, password } = req.body;
|
|
|
|
if (!username || !password) {
|
|
return res.status(400).json({ error: 'Username and password are required' });
|
|
}
|
|
|
|
const user = await db.validateUser(username, password);
|
|
|
|
if (!user) {
|
|
return res.status(401).json({ error: 'Invalid username or password' });
|
|
}
|
|
|
|
const tokens = generateTokens(user);
|
|
|
|
// Store refresh token hash
|
|
const refreshTokenHash = crypto.createHash('sha256').update(tokens.refreshToken).digest('hex');
|
|
const expiresAt = new Date(Date.now() + parseExpiry(JWT_REFRESH_EXPIRY)).toISOString();
|
|
await db.storeRefreshToken(user.id, refreshTokenHash, expiresAt);
|
|
|
|
console.log(`User logged in: ${user.username}`);
|
|
|
|
res.json({
|
|
user: {
|
|
id: user.id,
|
|
username: user.username,
|
|
email: user.email,
|
|
total_points: user.total_points,
|
|
finds_count: user.finds_count,
|
|
avatar_icon: user.avatar_icon,
|
|
avatar_color: user.avatar_color,
|
|
is_admin: user.is_admin
|
|
},
|
|
...tokens
|
|
});
|
|
} catch (err) {
|
|
console.error('Login error:', err);
|
|
res.status(500).json({ error: 'Login failed' });
|
|
}
|
|
});
|
|
|
|
// Refresh access token
|
|
app.post('/api/refresh', async (req, res) => {
|
|
try {
|
|
const { refreshToken } = req.body;
|
|
|
|
if (!refreshToken) {
|
|
return res.status(400).json({ error: 'Refresh token required' });
|
|
}
|
|
|
|
// Verify refresh token
|
|
let decoded;
|
|
try {
|
|
decoded = jwt.verify(refreshToken, JWT_SECRET);
|
|
} catch (err) {
|
|
return res.status(401).json({ error: 'Invalid refresh token' });
|
|
}
|
|
|
|
if (decoded.type !== 'refresh') {
|
|
return res.status(401).json({ error: 'Invalid token type' });
|
|
}
|
|
|
|
// Check if token exists in database
|
|
const tokenHash = crypto.createHash('sha256').update(refreshToken).digest('hex');
|
|
const storedToken = db.getRefreshToken(tokenHash);
|
|
|
|
if (!storedToken) {
|
|
return res.status(401).json({ error: 'Refresh token not found or expired' });
|
|
}
|
|
|
|
// Get user
|
|
const user = db.getUserById(decoded.userId);
|
|
if (!user) {
|
|
return res.status(401).json({ error: 'User not found' });
|
|
}
|
|
|
|
// Generate new tokens
|
|
const tokens = generateTokens(user);
|
|
|
|
// Delete old refresh token and store new one
|
|
db.deleteRefreshToken(tokenHash);
|
|
const newTokenHash = crypto.createHash('sha256').update(tokens.refreshToken).digest('hex');
|
|
const expiresAt = new Date(Date.now() + parseExpiry(JWT_REFRESH_EXPIRY)).toISOString();
|
|
await db.storeRefreshToken(user.id, newTokenHash, expiresAt);
|
|
|
|
res.json(tokens);
|
|
} catch (err) {
|
|
console.error('Token refresh error:', err);
|
|
res.status(500).json({ error: 'Token refresh failed' });
|
|
}
|
|
});
|
|
|
|
// Logout
|
|
app.post('/api/logout', authenticateToken, async (req, res) => {
|
|
try {
|
|
const { refreshToken } = req.body;
|
|
|
|
if (refreshToken) {
|
|
const tokenHash = crypto.createHash('sha256').update(refreshToken).digest('hex');
|
|
db.deleteRefreshToken(tokenHash);
|
|
}
|
|
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
console.error('Logout error:', err);
|
|
res.status(500).json({ error: 'Logout failed' });
|
|
}
|
|
});
|
|
|
|
// Get current user
|
|
app.get('/api/user/me', authenticateToken, (req, res) => {
|
|
try {
|
|
const user = db.getUserById(req.user.userId);
|
|
|
|
if (!user) {
|
|
return res.status(404).json({ error: 'User not found' });
|
|
}
|
|
|
|
res.json(user);
|
|
} catch (err) {
|
|
console.error('Get user error:', err);
|
|
res.status(500).json({ error: 'Failed to get user' });
|
|
}
|
|
});
|
|
|
|
// Update user avatar
|
|
app.put('/api/user/avatar', authenticateToken, (req, res) => {
|
|
try {
|
|
const { icon, color } = req.body;
|
|
|
|
if (!icon || !color) {
|
|
return res.status(400).json({ error: 'Icon and color are required' });
|
|
}
|
|
|
|
db.updateUserAvatar(req.user.userId, icon, color);
|
|
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
console.error('Update avatar error:', err);
|
|
res.status(500).json({ error: 'Failed to update avatar' });
|
|
}
|
|
});
|
|
|
|
// ============================================
|
|
// Game Mechanics Endpoints
|
|
// ============================================
|
|
|
|
// Points configuration
|
|
const POINTS = {
|
|
BASE_FIND: 100,
|
|
FIRST_FINDER_BONUS: 50,
|
|
CLOSE_FIND_BONUS: 25, // < 2m accuracy
|
|
MESSAGE_BONUS: 10
|
|
};
|
|
|
|
// Calculate distance between two points (Haversine formula)
|
|
function calculateDistance(lat1, lng1, lat2, lng2) {
|
|
const R = 6371e3; // Earth radius in meters
|
|
const phi1 = lat1 * Math.PI / 180;
|
|
const phi2 = lat2 * Math.PI / 180;
|
|
const deltaPhi = (lat2 - lat1) * Math.PI / 180;
|
|
const deltaLambda = (lng2 - lng1) * Math.PI / 180;
|
|
|
|
const a = Math.sin(deltaPhi / 2) * Math.sin(deltaPhi / 2) +
|
|
Math.cos(phi1) * Math.cos(phi2) *
|
|
Math.sin(deltaLambda / 2) * Math.sin(deltaLambda / 2);
|
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
|
|
return R * c;
|
|
}
|
|
|
|
// Find (claim) a geocache
|
|
app.post('/api/geocaches/:id/find', authenticateToken, (req, res) => {
|
|
try {
|
|
const geocacheId = req.params.id;
|
|
const { lat, lng, accuracy } = req.body;
|
|
|
|
// Find the geocache
|
|
const geocache = geocaches.find(g => g.id === geocacheId);
|
|
if (!geocache) {
|
|
return res.status(404).json({ error: 'Geocache not found' });
|
|
}
|
|
|
|
// Check if user already found it
|
|
if (db.hasUserFoundGeocache(req.user.userId, geocacheId)) {
|
|
return res.status(409).json({ error: 'You have already found this geocache' });
|
|
}
|
|
|
|
// Validate GPS accuracy (reject if too inaccurate)
|
|
if (accuracy && accuracy > 50) {
|
|
return res.status(400).json({ error: 'GPS accuracy too low. Please wait for better signal.' });
|
|
}
|
|
|
|
// Validate distance (must be within 25m)
|
|
const distance = calculateDistance(lat, lng, geocache.lat, geocache.lng);
|
|
if (distance > 25) {
|
|
return res.status(400).json({
|
|
error: 'You are too far from the geocache',
|
|
distance: Math.round(distance)
|
|
});
|
|
}
|
|
|
|
// Calculate points
|
|
let points = POINTS.BASE_FIND;
|
|
const isFirstFinder = db.isFirstFinder(geocacheId);
|
|
|
|
if (isFirstFinder) {
|
|
points += POINTS.FIRST_FINDER_BONUS;
|
|
}
|
|
|
|
if (distance < 2) {
|
|
points += POINTS.CLOSE_FIND_BONUS;
|
|
}
|
|
|
|
// Record the find
|
|
const result = db.recordFind(req.user.userId, geocacheId, points, isFirstFinder);
|
|
|
|
// Get updated user
|
|
const user = db.getUserById(req.user.userId);
|
|
|
|
console.log(`User ${req.user.username} found geocache ${geocacheId} for ${points} points`);
|
|
|
|
res.json({
|
|
success: true,
|
|
points_earned: points,
|
|
is_first_finder: isFirstFinder,
|
|
total_points: user.total_points,
|
|
finds_count: user.finds_count
|
|
});
|
|
} catch (err) {
|
|
console.error('Find geocache error:', err);
|
|
if (err.message.includes('already found')) {
|
|
return res.status(409).json({ error: err.message });
|
|
}
|
|
res.status(500).json({ error: 'Failed to record find' });
|
|
}
|
|
});
|
|
|
|
// Get geocache finders
|
|
app.get('/api/geocaches/:id/finders', optionalAuth, (req, res) => {
|
|
try {
|
|
const geocacheId = req.params.id;
|
|
const finders = db.getGeocacheFinders(geocacheId);
|
|
|
|
res.json(finders);
|
|
} catch (err) {
|
|
console.error('Get finders error:', err);
|
|
res.status(500).json({ error: 'Failed to get finders' });
|
|
}
|
|
});
|
|
|
|
// Get leaderboard
|
|
app.get('/api/leaderboard', optionalAuth, (req, res) => {
|
|
try {
|
|
const period = req.query.period || 'all'; // 'all', 'weekly', 'monthly'
|
|
const limit = Math.min(parseInt(req.query.limit) || 50, 100);
|
|
|
|
const leaderboard = db.getLeaderboard(period, limit);
|
|
|
|
res.json(leaderboard);
|
|
} catch (err) {
|
|
console.error('Get leaderboard error:', err);
|
|
res.status(500).json({ error: 'Failed to get leaderboard' });
|
|
}
|
|
});
|
|
|
|
// Get user's find history
|
|
app.get('/api/user/finds', authenticateToken, (req, res) => {
|
|
try {
|
|
const limit = Math.min(parseInt(req.query.limit) || 50, 100);
|
|
const finds = db.getUserFinds(req.user.userId, limit);
|
|
|
|
res.json(finds);
|
|
} catch (err) {
|
|
console.error('Get finds error:', err);
|
|
res.status(500).json({ error: 'Failed to get finds' });
|
|
}
|
|
});
|
|
|
|
// Get RPG stats for current user
|
|
app.get('/api/user/rpg-stats', authenticateToken, (req, res) => {
|
|
try {
|
|
const stats = db.getRpgStats(req.user.userId);
|
|
console.log('GET /api/user/rpg-stats for user', req.user.userId, '- atk:', stats ? stats.atk : 'NO STATS');
|
|
if (stats) {
|
|
// Parse unlocked_skills from JSON string
|
|
let unlockedSkills = ['basic_attack'];
|
|
if (stats.unlocked_skills) {
|
|
try {
|
|
unlockedSkills = JSON.parse(stats.unlocked_skills);
|
|
} catch (e) {
|
|
console.error('Failed to parse unlocked_skills:', e);
|
|
}
|
|
}
|
|
|
|
// Convert snake_case from DB to camelCase for client
|
|
res.json({
|
|
name: stats.character_name,
|
|
race: stats.race,
|
|
class: stats.class,
|
|
level: stats.level,
|
|
xp: stats.xp,
|
|
hp: stats.hp,
|
|
maxHp: stats.max_hp,
|
|
mp: stats.mp,
|
|
maxMp: stats.max_mp,
|
|
atk: stats.atk,
|
|
def: stats.def,
|
|
accuracy: stats.accuracy || 90,
|
|
dodge: stats.dodge || 10,
|
|
unlockedSkills: unlockedSkills,
|
|
homeBaseLat: stats.home_base_lat,
|
|
homeBaseLng: stats.home_base_lng,
|
|
lastHomeSet: stats.last_home_set,
|
|
isDead: !!stats.is_dead,
|
|
homeBaseIcon: stats.home_base_icon || '00'
|
|
});
|
|
} else {
|
|
// No stats yet - return null so client creates defaults
|
|
res.json(null);
|
|
}
|
|
} catch (err) {
|
|
console.error('Get RPG stats error:', err);
|
|
res.status(500).json({ error: 'Failed to get RPG stats' });
|
|
}
|
|
});
|
|
|
|
// Check if user has a character
|
|
app.get('/api/user/has-character', authenticateToken, (req, res) => {
|
|
try {
|
|
const hasCharacter = db.hasCharacter(req.user.userId);
|
|
res.json({ hasCharacter });
|
|
} catch (err) {
|
|
console.error('Check character error:', err);
|
|
res.status(500).json({ error: 'Failed to check character' });
|
|
}
|
|
});
|
|
|
|
// Create a new character
|
|
app.post('/api/user/character', authenticateToken, (req, res) => {
|
|
try {
|
|
const characterData = req.body;
|
|
|
|
// Validate required fields
|
|
if (!characterData.name || characterData.name.length < 2 || characterData.name.length > 20) {
|
|
return res.status(400).json({ error: 'Character name must be 2-20 characters' });
|
|
}
|
|
|
|
if (!characterData.race) {
|
|
return res.status(400).json({ error: 'Race is required' });
|
|
}
|
|
|
|
if (!characterData.class) {
|
|
return res.status(400).json({ error: 'Class is required' });
|
|
}
|
|
|
|
// Only trail_runner is available for now
|
|
if (characterData.class !== 'trail_runner') {
|
|
return res.status(400).json({ error: 'This class is not available yet' });
|
|
}
|
|
|
|
db.createCharacter(req.user.userId, characterData);
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
console.error('Create character error:', err);
|
|
res.status(500).json({ error: 'Failed to create character' });
|
|
}
|
|
});
|
|
|
|
// Save RPG stats for current user
|
|
app.put('/api/user/rpg-stats', authenticateToken, (req, res) => {
|
|
try {
|
|
const stats = req.body;
|
|
|
|
// Validate stats
|
|
if (!stats || typeof stats !== 'object') {
|
|
return res.status(400).json({ error: 'Invalid stats data' });
|
|
}
|
|
|
|
db.saveRpgStats(req.user.userId, stats);
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
console.error('Save RPG stats error:', err);
|
|
res.status(500).json({ error: 'Failed to save RPG stats' });
|
|
}
|
|
});
|
|
|
|
// Check if user can set home base (once per day)
|
|
app.get('/api/user/can-set-home', authenticateToken, (req, res) => {
|
|
try {
|
|
const canSet = db.canSetHomeBase(req.user.userId);
|
|
res.json({ canSet });
|
|
} catch (err) {
|
|
console.error('Check home base error:', err);
|
|
res.status(500).json({ error: 'Failed to check home base availability' });
|
|
}
|
|
});
|
|
|
|
// Set home base location
|
|
app.post('/api/user/home-base', authenticateToken, (req, res) => {
|
|
try {
|
|
const { lat, lng } = req.body;
|
|
|
|
if (lat === undefined || lng === undefined) {
|
|
return res.status(400).json({ error: 'Latitude and longitude are required' });
|
|
}
|
|
|
|
// Check if user can set home base (once per day)
|
|
if (!db.canSetHomeBase(req.user.userId)) {
|
|
return res.status(400).json({ error: 'You can only set your home base once per day' });
|
|
}
|
|
|
|
db.setHomeBase(req.user.userId, lat, lng);
|
|
res.json({ success: true, homeBaseLat: lat, homeBaseLng: lng });
|
|
} catch (err) {
|
|
console.error('Set home base error:', err);
|
|
res.status(500).json({ error: 'Failed to set home base' });
|
|
}
|
|
});
|
|
|
|
// Get available homebase icons (auto-detected from mapgameimgs directory)
|
|
app.get('/api/homebase-icons', (req, res) => {
|
|
try {
|
|
const fs = require('fs');
|
|
const imagesDir = path.join(__dirname, 'mapgameimgs');
|
|
|
|
// Read directory and find homebaseXX-100.png files
|
|
const files = fs.readdirSync(imagesDir);
|
|
const iconPattern = /^homebase(\d+)-100\.png$/;
|
|
|
|
const icons = files
|
|
.map(file => {
|
|
const match = file.match(iconPattern);
|
|
if (match) {
|
|
return {
|
|
id: match[1],
|
|
filename: file,
|
|
preview: `/mapgameimgs/${file}`, // Use 100px, CSS scales down
|
|
full: `/mapgameimgs/${file}`
|
|
};
|
|
}
|
|
return null;
|
|
})
|
|
.filter(Boolean)
|
|
.sort((a, b) => a.id.localeCompare(b.id));
|
|
|
|
res.json(icons);
|
|
} catch (err) {
|
|
console.error('Get homebase icons error:', err);
|
|
res.status(500).json({ error: 'Failed to get homebase icons' });
|
|
}
|
|
});
|
|
|
|
// Get spawn settings (public - client needs these for spawn logic)
|
|
app.get('/api/spawn-settings', (req, res) => {
|
|
try {
|
|
const settings = {
|
|
spawnInterval: JSON.parse(db.getSetting('monsterSpawnInterval') || '20000'),
|
|
spawnChance: JSON.parse(db.getSetting('monsterSpawnChance') || '50'),
|
|
spawnDistance: JSON.parse(db.getSetting('monsterSpawnDistance') || '10')
|
|
};
|
|
res.json(settings);
|
|
} catch (err) {
|
|
console.error('Get spawn settings error:', err);
|
|
res.status(500).json({ error: 'Failed to get spawn settings' });
|
|
}
|
|
});
|
|
|
|
// Update home base icon
|
|
app.put('/api/user/home-base/icon', authenticateToken, (req, res) => {
|
|
try {
|
|
const { iconId } = req.body;
|
|
|
|
if (!iconId) {
|
|
return res.status(400).json({ error: 'Icon ID is required' });
|
|
}
|
|
|
|
db.setHomeBaseIcon(req.user.userId, iconId);
|
|
res.json({ success: true, homeBaseIcon: iconId });
|
|
} catch (err) {
|
|
console.error('Set home base icon error:', err);
|
|
res.status(500).json({ error: 'Failed to set home base icon' });
|
|
}
|
|
});
|
|
|
|
// Handle player death
|
|
app.post('/api/user/death', authenticateToken, (req, res) => {
|
|
try {
|
|
const result = db.handlePlayerDeath(req.user.userId, 10); // 10% XP penalty
|
|
if (!result) {
|
|
return res.status(404).json({ error: 'Player stats not found' });
|
|
}
|
|
|
|
// Clear monster entourage
|
|
db.clearMonsterEntourage(req.user.userId);
|
|
|
|
res.json({
|
|
success: true,
|
|
xpLost: result.xpLost,
|
|
newXp: result.newXp
|
|
});
|
|
} catch (err) {
|
|
console.error('Handle death error:', err);
|
|
res.status(500).json({ error: 'Failed to handle death' });
|
|
}
|
|
});
|
|
|
|
// Respawn player at home base
|
|
app.post('/api/user/respawn', authenticateToken, (req, res) => {
|
|
try {
|
|
const stats = db.getRpgStats(req.user.userId);
|
|
if (!stats) {
|
|
return res.status(404).json({ error: 'Player stats not found' });
|
|
}
|
|
|
|
if (!stats.is_dead) {
|
|
return res.status(400).json({ error: 'Player is not dead' });
|
|
}
|
|
|
|
db.respawnPlayer(req.user.userId);
|
|
res.json({
|
|
success: true,
|
|
hp: stats.max_hp,
|
|
mp: stats.max_mp
|
|
});
|
|
} catch (err) {
|
|
console.error('Respawn error:', err);
|
|
res.status(500).json({ error: 'Failed to respawn' });
|
|
}
|
|
});
|
|
|
|
// Get all monster types (public endpoint - needed for game rendering)
|
|
app.get('/api/monster-types', (req, res) => {
|
|
try {
|
|
const types = db.getAllMonsterTypes(true); // Only enabled monsters
|
|
// Convert snake_case to camelCase and parse JSON dialogues
|
|
const formatted = types.map(t => ({
|
|
id: t.id,
|
|
name: t.name,
|
|
icon: t.icon,
|
|
baseHp: t.base_hp,
|
|
baseAtk: t.base_atk,
|
|
baseDef: t.base_def,
|
|
xpReward: t.xp_reward,
|
|
accuracy: t.accuracy || 85,
|
|
dodge: t.dodge || 5,
|
|
minLevel: t.min_level || 1,
|
|
maxLevel: t.max_level || 99,
|
|
spawnWeight: t.spawn_weight || 100,
|
|
levelScale: {
|
|
hp: t.level_scale_hp,
|
|
atk: t.level_scale_atk,
|
|
def: t.level_scale_def
|
|
},
|
|
dialogues: JSON.parse(t.dialogues)
|
|
}));
|
|
res.json(formatted);
|
|
} catch (err) {
|
|
console.error('Get monster types error:', err);
|
|
res.status(500).json({ error: 'Failed to get monster types' });
|
|
}
|
|
});
|
|
|
|
// ============================================
|
|
// Skills Endpoints (Public - needed for combat)
|
|
// ============================================
|
|
|
|
// Get all skills (public endpoint)
|
|
app.get('/api/skills', (req, res) => {
|
|
try {
|
|
const skills = db.getAllSkills(true); // Only enabled skills
|
|
// Convert snake_case to camelCase and parse JSON
|
|
const formatted = skills.map(s => ({
|
|
id: s.id,
|
|
name: s.name,
|
|
description: s.description,
|
|
type: s.type,
|
|
mpCost: s.mp_cost,
|
|
basePower: s.base_power,
|
|
accuracy: s.accuracy,
|
|
hitCount: s.hit_count,
|
|
target: s.target,
|
|
statusEffect: s.status_effect ? JSON.parse(s.status_effect) : null,
|
|
playerUsable: !!s.player_usable,
|
|
monsterUsable: !!s.monster_usable
|
|
}));
|
|
res.json(formatted);
|
|
} catch (err) {
|
|
console.error('Get skills error:', err);
|
|
res.status(500).json({ error: 'Failed to get skills' });
|
|
}
|
|
});
|
|
|
|
// Get all class skill names (public endpoint)
|
|
app.get('/api/class-skill-names', (req, res) => {
|
|
try {
|
|
const names = db.getAllClassSkillNames();
|
|
// Convert snake_case to camelCase
|
|
const formatted = names.map(n => ({
|
|
id: n.id,
|
|
skillId: n.skill_id,
|
|
classId: n.class_id,
|
|
customName: n.custom_name,
|
|
customDescription: n.custom_description
|
|
}));
|
|
res.json(formatted);
|
|
} catch (err) {
|
|
console.error('Get class skill names error:', err);
|
|
res.status(500).json({ error: 'Failed to get class skill names' });
|
|
}
|
|
});
|
|
|
|
// Get skills for a specific monster type (public endpoint)
|
|
app.get('/api/monster-types/:id/skills', (req, res) => {
|
|
try {
|
|
const skills = db.getMonsterTypeSkills(req.params.id);
|
|
// Convert snake_case to camelCase and parse JSON
|
|
const formatted = skills.map(s => ({
|
|
id: s.id,
|
|
skillId: s.skill_id,
|
|
monsterTypeId: s.monster_type_id,
|
|
weight: s.weight,
|
|
minLevel: s.min_level,
|
|
customName: s.custom_name,
|
|
// Include skill details - use custom_name if set, otherwise base name
|
|
name: s.custom_name || s.name,
|
|
baseName: s.name,
|
|
description: s.description,
|
|
type: s.type,
|
|
mpCost: s.mp_cost,
|
|
basePower: s.base_power,
|
|
accuracy: s.accuracy,
|
|
hitCount: s.hit_count,
|
|
target: s.target,
|
|
statusEffect: s.status_effect ? JSON.parse(s.status_effect) : null
|
|
}));
|
|
res.json(formatted);
|
|
} catch (err) {
|
|
console.error('Get monster skills error:', err);
|
|
res.status(500).json({ error: 'Failed to get monster skills' });
|
|
}
|
|
});
|
|
|
|
// Get monster entourage for current user
|
|
app.get('/api/user/monsters', authenticateToken, (req, res) => {
|
|
try {
|
|
const monsters = db.getMonsterEntourage(req.user.userId);
|
|
// Convert snake_case to camelCase for client
|
|
const formatted = monsters.map(m => ({
|
|
id: m.id,
|
|
type: m.monster_type,
|
|
level: m.level,
|
|
hp: m.hp,
|
|
maxHp: m.max_hp,
|
|
atk: m.atk,
|
|
def: m.def,
|
|
position: m.position_lat && m.position_lng ? {
|
|
lat: m.position_lat,
|
|
lng: m.position_lng
|
|
} : null,
|
|
spawnTime: m.spawn_time,
|
|
lastDialogueTime: m.last_dialogue_time
|
|
}));
|
|
res.json(formatted);
|
|
} catch (err) {
|
|
console.error('Get monsters error:', err);
|
|
res.status(500).json({ error: 'Failed to get monsters' });
|
|
}
|
|
});
|
|
|
|
// Save monster entourage for current user
|
|
app.put('/api/user/monsters', authenticateToken, (req, res) => {
|
|
try {
|
|
const monsters = req.body;
|
|
|
|
if (!Array.isArray(monsters)) {
|
|
return res.status(400).json({ error: 'Invalid monsters data' });
|
|
}
|
|
|
|
db.saveMonsterEntourage(req.user.userId, monsters);
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
console.error('Save monsters error:', err);
|
|
res.status(500).json({ error: 'Failed to save monsters' });
|
|
}
|
|
});
|
|
|
|
// Remove a specific monster (after combat)
|
|
app.delete('/api/user/monsters/:monsterId', authenticateToken, (req, res) => {
|
|
try {
|
|
db.removeMonster(req.user.userId, req.params.monsterId);
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
console.error('Remove monster error:', err);
|
|
res.status(500).json({ error: 'Failed to remove monster' });
|
|
}
|
|
});
|
|
|
|
// ============================================
|
|
// Admin Endpoints
|
|
// ============================================
|
|
|
|
// Serve admin page
|
|
app.get('/admin', (req, res) => {
|
|
res.sendFile(path.join(__dirname, 'admin.html'));
|
|
});
|
|
|
|
// Get all monster types (admin - includes disabled)
|
|
app.get('/api/admin/monster-types', adminOnly, (req, res) => {
|
|
try {
|
|
const types = db.getAllMonsterTypes(false); // Include disabled
|
|
// Return with snake_case for frontend compatibility
|
|
const formatted = types.map(t => ({
|
|
id: t.id,
|
|
key: t.id, // Use id as key for compatibility
|
|
name: t.name,
|
|
icon: t.icon,
|
|
min_level: t.min_level || 1,
|
|
max_level: t.max_level || 5,
|
|
base_hp: t.base_hp,
|
|
base_atk: t.base_atk,
|
|
base_def: t.base_def,
|
|
base_xp: t.xp_reward,
|
|
spawn_weight: t.spawn_weight || 100,
|
|
dialogues: t.dialogues,
|
|
enabled: !!t.enabled,
|
|
created_at: t.created_at
|
|
}));
|
|
res.json({ monsterTypes: formatted });
|
|
} catch (err) {
|
|
console.error('Admin get monster types error:', err);
|
|
res.status(500).json({ error: 'Failed to get monster types' });
|
|
}
|
|
});
|
|
|
|
// Create monster type
|
|
app.post('/api/admin/monster-types', adminOnly, async (req, res) => {
|
|
try {
|
|
const data = req.body;
|
|
// Accept either 'id' or 'key' as the monster identifier
|
|
const monsterId = data.id || data.key;
|
|
if (!monsterId || !data.name) {
|
|
return res.status(400).json({ error: 'Missing required fields (key and name)' });
|
|
}
|
|
// Ensure id is set for the database function
|
|
data.id = monsterId;
|
|
db.createMonsterType(data);
|
|
|
|
// Copy default images for the new monster
|
|
const imgDir = path.join(__dirname, 'mapgameimgs');
|
|
const sizes = ['50', '100'];
|
|
for (const size of sizes) {
|
|
const defaultImg = path.join(imgDir, `default${size}.png`);
|
|
const newImg = path.join(imgDir, `${monsterId}${size}.png`);
|
|
try {
|
|
// Only copy if the new image doesn't already exist
|
|
await fs.access(newImg);
|
|
} catch {
|
|
// File doesn't exist, copy the default
|
|
try {
|
|
await fs.copyFile(defaultImg, newImg);
|
|
console.log(`Created ${monsterId}${size}.png from default`);
|
|
} catch (copyErr) {
|
|
console.warn(`Could not copy default${size}.png:`, copyErr.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
console.error('Admin create monster type error:', err);
|
|
res.status(500).json({ error: 'Failed to create monster type' });
|
|
}
|
|
});
|
|
|
|
// Update monster type
|
|
app.put('/api/admin/monster-types/:id', adminOnly, (req, res) => {
|
|
try {
|
|
const data = req.body;
|
|
db.updateMonsterType(req.params.id, data);
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
console.error('Admin update monster type error:', err);
|
|
res.status(500).json({ error: 'Failed to update monster type' });
|
|
}
|
|
});
|
|
|
|
// Toggle monster enabled status
|
|
app.patch('/api/admin/monster-types/:id/enabled', adminOnly, (req, res) => {
|
|
try {
|
|
const { enabled } = req.body;
|
|
db.toggleMonsterEnabled(req.params.id, enabled);
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
console.error('Admin toggle monster error:', err);
|
|
res.status(500).json({ error: 'Failed to toggle monster' });
|
|
}
|
|
});
|
|
|
|
// Delete monster type
|
|
app.delete('/api/admin/monster-types/:id', adminOnly, (req, res) => {
|
|
try {
|
|
db.deleteMonsterType(req.params.id);
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
console.error('Admin delete monster type error:', err);
|
|
res.status(500).json({ error: 'Failed to delete monster type' });
|
|
}
|
|
});
|
|
|
|
// Get all users
|
|
app.get('/api/admin/users', adminOnly, (req, res) => {
|
|
try {
|
|
const users = db.getAllUsers();
|
|
// Return flat structure with snake_case for frontend compatibility
|
|
const formatted = users.map(u => ({
|
|
id: u.id,
|
|
username: u.username,
|
|
email: u.email,
|
|
created_at: u.created_at,
|
|
total_points: u.total_points,
|
|
finds_count: u.finds_count,
|
|
avatar_icon: u.avatar_icon,
|
|
avatar_color: u.avatar_color,
|
|
is_admin: !!u.is_admin,
|
|
character_name: u.character_name,
|
|
race: u.race,
|
|
class: u.class,
|
|
level: u.level || 1,
|
|
xp: u.xp || 0,
|
|
hp: u.hp || 0,
|
|
max_hp: u.max_hp || 0,
|
|
mp: u.mp || 0,
|
|
max_mp: u.max_mp || 0,
|
|
atk: u.atk || 0,
|
|
def: u.def || 0,
|
|
unlocked_skills: u.unlocked_skills
|
|
}));
|
|
// Debug: log user 2's atk value
|
|
const user2 = formatted.find(u => u.id === 2);
|
|
if (user2) console.log('GET /api/admin/users - user 2 atk:', user2.atk);
|
|
res.json({ users: formatted });
|
|
} catch (err) {
|
|
console.error('Admin get users error:', err);
|
|
res.status(500).json({ error: 'Failed to get users' });
|
|
}
|
|
});
|
|
|
|
// Update user RPG stats
|
|
app.put('/api/admin/users/:id', adminOnly, (req, res) => {
|
|
try {
|
|
const stats = req.body;
|
|
const targetUserId = parseInt(req.params.id);
|
|
console.log('Admin updating user', targetUserId, 'with stats:', JSON.stringify(stats));
|
|
const result = db.updateUserRpgStats(targetUserId, stats);
|
|
console.log('Update result:', result);
|
|
|
|
// Notify the user in real-time to refresh their stats
|
|
const notified = sendToAuthUser(targetUserId, { type: 'statsUpdated' });
|
|
console.log('User notified via WebSocket:', notified);
|
|
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
console.error('Admin update user error:', err);
|
|
res.status(500).json({ error: 'Failed to update user' });
|
|
}
|
|
});
|
|
|
|
// Toggle admin status
|
|
app.put('/api/admin/users/:id/admin', adminOnly, (req, res) => {
|
|
try {
|
|
const { isAdmin } = req.body;
|
|
db.setUserAdmin(req.params.id, isAdmin);
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
console.error('Admin toggle admin error:', err);
|
|
res.status(500).json({ error: 'Failed to toggle admin status' });
|
|
}
|
|
});
|
|
|
|
// Reset user progress
|
|
app.delete('/api/admin/users/:id/reset', adminOnly, (req, res) => {
|
|
try {
|
|
db.resetUserProgress(req.params.id);
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
console.error('Admin reset user error:', err);
|
|
res.status(500).json({ error: 'Failed to reset user progress' });
|
|
}
|
|
});
|
|
|
|
// Get game settings
|
|
app.get('/api/admin/settings', adminOnly, (req, res) => {
|
|
try {
|
|
const settings = db.getAllSettings();
|
|
res.json(settings);
|
|
} catch (err) {
|
|
console.error('Admin get settings error:', err);
|
|
res.status(500).json({ error: 'Failed to get settings' });
|
|
}
|
|
});
|
|
|
|
// Update game settings
|
|
app.put('/api/admin/settings', adminOnly, (req, res) => {
|
|
try {
|
|
const settings = req.body;
|
|
for (const [key, value] of Object.entries(settings)) {
|
|
db.setSetting(key, JSON.stringify(value));
|
|
}
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
console.error('Admin update settings error:', err);
|
|
res.status(500).json({ error: 'Failed to update settings' });
|
|
}
|
|
});
|
|
|
|
// ============================================
|
|
// Admin Skills Endpoints
|
|
// ============================================
|
|
|
|
// Get all skills (admin - includes disabled)
|
|
app.get('/api/admin/skills', adminOnly, (req, res) => {
|
|
try {
|
|
const skills = db.getAllSkills(false); // Include disabled
|
|
const formatted = skills.map(s => ({
|
|
id: s.id,
|
|
name: s.name,
|
|
description: s.description,
|
|
type: s.type,
|
|
mp_cost: s.mp_cost,
|
|
base_power: s.base_power,
|
|
accuracy: s.accuracy,
|
|
hit_count: s.hit_count,
|
|
target: s.target,
|
|
status_effect: s.status_effect,
|
|
player_usable: !!s.player_usable,
|
|
monster_usable: !!s.monster_usable,
|
|
enabled: !!s.enabled,
|
|
created_at: s.created_at
|
|
}));
|
|
res.json({ skills: formatted });
|
|
} catch (err) {
|
|
console.error('Admin get skills error:', err);
|
|
res.status(500).json({ error: 'Failed to get skills' });
|
|
}
|
|
});
|
|
|
|
// Create skill
|
|
app.post('/api/admin/skills', adminOnly, (req, res) => {
|
|
try {
|
|
const data = req.body;
|
|
if (!data.id || !data.name) {
|
|
return res.status(400).json({ error: 'Missing required fields (id and name)' });
|
|
}
|
|
db.createSkill(data);
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
console.error('Admin create skill error:', err);
|
|
res.status(500).json({ error: 'Failed to create skill' });
|
|
}
|
|
});
|
|
|
|
// Update skill
|
|
app.put('/api/admin/skills/:id', adminOnly, (req, res) => {
|
|
try {
|
|
const data = req.body;
|
|
db.updateSkill(req.params.id, data);
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
console.error('Admin update skill error:', err);
|
|
res.status(500).json({ error: 'Failed to update skill' });
|
|
}
|
|
});
|
|
|
|
// Delete skill
|
|
app.delete('/api/admin/skills/:id', adminOnly, (req, res) => {
|
|
try {
|
|
db.deleteSkill(req.params.id);
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
console.error('Admin delete skill error:', err);
|
|
res.status(500).json({ error: 'Failed to delete skill' });
|
|
}
|
|
});
|
|
|
|
// Get all class skill names (admin)
|
|
app.get('/api/admin/class-skill-names', adminOnly, (req, res) => {
|
|
try {
|
|
const names = db.getAllClassSkillNames();
|
|
const formatted = names.map(n => ({
|
|
id: n.id,
|
|
skill_id: n.skill_id,
|
|
class_id: n.class_id,
|
|
custom_name: n.custom_name,
|
|
custom_description: n.custom_description
|
|
}));
|
|
res.json({ classSkillNames: formatted });
|
|
} catch (err) {
|
|
console.error('Admin get class skill names error:', err);
|
|
res.status(500).json({ error: 'Failed to get class skill names' });
|
|
}
|
|
});
|
|
|
|
// Create class skill name
|
|
app.post('/api/admin/class-skill-names', adminOnly, (req, res) => {
|
|
try {
|
|
const data = req.body;
|
|
if (!data.skill_id || !data.class_id || !data.custom_name) {
|
|
return res.status(400).json({ error: 'Missing required fields' });
|
|
}
|
|
db.createClassSkillName(data);
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
console.error('Admin create class skill name error:', err);
|
|
res.status(500).json({ error: 'Failed to create class skill name' });
|
|
}
|
|
});
|
|
|
|
// Update class skill name
|
|
app.put('/api/admin/class-skill-names/:id', adminOnly, (req, res) => {
|
|
try {
|
|
const data = req.body;
|
|
db.updateClassSkillName(req.params.id, data);
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
console.error('Admin update class skill name error:', err);
|
|
res.status(500).json({ error: 'Failed to update class skill name' });
|
|
}
|
|
});
|
|
|
|
// Delete class skill name
|
|
app.delete('/api/admin/class-skill-names/:id', adminOnly, (req, res) => {
|
|
try {
|
|
db.deleteClassSkillName(req.params.id);
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
console.error('Admin delete class skill name error:', err);
|
|
res.status(500).json({ error: 'Failed to delete class skill name' });
|
|
}
|
|
});
|
|
|
|
// Get all monster skills (admin)
|
|
app.get('/api/admin/monster-skills', adminOnly, (req, res) => {
|
|
try {
|
|
const skills = db.getAllMonsterSkills();
|
|
const formatted = skills.map(s => ({
|
|
id: s.id,
|
|
monster_type_id: s.monster_type_id,
|
|
skill_id: s.skill_id,
|
|
weight: s.weight,
|
|
min_level: s.min_level,
|
|
custom_name: s.custom_name
|
|
}));
|
|
res.json({ monsterSkills: formatted });
|
|
} catch (err) {
|
|
console.error('Admin get monster skills error:', err);
|
|
res.status(500).json({ error: 'Failed to get monster skills' });
|
|
}
|
|
});
|
|
|
|
// Create monster skill assignment
|
|
app.post('/api/admin/monster-skills', adminOnly, (req, res) => {
|
|
try {
|
|
const data = req.body;
|
|
if (!data.monster_type_id || !data.skill_id) {
|
|
return res.status(400).json({ error: 'Missing required fields' });
|
|
}
|
|
db.createMonsterSkill(data);
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
console.error('Admin create monster skill error:', err);
|
|
res.status(500).json({ error: 'Failed to create monster skill' });
|
|
}
|
|
});
|
|
|
|
// Update monster skill assignment
|
|
app.put('/api/admin/monster-skills/:id', adminOnly, (req, res) => {
|
|
try {
|
|
const data = req.body;
|
|
db.updateMonsterSkill(req.params.id, data);
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
console.error('Admin update monster skill error:', err);
|
|
res.status(500).json({ error: 'Failed to update monster skill' });
|
|
}
|
|
});
|
|
|
|
// Delete monster skill assignment
|
|
app.delete('/api/admin/monster-skills/:id', adminOnly, (req, res) => {
|
|
try {
|
|
db.deleteMonsterSkill(req.params.id);
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
console.error('Admin delete monster skill error:', err);
|
|
res.status(500).json({ error: 'Failed to delete monster skill' });
|
|
}
|
|
});
|
|
|
|
// =====================
|
|
// CLASS API ENDPOINTS
|
|
// =====================
|
|
|
|
// Get all classes (public - enabled only, for character creation)
|
|
app.get('/api/classes', (req, res) => {
|
|
try {
|
|
const classes = db.getAllClasses(true); // Enabled only
|
|
res.json(classes);
|
|
} catch (err) {
|
|
console.error('Get classes error:', err);
|
|
res.status(500).json({ error: 'Failed to get classes' });
|
|
}
|
|
});
|
|
|
|
// Get class skills (public - for character sheet display)
|
|
app.get('/api/classes/:id/skills', (req, res) => {
|
|
try {
|
|
const skills = db.getClassSkills(req.params.id);
|
|
res.json(skills);
|
|
} catch (err) {
|
|
console.error('Get class skills error:', err);
|
|
res.status(500).json({ error: 'Failed to get class skills' });
|
|
}
|
|
});
|
|
|
|
// Get skill choices for a specific level (for level-up modal)
|
|
app.get('/api/classes/:id/skill-choices/:level', (req, res) => {
|
|
try {
|
|
const choices = db.getSkillChoicesForLevel(req.params.id, parseInt(req.params.level));
|
|
res.json(choices);
|
|
} catch (err) {
|
|
console.error('Get skill choices error:', err);
|
|
res.status(500).json({ error: 'Failed to get skill choices' });
|
|
}
|
|
});
|
|
|
|
// Admin: Get all classes (including disabled)
|
|
app.get('/api/admin/classes', adminOnly, (req, res) => {
|
|
try {
|
|
const classes = db.getAllClasses(false); // All classes
|
|
res.json(classes);
|
|
} catch (err) {
|
|
console.error('Admin get classes error:', err);
|
|
res.status(500).json({ error: 'Failed to get classes' });
|
|
}
|
|
});
|
|
|
|
// Admin: Get single class
|
|
app.get('/api/admin/classes/:id', adminOnly, (req, res) => {
|
|
try {
|
|
const classData = db.getClass(req.params.id);
|
|
if (!classData) {
|
|
return res.status(404).json({ error: 'Class not found' });
|
|
}
|
|
res.json(classData);
|
|
} catch (err) {
|
|
console.error('Admin get class error:', err);
|
|
res.status(500).json({ error: 'Failed to get class' });
|
|
}
|
|
});
|
|
|
|
// Admin: Create class
|
|
app.post('/api/admin/classes', adminOnly, (req, res) => {
|
|
try {
|
|
db.createClass(req.body);
|
|
const newClass = db.getClass(req.body.id);
|
|
res.json(newClass);
|
|
} catch (err) {
|
|
console.error('Admin create class error:', err);
|
|
res.status(500).json({ error: 'Failed to create class' });
|
|
}
|
|
});
|
|
|
|
// Admin: Update class
|
|
app.put('/api/admin/classes/:id', adminOnly, (req, res) => {
|
|
try {
|
|
db.updateClass(req.params.id, req.body);
|
|
const updated = db.getClass(req.params.id);
|
|
res.json(updated);
|
|
} catch (err) {
|
|
console.error('Admin update class error:', err);
|
|
res.status(500).json({ error: 'Failed to update class' });
|
|
}
|
|
});
|
|
|
|
// Admin: Delete class
|
|
app.delete('/api/admin/classes/:id', adminOnly, (req, res) => {
|
|
try {
|
|
db.deleteClass(req.params.id);
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
console.error('Admin delete class error:', err);
|
|
res.status(500).json({ error: 'Failed to delete class' });
|
|
}
|
|
});
|
|
|
|
// Admin: Toggle class enabled
|
|
app.put('/api/admin/classes/:id/toggle', adminOnly, (req, res) => {
|
|
try {
|
|
const classData = db.getClass(req.params.id);
|
|
if (!classData) {
|
|
return res.status(404).json({ error: 'Class not found' });
|
|
}
|
|
db.toggleClassEnabled(req.params.id, !classData.enabled);
|
|
res.json({ success: true, enabled: !classData.enabled });
|
|
} catch (err) {
|
|
console.error('Admin toggle class error:', err);
|
|
res.status(500).json({ error: 'Failed to toggle class' });
|
|
}
|
|
});
|
|
|
|
// =====================
|
|
// CLASS SKILLS API ENDPOINTS
|
|
// =====================
|
|
|
|
// Admin: Get all class skills
|
|
app.get('/api/admin/class-skills', adminOnly, (req, res) => {
|
|
try {
|
|
const classSkills = db.getAllClassSkills();
|
|
res.json(classSkills);
|
|
} catch (err) {
|
|
console.error('Admin get class skills error:', err);
|
|
res.status(500).json({ error: 'Failed to get class skills' });
|
|
}
|
|
});
|
|
|
|
// Admin: Get skills for a specific class
|
|
app.get('/api/admin/class-skills/:classId', adminOnly, (req, res) => {
|
|
try {
|
|
const skills = db.getClassSkills(req.params.classId);
|
|
res.json(skills);
|
|
} catch (err) {
|
|
console.error('Admin get class skills error:', err);
|
|
res.status(500).json({ error: 'Failed to get class skills' });
|
|
}
|
|
});
|
|
|
|
// Admin: Create class skill
|
|
app.post('/api/admin/class-skills', adminOnly, (req, res) => {
|
|
try {
|
|
db.createClassSkill(req.body);
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
console.error('Admin create class skill error:', err);
|
|
res.status(500).json({ error: 'Failed to create class skill' });
|
|
}
|
|
});
|
|
|
|
// Admin: Update class skill
|
|
app.put('/api/admin/class-skills/:id', adminOnly, (req, res) => {
|
|
try {
|
|
db.updateClassSkill(req.params.id, req.body);
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
console.error('Admin update class skill error:', err);
|
|
res.status(500).json({ error: 'Failed to update class skill' });
|
|
}
|
|
});
|
|
|
|
// Admin: Delete class skill
|
|
app.delete('/api/admin/class-skills/:id', adminOnly, (req, res) => {
|
|
try {
|
|
db.deleteClassSkill(req.params.id);
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
console.error('Admin delete class skill error:', err);
|
|
res.status(500).json({ error: 'Failed to delete class skill' });
|
|
}
|
|
});
|
|
|
|
// Function to send push notification to all subscribers
|
|
async function sendPushNotification(title, body, data = {}) {
|
|
const notification = {
|
|
title,
|
|
body,
|
|
icon: '/icon-192x192.png',
|
|
badge: '/icon-72x72.png',
|
|
data: {
|
|
...data,
|
|
timestamp: Date.now()
|
|
}
|
|
};
|
|
|
|
const promises = pushSubscriptions.map(subscription => {
|
|
return webpush.sendNotification(subscription, JSON.stringify(notification))
|
|
.catch(err => {
|
|
console.error('Push failed for:', subscription.endpoint, err.message);
|
|
// Remove failed subscriptions
|
|
if (err.statusCode === 410) {
|
|
pushSubscriptions = pushSubscriptions.filter(
|
|
sub => sub.endpoint !== subscription.endpoint
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
await Promise.all(promises);
|
|
console.log(`Sent push notification: ${title}`);
|
|
}
|
|
|
|
// Load push subscriptions on startup
|
|
async function loadPushSubscriptions() {
|
|
try {
|
|
const dataDir = await getGeocachePath();
|
|
const subsPath = path.join(path.dirname(dataDir), 'push-subscriptions.json');
|
|
const data = await fs.readFile(subsPath, 'utf8');
|
|
pushSubscriptions = JSON.parse(data);
|
|
console.log(`Loaded ${pushSubscriptions.length} push subscriptions`);
|
|
} catch (err) {
|
|
if (err.code !== 'ENOENT') {
|
|
console.error('Error loading push subscriptions:', err);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Generate random user ID
|
|
function generateUserId() {
|
|
return Math.random().toString(36).substring(7);
|
|
}
|
|
|
|
// Broadcast to all clients except sender
|
|
function broadcast(data, senderId) {
|
|
const message = JSON.stringify(data);
|
|
wss.clients.forEach(client => {
|
|
if (client.readyState === WebSocket.OPEN && client.userId !== senderId) {
|
|
client.send(message);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Map authenticated user IDs to WebSocket connections for targeted messages
|
|
const authUserConnections = new Map(); // authUserId (number) -> ws connection
|
|
|
|
// Send message to a specific authenticated user
|
|
function sendToAuthUser(authUserId, data) {
|
|
const ws = authUserConnections.get(authUserId);
|
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify(data));
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Clean up disconnected user
|
|
function removeUser(userId) {
|
|
if (users.has(userId)) {
|
|
users.delete(userId);
|
|
broadcast({ type: 'userDisconnected', userId }, null);
|
|
console.log(`User ${userId} disconnected. Active users: ${users.size}`);
|
|
}
|
|
}
|
|
|
|
wss.on('connection', (ws) => {
|
|
const userId = generateUserId();
|
|
ws.userId = userId;
|
|
|
|
console.log(`User ${userId} connected. Active users: ${users.size + 1}`);
|
|
|
|
// Send user their ID, current visible users, and geocaches
|
|
const initMsg = {
|
|
type: 'init',
|
|
userId: userId,
|
|
users: Array.from(users.entries())
|
|
.filter(([id, data]) => data.visible !== false) // Only send visible users
|
|
.map(([id, data]) => ({
|
|
userId: id,
|
|
...data
|
|
}))
|
|
};
|
|
console.log(`Sending init message to ${userId}`);
|
|
ws.send(JSON.stringify(initMsg));
|
|
|
|
// Send all geocaches
|
|
if (geocaches.length > 0) {
|
|
console.log(`Sending ${geocaches.length} geocaches to ${userId}`);
|
|
ws.send(JSON.stringify({
|
|
type: 'geocachesInit',
|
|
geocaches: geocaches
|
|
}));
|
|
} else {
|
|
console.log('No geocaches to send');
|
|
}
|
|
|
|
ws.on('message', (message) => {
|
|
try {
|
|
const data = JSON.parse(message);
|
|
|
|
if (data.type === 'auth') {
|
|
// Register authenticated user's WebSocket connection
|
|
if (data.authUserId) {
|
|
ws.authUserId = data.authUserId;
|
|
authUserConnections.set(data.authUserId, ws);
|
|
console.log(`Auth user ${data.authUserId} registered on WebSocket ${userId}`);
|
|
}
|
|
} else if (data.type === 'location') {
|
|
// Store user location with icon info
|
|
users.set(userId, {
|
|
lat: data.lat,
|
|
lng: data.lng,
|
|
accuracy: data.accuracy,
|
|
icon: data.icon,
|
|
color: data.color,
|
|
visible: data.visible !== false, // default to true if not specified
|
|
timestamp: Date.now()
|
|
});
|
|
|
|
// Broadcast to other users (including visibility status)
|
|
broadcast({
|
|
type: 'userLocation',
|
|
userId: userId,
|
|
lat: data.lat,
|
|
lng: data.lng,
|
|
accuracy: data.accuracy,
|
|
icon: data.icon,
|
|
color: data.color,
|
|
visible: data.visible !== false
|
|
}, userId);
|
|
} else if (data.type === 'iconUpdate') {
|
|
// Update user's icon
|
|
const userData = users.get(userId) || {};
|
|
userData.icon = data.icon;
|
|
userData.color = data.color;
|
|
users.set(userId, userData);
|
|
|
|
// Broadcast icon update to other users if we have location and are visible
|
|
if (userData.lat && userData.lng && userData.visible !== false) {
|
|
broadcast({
|
|
type: 'userLocation',
|
|
userId: userId,
|
|
lat: userData.lat,
|
|
lng: userData.lng,
|
|
accuracy: userData.accuracy || 100,
|
|
icon: data.icon,
|
|
color: data.color,
|
|
visible: true
|
|
}, userId);
|
|
}
|
|
} else if (data.type === 'geocacheUpdate') {
|
|
// Handle geocache creation/update
|
|
if (data.geocache) {
|
|
const existingIndex = geocaches.findIndex(g => g.id === data.geocache.id);
|
|
if (existingIndex >= 0) {
|
|
// Update existing geocache
|
|
geocaches[existingIndex] = data.geocache;
|
|
} else {
|
|
// Add new geocache
|
|
geocaches.push(data.geocache);
|
|
|
|
// Send push notification for new geocache
|
|
sendPushNotification(
|
|
'📍 New Geocache!',
|
|
'A new geocache has been placed nearby',
|
|
{
|
|
type: 'geocache',
|
|
geocacheId: data.geocache.id,
|
|
lat: data.geocache.lat,
|
|
lng: data.geocache.lng
|
|
}
|
|
);
|
|
}
|
|
|
|
// Save to file
|
|
saveGeocaches();
|
|
|
|
// Broadcast to all other users
|
|
broadcast({
|
|
type: 'geocacheUpdate',
|
|
geocache: data.geocache
|
|
}, userId);
|
|
}
|
|
} else if (data.type === 'geocacheDelete') {
|
|
// Handle geocache deletion
|
|
if (data.geocacheId) {
|
|
const index = geocaches.findIndex(g => g.id === data.geocacheId);
|
|
if (index > -1) {
|
|
geocaches.splice(index, 1);
|
|
|
|
// Save to file
|
|
saveGeocaches();
|
|
|
|
// Broadcast deletion to all other users
|
|
broadcast({
|
|
type: 'geocacheDelete',
|
|
geocacheId: data.geocacheId
|
|
}, userId);
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('Error processing message:', err);
|
|
}
|
|
});
|
|
|
|
ws.on('close', () => {
|
|
removeUser(userId);
|
|
// Clean up auth user mapping
|
|
if (ws.authUserId) {
|
|
authUserConnections.delete(ws.authUserId);
|
|
}
|
|
});
|
|
|
|
ws.on('error', (err) => {
|
|
console.error(`WebSocket error for user ${userId}:`, err);
|
|
removeUser(userId);
|
|
if (ws.authUserId) {
|
|
authUserConnections.delete(ws.authUserId);
|
|
}
|
|
});
|
|
|
|
// Heartbeat to detect disconnected clients
|
|
ws.isAlive = true;
|
|
ws.on('pong', () => {
|
|
ws.isAlive = true;
|
|
});
|
|
});
|
|
|
|
// Periodic cleanup of stale connections
|
|
const heartbeatInterval = setInterval(() => {
|
|
wss.clients.forEach(ws => {
|
|
if (ws.isAlive === false) {
|
|
removeUser(ws.userId);
|
|
return ws.terminate();
|
|
}
|
|
ws.isAlive = false;
|
|
ws.ping();
|
|
});
|
|
}, 30000);
|
|
|
|
wss.on('close', () => {
|
|
clearInterval(heartbeatInterval);
|
|
});
|
|
|
|
const PORT = process.env.PORT || 8080;
|
|
server.listen(PORT, async () => {
|
|
console.log(`Server running on port ${PORT}`);
|
|
console.log(`Open http://localhost:${PORT} to view the map`);
|
|
|
|
// Initialize database
|
|
try {
|
|
let dbPath;
|
|
try {
|
|
await fs.access('/app/data');
|
|
dbPath = '/app/data/hikemap.db';
|
|
} catch (err) {
|
|
dbPath = path.join(__dirname, 'data', 'hikemap.db');
|
|
// Create data directory if it doesn't exist
|
|
await fs.mkdir(path.dirname(dbPath), { recursive: true });
|
|
}
|
|
db = new HikeMapDB(dbPath).init();
|
|
console.log('Database initialized');
|
|
|
|
// Seed default monsters if they don't exist
|
|
db.seedDefaultMonsters();
|
|
|
|
// Seed default skills if they don't exist
|
|
db.seedDefaultSkills();
|
|
|
|
// Seed default classes if they don't exist
|
|
db.seedDefaultClasses();
|
|
|
|
// Seed default game settings if they don't exist
|
|
db.seedDefaultSettings();
|
|
|
|
// Clean expired tokens periodically
|
|
setInterval(() => {
|
|
try {
|
|
db.cleanExpiredTokens();
|
|
} catch (err) {
|
|
console.error('Error cleaning expired tokens:', err);
|
|
}
|
|
}, 60 * 60 * 1000); // Every hour
|
|
} catch (err) {
|
|
console.error('Failed to initialize database:', err);
|
|
}
|
|
|
|
// Load geocaches on startup
|
|
await loadGeocaches();
|
|
|
|
// Load push subscriptions on startup
|
|
await loadPushSubscriptions();
|
|
});
|