feat: Improve Account System, Add Personalized Carousel, Improve Plugin Format

This commit is contained in:
ibratabian17
2025-06-07 22:41:56 +07:00
parent 77aee28c95
commit 96546a4d6f
27 changed files with 6398 additions and 221 deletions

View File

@@ -8,6 +8,7 @@ const cClass = require("./classList.json");
const settings = require('../../settings.json'); const settings = require('../../settings.json');
const SongService = require('../services/SongService'); const SongService = require('../services/SongService');
const MostPlayedService = require('../services/MostPlayedService'); const MostPlayedService = require('../services/MostPlayedService');
const AccountService = require('../services/AccountService'); // Import AccountService
let carousel = {}; //avoid list cached let carousel = {}; //avoid list cached
@@ -92,17 +93,122 @@ function addJDVersion(songMapNames, type = "partyMap") {
addCategories(generateCategories("Unplayable Songs", SongService.filterSongsByJDVersion(songMapNames, 404))); addCategories(generateCategories("Unplayable Songs", SongService.filterSongsByJDVersion(songMapNames, 404)));
} }
exports.generateCarousel = async (search, type = "partyMap") => { exports.generateCarousel = async (search, type = "partyMap", profileId = null) => {
carousel = {}; carousel = {};
carousel = CloneObject(cClass.rootClass); carousel = CloneObject(cClass.rootClass);
carousel.actionLists = cClass.actionListsClass; carousel.actionLists = cClass.actionListsClass;
const allSongMapNames = SongService.getAllMapNames(); const allSongMapNames = SongService.getAllMapNames();
// Dynamic Carousel System // Dynamic Carousel System
addCategories(generateCategories(settings.server.modName, allSongMapNames, type)); addCategories(generateCategories(settings.server.modName, CloneObject(shuffleArray(allSongMapNames)), type)); // Shuffle main category
addCategories(generateCategories("Recommended For You", CloneObject(shuffleArray(allSongMapNames), type)));
let userProfile = null;
if (profileId) {
userProfile = await AccountService.getUserData(profileId);
}
const allSongsData = SongService.getAllSongs(); // Get all song details once
if (userProfile) {
let recommendedSongs = [];
// 1. "Recommended For You (Based on Your Plays)"
if (userProfile.history && Object.keys(userProfile.history).length > 0) {
recommendedSongs = Object.entries(userProfile.history)
.sort(([, countA], [, countB]) => countB - countA) // Sort by play count desc
.map(([mapName]) => mapName)
.slice(0, 24);
} else if (userProfile.scores && Object.keys(userProfile.scores).length > 0) {
// Fallback to scores if history is not available or empty
recommendedSongs = Object.entries(userProfile.scores)
.filter(([, scoreData]) => scoreData && typeof scoreData.timesPlayed === 'number')
.sort(([, scoreA], [, scoreB]) => scoreB.timesPlayed - scoreA.timesPlayed)
.map(([mapName]) => mapName)
.slice(0, 24);
}
if (recommendedSongs.length > 0) {
addCategories(generateCategories("Recommended For You", CloneObject(recommendedSongs), type));
} else {
// Fallback if no play history or scores with timesPlayed
addCategories(generateCategories("Recommended For You", CloneObject(shuffleArray(allSongMapNames)), type));
}
// 2. "More from Artists You Enjoy"
const artistCounts = {};
const playedAndFavoritedSongs = new Set([
...(userProfile.history ? Object.keys(userProfile.history) : []),
...(userProfile.favorites ? Object.keys(userProfile.favorites) : [])
]);
playedAndFavoritedSongs.forEach(mapName => {
const song = allSongsData[mapName];
if (song && song.artist) {
artistCounts[song.artist] = (artistCounts[song.artist] || 0) +
(userProfile.history?.[mapName] || 1); // Weight by play count or 1 for favorite
}
});
const topArtists = Object.entries(artistCounts)
.sort(([, countA], [, countB]) => countB - countA)
.slice(0, 3) // Get top 3 artists
.map(([artist]) => artist);
topArtists.forEach(artistName => {
const artistSongs = allSongMapNames.filter(mapName => {
const song = allSongsData[mapName];
return song && song.artist === artistName && !playedAndFavoritedSongs.has(mapName); // Exclude already prominent songs
});
if (artistSongs.length > 0) {
addCategories(generateCategories(`[icon:ARTIST] More from ${artistName}`, CloneObject(shuffleArray(artistSongs)).slice(0,12), type));
}
});
// 3. "Because You Liked..."
const favoriteMaps = Object.keys(userProfile.favorites || {});
if (favoriteMaps.length > 0) {
const shuffledFavorites = shuffleArray(favoriteMaps);
const numBecauseYouLiked = Math.min(shuffledFavorites.length, 2); // Max 2 "Because you liked" categories
for (let i = 0; i < numBecauseYouLiked; i++) {
const favMapName = shuffledFavorites[i];
const favSong = allSongsData[favMapName];
if (!favSong) continue;
let relatedSongs = [];
// Related by artist
allSongMapNames.forEach(mapName => {
const song = allSongsData[mapName];
if (song && song.artist === favSong.artist && mapName !== favMapName && !favoriteMaps.includes(mapName)) {
relatedSongs.push(mapName);
}
});
// Related by original JD version
allSongMapNames.forEach(mapName => {
const song = allSongsData[mapName];
if (song && song.originalJDVersion === favSong.originalJDVersion && mapName !== favMapName && !favoriteMaps.includes(mapName) && !relatedSongs.includes(mapName)) {
relatedSongs.push(mapName);
}
});
if (relatedSongs.length > 0) {
addCategories(generateCategories(`[icon:HEART] Because You Liked ${favSong.title}`, CloneObject(shuffleArray(relatedSongs)).slice(0, 10), type));
}
}
// Original "Your Favorites" category
addCategories(generateCategories("[icon:FAVORITE] Your Favorites", CloneObject(favoriteMaps), type));
}
// Your Recently Played
const recentlyPlayedMaps = (userProfile.songsPlayed || []).slice(-24).reverse(); // Get last 24 played, most recent first
if (recentlyPlayedMaps.length > 0) {
addCategories(generateCategories("[icon:HISTORY] Your Recently Played", CloneObject(recentlyPlayedMaps), type));
}
} else {
// Fallback for non-logged in users or no profileId
addCategories(generateCategories("Recommended For You", CloneObject(shuffleArray(allSongMapNames)), type));
}
addCategories(generateCategories("[icon:PLAYLIST]Recently Added!", CloneObject(SongService.filterSongsByTags(allSongMapNames, 'NEW')), type)); addCategories(generateCategories("[icon:PLAYLIST]Recently Added!", CloneObject(SongService.filterSongsByTags(allSongMapNames, 'NEW')), type));
const path = require('path');
await generateWeeklyRecommendedSong(loadJsonFile('carousel/playlist.json', '../database/data/carousel/playlist.json'), type); await generateWeeklyRecommendedSong(loadJsonFile('carousel/playlist.json', '../database/data/carousel/playlist.json'), type);
processPlaylists(loadJsonFile('carousel/playlist.json', '../database/data/carousel/playlist.json'), type); processPlaylists(loadJsonFile('carousel/playlist.json', '../database/data/carousel/playlist.json'), type);
addJDVersion(allSongMapNames, type); addJDVersion(allSongMapNames, type);

View File

@@ -7,7 +7,7 @@ const { resolvePath } = require('../helper');
const PluginManager = require('./PluginManager'); const PluginManager = require('./PluginManager');
const Router = require('./Router'); const Router = require('./Router');
const ErrorHandler = require('./ErrorHandler'); const ErrorHandler = require('./ErrorHandler');
const bodyParser = require('body-parser'); const express = require('express'); // bodyParser is part of express now
const requestIp = require('../lib/ipResolver.js'); const requestIp = require('../lib/ipResolver.js');
const Logger = require('../utils/logger'); const Logger = require('../utils/logger');
@@ -20,6 +20,7 @@ class Core {
this.settings = settings; this.settings = settings;
this.pluginManager = new PluginManager(); this.pluginManager = new PluginManager();
this.router = new Router(); this.router = new Router();
this.appInstance = null; // To store app instance for plugins if needed
this.logger = new Logger('CORE'); this.logger = new Logger('CORE');
} }
@@ -31,6 +32,7 @@ class Core {
*/ */
async init(app, express, server) { async init(app, express, server) {
this.logger.info('Initializing core...'); this.logger.info('Initializing core...');
this.appInstance = app; // Store app instance
// Initialize the database // Initialize the database
const { initializeDatabase } = require('../database/sqlite'); const { initializeDatabase } = require('../database/sqlite');
@@ -42,8 +44,11 @@ class Core {
process.exit(1); // Exit if database cannot be initialized process.exit(1); // Exit if database cannot be initialized
} }
// Set pluginManager on the app instance so plugins can access it
app.set('pluginManager', this.pluginManager);
// Configure middleware // Configure middleware
this.configureMiddleware(app, express); this.configureMiddleware(app); // express module not needed here anymore
// Load plugins // Load plugins
this.pluginManager.loadPlugins(this.settings.modules); this.pluginManager.loadPlugins(this.settings.modules);
@@ -68,9 +73,10 @@ class Core {
* @param {Express} app - The Express application instance * @param {Express} app - The Express application instance
* @param {Express} express - The Express module * @param {Express} express - The Express module
*/ */
configureMiddleware(app, express) { configureMiddleware(app) {
app.use(express.json()); app.use(express.json());
app.use(bodyParser.raw()); app.use(express.urlencoded({ extended: true })); // Added for form data
// app.use(express.raw()); // If you need raw body parsing, uncomment this and ensure AdminPanelPlugin doesn't re-add it.
app.use(requestIp.mw()); app.use(requestIp.mw());
// Use centralized error handler // Use centralized error handler

View File

@@ -14,7 +14,8 @@ class Plugin {
this.name = name; this.name = name;
this.description = description; this.description = description;
this.enabled = true; this.enabled = true;
this.logger = new Logger(name); // Use plugin name as module name for logger this.logger = new Logger(name); // Use name as module name for logger
this.manifest = null; // To store manifest data
} }
/** /**

View File

@@ -4,8 +4,7 @@
*/ */
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const Plugin = require('./Plugin'); // This is the Plugin class PluginManager uses for comparison const Plugin = require('./Plugin');
const { resolvePath } = require('../helper');
const Logger = require('../utils/logger'); const Logger = require('../utils/logger');
class PluginManager { class PluginManager {
@@ -19,37 +18,86 @@ class PluginManager {
/** /**
* Load plugins from settings * Load plugins from settings
* @param {Object} modules - The modules configuration from settings.json
* @returns {Map} The loaded plugins * @returns {Map} The loaded plugins
*/ */
loadPlugins(modules) { loadPlugins() {
this.logger.info('Loading plugins...'); this.logger.info('Loading plugins from plugins directory...');
this.plugins.clear(); // Clear existing plugins before reloading
// Log the Plugin class that PluginManager is using for comparison const pluginsDir = path.resolve(__dirname, '../../plugins');
this.logger.info('Plugin class used for comparison:', Plugin.name);
if (!fs.existsSync(pluginsDir)) {
this.logger.warn(`Plugins directory not found: ${pluginsDir}`);
return this.plugins;
}
const pluginFolders = fs.readdirSync(pluginsDir, { withFileTypes: true })
.filter(dirent => dirent.isDirectory())
.map(dirent => dirent.name);
pluginFolders.forEach(folderName => {
const pluginFolderPath = path.join(pluginsDir, folderName);
const manifestPath = path.join(pluginFolderPath, 'manifest.json');
if (!fs.existsSync(manifestPath)) {
this.logger.warn(`Manifest.json not found in plugin folder: ${folderName}. Skipping.`);
return;
}
modules.forEach((item) => {
try { try {
const plugin = require(resolvePath(item.path)); const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
// Log the Plugin class that the loaded plugin is extending if (!manifest.name || !manifest.main || !manifest.execution) {
this.logger.info(`Loaded plugin '${item.path}' extends:`, Object.getPrototypeOf(plugin.constructor).name); this.logger.error(`Invalid manifest.json in ${folderName}: Missing name, main, or execution. Skipping.`);
return;
}
// Verify that the plugin extends the Plugin class const mainPluginFile = path.join(pluginFolderPath, manifest.main);
if (plugin instanceof Plugin) { if (!fs.existsSync(mainPluginFile)) {
this.plugins.set(plugin.name, plugin); this.logger.error(`Main plugin file '${manifest.main}' not found in ${folderName} at ${mainPluginFile}. Skipping.`);
this.logger.info(`Loaded plugin: ${plugin.name}`); return;
}
const pluginInstance = require(mainPluginFile);
if (pluginInstance instanceof Plugin) {
const originalPluginName = pluginInstance.name;
pluginInstance.manifest = manifest;
pluginInstance.name = manifest.name; // Override name from manifest
pluginInstance.description = manifest.description || pluginInstance.description; // Override description
// If the name was overridden by the manifest, update the logger instance to use the new name
if (pluginInstance.logger.moduleName !== manifest.name) {
this.logger.info(`Plugin class-defined name ('${originalPluginName}') differs from manifest ('${manifest.name}') for plugin in folder '${folderName}'. Updating logger to use manifest name '${manifest.name}'.`);
pluginInstance.logger = new Logger(manifest.name); // Re-initialize logger with manifest name
}
this.plugins.set(manifest.name, pluginInstance);
this.logger.info(`Loaded plugin: ${manifest.name} (v${manifest.version || 'N/A'}) from ${folderName}`);
} else { } else {
this.logger.error(`Error: ${item.path} is not a valid plugin. It does not extend the expected 'Plugin' class.`); this.logger.error(`Error: ${mainPluginFile} from ${folderName} is not a valid plugin. It does not extend the 'Plugin' class.`);
// Provide more detail if the instanceof check fails
this.logger.error(`Expected Plugin constructor:`, Plugin);
this.logger.error(`Actual plugin's prototype chain constructor:`, Object.getPrototypeOf(plugin.constructor));
} }
} catch (error) { } catch (error) {
this.logger.error(`Error loading plugin ${item.path}: ${error.message}`); this.logger.error(`Error loading plugin from ${folderName}: ${error.message}\n${error.stack}`);
} }
}); });
// Process overrides
const pluginsToOverride = new Set();
this.plugins.forEach(pInstance => {
if (pInstance.manifest && Array.isArray(pInstance.manifest.override)) {
pInstance.manifest.override.forEach(pluginNameToOverride => {
pluginsToOverride.add(pluginNameToOverride);
});
}
});
pluginsToOverride.forEach(pluginNameToOverride => {
if (this.plugins.has(pluginNameToOverride) && this.plugins.get(pluginNameToOverride).isEnabled()) {
this.logger.info(`Plugin '${pluginNameToOverride}' is being overridden and will be disabled by another plugin.`);
this.plugins.get(pluginNameToOverride).disable();
}
});
return this.plugins; return this.plugins;
} }
@@ -61,23 +109,21 @@ class PluginManager {
initializePlugins(app, executionType) { initializePlugins(app, executionType) {
this.logger.info(`Initializing ${executionType} plugins...`); this.logger.info(`Initializing ${executionType} plugins...`);
this.plugins.forEach((plugin) => { this.plugins.forEach((pluginInstance) => {
// Assuming isEnabled() exists on the Plugin base class or is handled otherwise if (pluginInstance.manifest && pluginInstance.isEnabled && pluginInstance.isEnabled()) {
if (plugin.isEnabled && plugin.isEnabled()) {
try { try {
// Get the plugin's configuration from settings.json if (pluginInstance.manifest.execution === executionType) {
const pluginConfig = this.getPluginConfig(plugin.name); this.logger.info(`Calling initroute for plugin: ${pluginInstance.name} (Execution Type: ${executionType})`);
if (pluginConfig && pluginConfig.execution === executionType) { pluginInstance.initroute(app);
this.logger.info(`Calling initroute for plugin: ${plugin.name} (Execution Type: ${executionType})`);
plugin.initroute(app);
} else { } else {
this.logger.info(`Skipping plugin ${plugin.name}: Execution type mismatch or no config.`); // This log can be verbose, uncomment if needed for debugging
// this.logger.info(`Skipping plugin ${pluginInstance.name}: Execution type mismatch (Plugin: ${pluginInstance.manifest.execution}, Required: ${executionType}).`);
} }
} catch (error) { } catch (error) {
this.logger.error(`Error initializing plugin ${plugin.name}: ${error.message}`); this.logger.error(`Error initializing plugin ${pluginInstance.name}: ${error.message}\n${error.stack}`);
} }
} else { } else if (pluginInstance.manifest && (!pluginInstance.isEnabled || !pluginInstance.isEnabled())) {
this.logger.info(`Skipping disabled plugin: ${plugin.name}`); this.logger.info(`Skipping disabled plugin: ${pluginInstance.name}`);
} }
}); });
} }
@@ -98,24 +144,6 @@ class PluginManager {
getPlugins() { getPlugins() {
return this.plugins; return this.plugins;
} }
/**
* Get the configuration for a plugin from settings.json
* @param {string} name - The name of the plugin
* @returns {Object|null} The plugin configuration or null if not found
*/
getPluginConfig(name) {
// IMPORTANT: Adjust this path if your settings.json is not located relative to PluginManager.js
// For example, if PluginManager is in 'core/classes' and settings.json is in the root,
// '../../settings.json' is likely correct.
try {
const settings = require('../../settings.json');
return settings.modules.find(module => module.name === name) || null;
} catch (error) {
this.logger.error(`Error loading settings.json: ${error.message}`);
return null;
}
}
} }
module.exports = PluginManager; module.exports = PluginManager;

View File

@@ -4,10 +4,11 @@
*/ */
const axios = require('axios'); const axios = require('axios');
const RouteHandler = require('./RouteHandler'); // Assuming RouteHandler is in the same directory const RouteHandler = require('./RouteHandler'); // Assuming RouteHandler is in the same directory
const { updateMostPlayed } = require('../../carousel/carousel'); // Adjust path as needed const MostPlayedService = require('../../services/MostPlayedService');
const AccountService = require('../../services/AccountService'); // Import the AccountService const AccountService = require('../../services/AccountService'); // Import the AccountService
const { getDb } = require('../../database/sqlite'); const { getDb } = require('../../database/sqlite');
const Logger = require('../../utils/logger'); const Logger = require('../../utils/logger');
const { v4: uuidv4 } = require('uuid'); // Import uuid for generating new profile IDs
class AccountRouteHandler extends RouteHandler { class AccountRouteHandler extends RouteHandler {
/** /**
@@ -122,6 +123,66 @@ class AccountRouteHandler extends RouteHandler {
return AccountService.getUserData(profileId); return AccountService.getUserData(profileId);
} }
/**
* Helper: Read the last reset week from the database.
* @returns {Promise<number>} The last reset week number.
* @private
*/
async readLastResetWeek() {
const db = getDb();
return new Promise((resolve, reject) => {
db.get('SELECT value FROM config WHERE key = ?', ['last_reset_week'], (err, row) => {
if (err) {
this.logger.error(`Error reading last_reset_week from DB: ${err.message}`);
reject(err);
} else {
resolve(row ? parseInt(row.value, 10) : 0); // Default to 0 if not found
}
});
});
}
/**
* Helper: Write the current week to the database.
* @param {number} weekNumber - The current week number.
* @returns {Promise<void>}
* @private
*/
async writeLastResetWeek(weekNumber) {
const db = getDb();
return new Promise((resolve, reject) => {
db.run('INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)', ['last_reset_week', weekNumber.toString()], (err) => {
if (err) {
this.logger.error(`Error writing last_reset_week to DB: ${err.message}`);
reject(err);
} else {
this.logger.info(`Updated last_reset_week in DB to week ${weekNumber}`);
resolve();
}
});
});
}
/**
* Helper: Clear all entries from the main leaderboard table.
* @returns {Promise<void>} A promise that resolves when the table is cleared.
* @private
*/
async clearLeaderboard() {
const db = getDb();
return new Promise((resolve, reject) => {
db.run('DELETE FROM leaderboard', [], (err) => {
if (err) {
this.logger.error(`Error clearing leaderboard table:`, err.message);
reject(err);
} else {
this.logger.info(`Cleared leaderboard table.`);
resolve();
}
});
});
}
/** /**
* Helper: Read leaderboard data from SQLite. * Helper: Read leaderboard data from SQLite.
* @param {boolean} isDotw - True if reading Dancer of the Week leaderboard. * @param {boolean} isDotw - True if reading Dancer of the Week leaderboard.
@@ -147,11 +208,14 @@ class AccountRouteHandler extends RouteHandler {
data[row.mapName].push(row); data[row.mapName].push(row);
}); });
} else { } else {
// For DOTW, assume a single entry per week or handle as needed // For DOTW, return the week number of the first entry if available
// For now, just return the rows as an array, or the first row if only one is expected // and all entries.
if (rows.length > 0) { if (rows.length > 0) {
data.week = this.getWeekNumber(); // Assuming 'week' property is used to check current week data.week = rows[0].weekNumber; // Assuming weekNumber is stored in the row
data.entries = rows; data.entries = rows;
} else {
data.week = null;
data.entries = [];
} }
} }
resolve(data); resolve(data);
@@ -160,6 +224,26 @@ class AccountRouteHandler extends RouteHandler {
}); });
} }
/**
* Helper: Clear all entries from the dotw leaderboard table.
* @returns {Promise<void>} A promise that resolves when the table is cleared.
* @private
*/
async clearDotwLeaderboard() {
const db = getDb();
return new Promise((resolve, reject) => {
db.run('DELETE FROM dotw', [], (err) => {
if (err) {
this.logger.error(`Error clearing dotw table:`, err.message);
reject(err);
} else {
this.logger.info(`Cleared dotw table.`);
resolve();
}
});
});
}
/** /**
* Helper: Generate a leaderboard object from user data. * Helper: Generate a leaderboard object from user data.
* This method is now primarily for transforming user data into a format suitable for leaderboard entries, * This method is now primarily for transforming user data into a format suitable for leaderboard entries,
@@ -218,7 +302,11 @@ class AccountRouteHandler extends RouteHandler {
if (isDotw) { if (isDotw) {
stmt = db.prepare(`INSERT OR REPLACE INTO ${tableName} (mapName, profileId, username, score, timestamp, gameVersion, rank, name, avatar, country, platformId, alias, aliasGender, jdPoints, portraitBorder, weekNumber) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`); stmt = db.prepare(`INSERT OR REPLACE INTO ${tableName} (mapName, profileId, username, score, timestamp, gameVersion, rank, name, avatar, country, platformId, alias, aliasGender, jdPoints, portraitBorder, weekNumber) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
} else { } else {
stmt = db.prepare(`INSERT OR REPLACE INTO ${tableName} (mapName, profileId, username, score, timestamp) VALUES (?, ?, ?, ?, ?)`); stmt = db.prepare(`INSERT OR REPLACE INTO ${tableName} (
mapName, profileId, username, score, timestamp, name,
gameVersion, rank, avatar, country, platformId,
alias, aliasGender, jdPoints, portraitBorder
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
} }
const promises = leaderboardEntries.map(entry => { const promises = leaderboardEntries.map(entry => {
@@ -253,6 +341,16 @@ class AccountRouteHandler extends RouteHandler {
entry.username, entry.username,
entry.score, entry.score,
entry.timestamp, entry.timestamp,
entry.name,
entry.gameVersion,
entry.rank,
entry.avatar,
entry.country,
entry.platformId,
entry.alias,
entry.aliasGender,
entry.jdPoints,
entry.portraitBorder,
(err) => { (err) => {
if (err) rejectRun(err); if (err) rejectRun(err);
else resolveRun(); else resolveRun();
@@ -297,44 +395,42 @@ class AccountRouteHandler extends RouteHandler {
* @param {Response} res - The response object * @param {Response} res - The response object
*/ */
async handlePostProfiles(req, res) { async handlePostProfiles(req, res) {
const profileId = req.body?.profileId || req.body?.userId; const authHeader = req.header('Authorization');
const ticket = authHeader ? authHeader : null; // Keep the full header as ticket
if (!profileId) { if (!ticket) {
return res.status(400).send({ message: "Missing profileId or userId" }); this.logger.warn(`POST /profile/v2/profiles: Missing Authorization header (ticket).`);
return res.status(400).send({ message: "Missing Authorization header (ticket)." });
} }
const userData = await AccountService.getUserData(profileId); // Await getUserData const profileId = await AccountService.findUserFromTicket(ticket);
if (!profileId) {
this.logger.warn(`POST /profile/v2/profiles: Profile not found for provided ticket.`);
return res.status(400).send({ message: "Profile not found for provided ticket." });
}
let userData = await AccountService.getUserData(profileId);
if (userData) { if (userData) {
this.logger.info(`Updating existing profile ${profileId}`); this.logger.info(`Updating existing profile ${profileId}`);
// Update only the fields present in the request body, preserving other fields // Update only the fields present in the request body, preserving other fields
const updatedProfile = await AccountService.updateUser(profileId, req.body); // Await updateUser // Ensure the ticket is updated if present in the header
const updateData = { ...req.body };
updateData.ticket = ticket; // Always update ticket from header
const updatedProfile = await AccountService.updateUser(profileId, updateData);
return res.send({ return res.send({
__class: "UserProfile", __class: "UserProfile",
...updatedProfile.toJSON() ...updatedProfile.toPublicJSON()
}); });
} else { } else {
this.logger.info(`Creating new profile ${profileId}`); // This case should ideally not be reached if profileId is always found via ticket
// However, if it is, it means a ticket was provided but no profile exists for it.
// Create a new profile with default values and request body values // As per previous instruction, we should not create a new profile here.
const newProfile = await AccountService.updateUser(profileId, { // Await updateUser this.logger.error(`POST /profile/v2/profiles: Unexpected state - profileId found via ticket but no existing user data.`);
...req.body, return res.status(400).send({ message: "Profile not found for provided ticket." });
name: req.body.name || "Player",
alias: req.body.alias || "default",
aliasGender: req.body.aliasGender || 2,
scores: req.body.scores || {},
songsPlayed: req.body.songsPlayed || [],
avatar: req.body.avatar || "UI/menu_avatar/base/light.png",
country: req.body.country || "US",
createdAt: new Date().toISOString()
});
return res.send({
__class: "UserProfile",
...newProfile.toJSON()
});
} }
} }
@@ -344,51 +440,51 @@ class AccountRouteHandler extends RouteHandler {
* @param {Response} res - The response object * @param {Response} res - The response object
*/ */
async handleGetProfiles(req, res) { async handleGetProfiles(req, res) {
// Get the profileId from query parameters or authorization header const profileIdsParam = req.query.profileIds;
const profileId = req.query.profileId || await this.findUserFromTicket(req.header('Authorization')); // Await findUserFromTicket
if (profileIdsParam) {
try {
const requestedProfileIds = profileIdsParam.split(',');
const profiles = [];
for (const reqProfileId of requestedProfileIds) {
const profile = await AccountService.getUserData(reqProfileId);
if (profile) {
profiles.push({
__class: "UserProfile",
...profile.toPublicJSON()
});
} else {
profiles.push({
profileId: reqProfileId,
isExisting: false
});
}
}
return res.send(profiles);
} catch (error) {
this.logger.error('Error processing profileIds:', error);
return res.status(400).send({ message: "Invalid profileIds format" });
}
}
// Fallback for single profile request or if profileIds is not present
const profileId = req.query.profileId || await this.findUserFromTicket(req.header('Authorization'));
if (!profileId) { if (!profileId) {
return res.status(400).send({ message: "Missing profileId" }); return res.status(400).send({ message: "Missing profileId" });
} }
const userProfile = await AccountService.getUserData(profileId); // Await getUserData const userProfile = await AccountService.getUserData(profileId);
if (!userProfile) { if (!userProfile) {
this.logger.info(`Profile ${profileId} not found`); this.logger.info(`Profile ${profileId} not found`);
return res.status(404).send({ message: "Profile not found" }); return res.status(404).send({ message: "Profile not found" });
} }
// If query contains specific profile requests by IDs
if (req.query.requestedProfiles) {
try {
const requestedProfiles = JSON.parse(req.query.requestedProfiles);
const profiles = {};
// Get each requested profile
for (const reqProfileId of requestedProfiles) {
const profile = await AccountService.getUserData(reqProfileId); // Await getUserData
if (profile) {
profiles[reqProfileId] = {
__class: "UserProfile",
...profile.toJSON()
};
}
}
return res.send({
__class: "ProfilesContainer",
profiles: profiles
});
} catch (error) {
this.logger.error('Error parsing requestedProfiles:', error);
return res.status(400).send({ message: "Invalid requestedProfiles format" });
}
}
// Return single profile
return res.send({ return res.send({
__class: "UserProfile", __class: "UserProfile",
...userProfile.toJSON() ...userProfile.toPublicJSON()
}); });
} }
@@ -399,7 +495,7 @@ class AccountRouteHandler extends RouteHandler {
*/ */
async handleMapEnded(req, res) { async handleMapEnded(req, res) {
const { mapName, score } = req.body; const { mapName, score } = req.body;
const profileId = req.query.profileId || await this.findUserFromTicket(req.header('Authorization')); // Await findUserFromTicket const profileId = req.query.profileId || await this.findUserFromTicket(req.header('Authorization'));
if (!profileId) { if (!profileId) {
return res.status(400).send({ message: "Missing profileId" }); return res.status(400).send({ message: "Missing profileId" });
@@ -409,21 +505,32 @@ class AccountRouteHandler extends RouteHandler {
return res.status(400).send({ message: "Missing mapName or score" }); return res.status(400).send({ message: "Missing mapName or score" });
} }
const userProfile = await AccountService.getUserData(profileId); // Await getUserData const userProfile = await AccountService.getUserData(profileId);
if (!userProfile) { if (!userProfile) {
this.logger.info(`Profile ${profileId} not found`); this.logger.info(`Profile ${profileId} not found`);
return res.status(404).send({ message: "Profile not found" }); return res.status(404).send({ message: "Profile not found" });
} }
// Perform weekly leaderboard reset check
const currentWeek = this.getWeekNumber();
const lastResetWeek = await this.readLastResetWeek();
if (currentWeek !== lastResetWeek) {
this.logger.info(`New week detected: ${currentWeek}. Resetting all leaderboards.`);
await this.clearLeaderboard(); // Clear main leaderboard
await this.clearDotwLeaderboard(); // Clear DOTW leaderboard
await this.writeLastResetWeek(currentWeek); // Update last reset week
}
// Update most played maps // Update most played maps
updateMostPlayed(mapName); await MostPlayedService.updateMostPlayed(mapName);
// Update user's score for this map // Update user's score for this map
const currentScore = userProfile.scores?.[mapName]?.highest || 0; const currentScore = userProfile.scores?.[mapName]?.highest || 0;
const newHighest = Math.max(currentScore, score); const newHighest = Math.max(currentScore, score);
await AccountService.updateUserScore(profileId, mapName, { // Await updateUserScore await AccountService.updateUserScore(profileId, mapName, {
highest: newHighest, highest: newHighest,
lastPlayed: new Date().toISOString(), lastPlayed: new Date().toISOString(),
history: [ history: [
@@ -437,24 +544,36 @@ class AccountRouteHandler extends RouteHandler {
// Add to songsPlayed array if not already present // Add to songsPlayed array if not already present
if (!userProfile.songsPlayed?.includes(mapName)) { if (!userProfile.songsPlayed?.includes(mapName)) {
await AccountService.updateUser(profileId, { // Await updateUser await AccountService.updateUser(profileId, {
songsPlayed: [...(userProfile.songsPlayed || []), mapName] songsPlayed: [...(userProfile.songsPlayed || []), mapName]
}); });
} }
// Update leaderboards // Update leaderboards (main and DOTW)
const allAccounts = await AccountService.getAllAccounts(); const allAccounts = await AccountService.getAllAccounts();
const leaderboardEntries = this.generateLeaderboard(allAccounts, req); const leaderboardEntries = this.generateLeaderboard(allAccounts, req);
await this.saveLeaderboard(leaderboardEntries); await this.saveLeaderboard(leaderboardEntries); // Save to main leaderboard
// Update DOTW (Dancer of the Week) leaderboard if it's a new week // Save current map's score to DOTW
const currentWeek = this.getWeekNumber(); await this.saveLeaderboard([
const dotwData = await this.readLeaderboard(true); {
mapName: mapName,
if (!dotwData.week || dotwData.week !== currentWeek) { profileId: profileId,
this.logger.info(`New week detected: ${currentWeek}, resetting DOTW`); username: userProfile.name,
await this.saveLeaderboard([], true); score: newHighest,
timestamp: new Date().toISOString(),
gameVersion: this.getGameVersion(req),
rank: userProfile.rank,
name: userProfile.name,
avatar: userProfile.avatar,
country: userProfile.country,
platformId: userProfile.platformId,
alias: userProfile.alias,
aliasGender: userProfile.aliasGender,
jdPoints: userProfile.jdPoints,
portraitBorder: userProfile.portraitBorder
} }
], true); // Save to DOTW leaderboard
return res.send({ return res.send({
__class: "MapEndResult", __class: "MapEndResult",
@@ -504,9 +623,42 @@ class AccountRouteHandler extends RouteHandler {
return res.status(404).send({ message: "Profile not found" }); return res.status(404).send({ message: "Profile not found" });
} }
const clientIp = req.ip; // Get client IP
const authHeader = req.header('Authorization');
const ticket = authHeader ? authHeader.split(' ')[1] : null; // Extract ticket from "Ubi_v1 <ticket>"
// Determine platformType from X-SkuId
const skuId = req.header('X-SkuId') || '';
let platformType = 'pc'; // Default
if (skuId.includes('nx')) {
platformType = 'switch';
} else if (skuId.includes('ps4') || skuId.includes('orbis')) {
platformType = 'ps4';
} else if (skuId.includes('xboxone') || skuId.includes('durango')) {
platformType = 'xboxone';
} else if (skuId.includes('wiiu')) {
platformType = 'wiiu';
}
const serverTime = new Date().toISOString();
const expiration = new Date(Date.now() + 3600 * 1000).toISOString(); // Expires in 1 hour
return res.send({ return res.send({
sessionId: profileId, platformType: platformType,
trackingEnabled: false ticket: ticket,
twoFactorAuthenticationTicket: null,
profileId: userProfile.profileId,
userId: userProfile.userId, // Assuming userId is stored in Account model
nameOnPlatform: userProfile.name || userProfile.nickname,
environment: "Prod", // Static for now
expiration: expiration,
spaceId: "cd052712-ba1d-453a-89b9-08778888d380", // Static from example
clientIp: clientIp,
clientIpCountry: "US", // Static for now, requires IP lookup for dynamic
serverTime: serverTime,
sessionId: userProfile.profileId, // Keeping profileId as sessionId as per current implementation
sessionKey: "KJNTFMD24XOPTmpgGU3MXPuhAx3IMYSYG4YgyhPJ8rVkQDHzK1MmiOtHKrQiyL/HCOsJNCfX63oRAsyGe9CDiQ==", // Placeholder
rememberMeTicket: null
}); });
} }

View File

@@ -6,6 +6,7 @@ const RouteHandler = require('./RouteHandler');
const CarouselService = require('../../services/CarouselService'); const CarouselService = require('../../services/CarouselService');
const coreMain = require('../../var').main; // Assuming core.main is needed for various carousel data const coreMain = require('../../var').main; // Assuming core.main is needed for various carousel data
const Logger = require('../../utils/logger'); const Logger = require('../../utils/logger');
const AccountService = require('../../services/AccountService'); // Import AccountService to get profileId
class CarouselRouteHandler extends RouteHandler { class CarouselRouteHandler extends RouteHandler {
constructor() { constructor() {
@@ -76,6 +77,9 @@ class CarouselRouteHandler extends RouteHandler {
search = req.body.searchTags[0]; search = req.body.searchTags[0];
} }
// Get profileId for personalization
const profileId = req.query.profileId || await AccountService.findUserFromTicket(req.header('Authorization'));
let action = null; let action = null;
let isPlaylist = false; let isPlaylist = false;
@@ -100,11 +104,13 @@ class CarouselRouteHandler extends RouteHandler {
if (isPlaylist) { if (isPlaylist) {
// Assuming core.generatePlaylist is still needed for playlist categories // Assuming core.generatePlaylist is still needed for playlist categories
// TODO: Potentially personalize playlists as well if needed in the future
return res.json(require('../../lib/playlist').generatePlaylist().playlistcategory); return res.json(require('../../lib/playlist').generatePlaylist().playlistcategory);
} }
if (action != null) { if (action != null) {
return res.send(await CarouselService.generateCarousel(search, action)); // Pass profileId to generateCarousel for personalization
return res.send(await CarouselService.generateCarousel(search, action, profileId));
} }
return res.json({}); return res.json({});

View File

@@ -75,14 +75,21 @@ class UbiservicesRouteHandler extends RouteHandler {
// Serve application parameters for JD22 // Serve application parameters for JD22
this.registerGet(app, '/v1/applications/34ad0f04-b141-4793-bdd4-985a9175e70d/parameters', this.handleGetParametersJD22); this.registerGet(app, '/v1/applications/34ad0f04-b141-4793-bdd4-985a9175e70d/parameters', this.handleGetParametersJD22);
// Serve application parameters for JD21
this.registerGet(app, '/v1/applications/c8cfd4b7-91b0-446b-8e3b-7edfa393c946/parameters', this.handleGetParametersJD21);
// Serve application parameters for JD18 // Serve application parameters for JD18
this.registerGet(app, '/v1/spaces/041c03fa-1735-4ea7-b5fc-c16546d092ca/parameters', this.handleGetParametersJD18); this.registerGet(app, '/v1/spaces/041c03fa-1735-4ea7-b5fc-c16546d092ca/parameters', this.handleGetParametersJD18);
// Handle user-related requests (stubbed for now) // Handle user-related requests (stubbed for now)
this.registerGet(app, '/v3/policies/:langID', this.handleGetPolicies); // New policies route
this.registerPost(app, '/v3/users', this.handlePostUsersNew); // New POST /v3/users route
this.registerPost(app, '/v3/users/:user', this.handlePostUsers); this.registerPost(app, '/v3/users/:user', this.handlePostUsers);
this.registerGet(app, '/v3/users/:user', this.handleGetUsers); this.registerGet(app, '/v3/users/:user', this.handleGetUsers);
console.log(`[ROUTE] ${this.name} routes initialized`); console.log(`[ROUTE] ${this.name} routes initialized`);
} }
@@ -234,6 +241,20 @@ class UbiservicesRouteHandler extends RouteHandler {
res.send(this.replaceDomainPlaceholder(require("../../database/config/v1/parameters.json"), this.settings.server.domain)); res.send(this.replaceDomainPlaceholder(require("../../database/config/v1/parameters.json"), this.settings.server.domain));
} }
/**
* Serve application parameters for JD18
* @param {Request} req - The request object
* @param {Response} res - The response object
*/
/**
* Serve application parameters for JD21
* @param {Request} req - The request object
* @param {Response} res - The response object
*/
handleGetParametersJD21(req, res) {
res.send(this.replaceDomainPlaceholder(require("../../database/config/v1/jd21/parameters.json"), this.settings.server.domain));
}
/** /**
* Serve application parameters for JD18 * Serve application parameters for JD18
* @param {Request} req - The request object * @param {Request} req - The request object
@@ -243,6 +264,60 @@ class UbiservicesRouteHandler extends RouteHandler {
res.send(this.replaceDomainPlaceholder(require("../../database/config/v1/parameters2.json"), this.settings.server.domain)); res.send(this.replaceDomainPlaceholder(require("../../database/config/v1/parameters2.json"), this.settings.server.domain));
} }
/**
* Handle user-related requests (stubbed for now)
* @param {Request} req - The request object
* @param {Response} res - The response object
*/
/**
* Handle GET /v3/policies/:langID
* @param {Request} req - The request object
* @param {Response} res - The response object
*/
handleGetPolicies(req, res) {
res.send({
"termOfSaleContent": "",
"policyAcceptance": "I accept Ubisoft's Terms of Use, Terms of Sale and Privacy Policy.",
"policyAcceptanceIsRequired": true,
"policyAcceptanceDefaultValue": false,
"policyLocaleCode": "en-US",
"minorAccount": {
"ageRequired": 7,
"isDigitalSignatureRequiredForAccountCreation": true,
"privacyPolicyContent": "Basically We Need Ur Ticket and IP for assigning cracked user only. We dont care about your data."
},
"adultAccount": {
"ageRequired": 18
},
"legalOptinsKey": "eyJ2dG91IjoiNC4wIiwidnBwIjoiNC4xIiwidnRvcyI6IjIuMSIsImx0b3UiOiJlbi1VUyIsImxwcCI6ImVuLVVTIiwibHRvcyI6ImVuLVVTIn0",
"ageRequired": 13,
"communicationOptInDefaultValue": true,
"privacyPolicyContent": "Who Need Those",
"termOfUseContent": "Who Need Those"
});
}
/**
* Handle POST /v3/users
* @param {Request} req - The request object
* @param {Response} res - The response object
*/
handlePostUsersNew(req, res) {
res.send({
country: 'US',
ageGroup: 'Adult',
email: 'MFADAMO_JD2016@ubisoft.com',
legalOptinsKey: 'eyJ2dG91IjoiNC4wIiwidnBwIjoiNC4xIiwidnRvcyI6IjIuMSIsImx0b3UiOiJlbi1VUyIsImxwcCI6ImVuLVVTIiwibHRvcyI6ImVuLVVTIn0',
password: '#JD2016ubi42',
gender: 'NOT_DEFINED',
preferredLanguage: 'FR',
nameOnPlatform: 'MFADAMO_J_JD2016',
accountType: 'Ubisoft',
profileId: "f7d85441-265d-4c9c-b1e3-25af3182091a",
userId: "4f7fc740-da32-4f2d-a81e-18faf7a1262d"
});
}
/** /**
* Handle user-related requests (stubbed for now) * Handle user-related requests (stubbed for now)
* @param {Request} req - The request object * @param {Request} req - The request object

View File

@@ -97,20 +97,45 @@ class DatabaseManager {
// Create user_profiles table // Create user_profiles table
this._db.run(`CREATE TABLE IF NOT EXISTS user_profiles ( this._db.run(`CREATE TABLE IF NOT EXISTS user_profiles (
profileId TEXT PRIMARY KEY, profileId TEXT PRIMARY KEY,
userId TEXT, -- Already present
username TEXT,
nickname TEXT,
name TEXT, name TEXT,
email TEXT,
password TEXT, -- Consider hashing if storing sensitive passwords
ticket TEXT,
alias TEXT, alias TEXT,
aliasGender INTEGER, aliasGender INTEGER,
avatar TEXT, avatar TEXT,
country TEXT, country TEXT,
createdAt TEXT,
ticket TEXT,
platformId TEXT, platformId TEXT,
jdPoints INTEGER, jdPoints INTEGER,
portraitBorder TEXT, portraitBorder TEXT,
-- Store scores and songsPlayed as JSON strings rank INTEGER,
scores TEXT, scores TEXT, -- JSON stored as TEXT
songsPlayed TEXT, favorites TEXT, -- JSON stored as TEXT
favorites TEXT songsPlayed TEXT, -- JSON array stored as TEXT
progression TEXT, -- JSON stored as TEXT
history TEXT, -- JSON stored as TEXT
skin TEXT,
diamondPoints INTEGER,
unlockedAvatars TEXT, -- JSON array stored as TEXT
unlockedSkins TEXT, -- JSON array stored as TEXT
unlockedAliases TEXT, -- JSON array stored as TEXT
unlockedPortraitBorders TEXT, -- JSON array stored as TEXT
wdfRank INTEGER,
stars INTEGER,
unlocks INTEGER,
populations TEXT, -- JSON array stored as TEXT
inProgressAliases TEXT, -- JSON array stored as TEXT
language TEXT,
firstPartyEnv TEXT,
syncVersions TEXT, -- JSON stored as TEXT
otherPids TEXT, -- JSON array stored as TEXT
stats TEXT, -- JSON stored as TEXT
mapHistory TEXT, -- JSON stored as TEXT
createdAt TEXT,
updatedAt TEXT
)`, (err) => { )`, (err) => {
if (err) { if (err) {
this.logger.error('Error creating user_profiles table:', err.message); this.logger.error('Error creating user_profiles table:', err.message);

File diff suppressed because it is too large Load Diff

View File

@@ -25,6 +25,29 @@ class Account {
this.rank = data.rank || 0; this.rank = data.rank || 0;
this.scores = data.scores || {}; // Map of mapName to score data this.scores = data.scores || {}; // Map of mapName to score data
this.favorites = data.favorites || {}; // User's favorite maps this.favorites = data.favorites || {}; // User's favorite maps
this.songsPlayed = data.songsPlayed || []; // Array of map names
this.progression = data.progression || {};
this.history = data.history || {}; // Example: {"MapName": playCount}
// New fields from extended JSON structures (assuming these were added from previous step)
this.skin = data.skin || null;
this.diamondPoints = data.diamondPoints || 0;
this.unlockedAvatars = data.unlockedAvatars || [];
this.unlockedSkins = data.unlockedSkins || [];
this.unlockedAliases = data.unlockedAliases || [];
this.unlockedPortraitBorders = data.unlockedPortraitBorders || [];
this.wdfRank = data.wdfRank || 0;
this.stars = data.stars || 0;
this.unlocks = data.unlocks || 0;
this.populations = data.populations || [];
this.inProgressAliases = data.inProgressAliases || [];
this.language = data.language || null;
this.firstPartyEnv = data.firstPartyEnv || null;
this.syncVersions = data.syncVersions || {};
this.otherPids = data.otherPids || [];
this.stats = data.stats || {};
this.mapHistory = data.mapHistory || { classic: [], kids: [] };
this.createdAt = data.createdAt || new Date().toISOString(); this.createdAt = data.createdAt || new Date().toISOString();
this.updatedAt = data.updatedAt || new Date().toISOString(); this.updatedAt = data.updatedAt || new Date().toISOString();
} }
@@ -35,7 +58,105 @@ class Account {
* @returns {Account} Updated account instance * @returns {Account} Updated account instance
*/ */
update(data) { update(data) {
Object.assign(this, data); // Helper: Check if a value is a plain object
const _isObject = (item) => {
return item && typeof item === 'object' && !Array.isArray(item);
};
// Helper: Deeply merge source object's properties into target object
const _deepMergeObjects = (target, source) => {
const output = { ...target };
for (const key in source) {
if (source.hasOwnProperty(key)) {
const sourceVal = source[key];
const targetVal = output[key];
if (_isObject(sourceVal)) {
if (_isObject(targetVal)) {
output[key] = _deepMergeObjects(targetVal, sourceVal);
} else {
// If target's property is not an object, or doesn't exist,
// clone the source object property.
output[key] = _deepMergeObjects({}, sourceVal);
}
} else {
// For non-object properties (primitives, arrays), source overwrites target.
// Specific array merging is handled in the main update loop.
output[key] = sourceVal;
}
}
}
return output;
};
const simpleOverwriteKeys = [
'profileId', 'userId', 'username', 'nickname', 'name', 'email', 'password', 'ticket',
'avatar', 'country', 'platformId', 'alias', 'aliasGender', 'jdPoints',
'portraitBorder', 'rank', 'skin', 'diamondPoints', 'wdfRank', 'stars',
'unlocks', 'language', 'firstPartyEnv'
];
const deepMergeObjectKeys = [
'scores', 'progression', 'history', 'favorites', 'stats', 'syncVersions'
];
const unionArrayKeys = [ // Arrays of unique primitive values
'unlockedAvatars', 'unlockedSkins', 'unlockedAliases', 'unlockedPortraitBorders', 'otherPids', 'songsPlayed'
];
for (const key in data) {
if (data.hasOwnProperty(key)) {
const incomingValue = data[key];
if (simpleOverwriteKeys.includes(key)) {
this[key] = incomingValue;
} else if (deepMergeObjectKeys.includes(key)) {
if (_isObject(incomingValue)) {
this[key] = _deepMergeObjects(this[key] || {}, incomingValue);
} else { // If incoming data for a deep-merge key is not an object, overwrite.
this[key] = incomingValue;
}
} else if (unionArrayKeys.includes(key)) {
if (Array.isArray(incomingValue)) {
this[key] = [...new Set([...(this[key] || []), ...incomingValue])];
} else { // If incoming data for a union-array key is not an array, overwrite.
this[key] = incomingValue;
}
} else if (key === 'populations') {
if (Array.isArray(incomingValue)) {
const existingItems = this.populations || [];
const mergedItems = [...existingItems];
incomingValue.forEach(newItem => {
const index = mergedItems.findIndex(ep => ep.subject === newItem.subject && ep.spaceId === newItem.spaceId);
if (index !== -1) mergedItems[index] = _deepMergeObjects(mergedItems[index], newItem);
else mergedItems.push(newItem);
});
this.populations = mergedItems;
} else this.populations = incomingValue;
} else if (key === 'inProgressAliases') {
if (Array.isArray(incomingValue)) {
const existingItems = this.inProgressAliases || [];
const mergedItems = [...existingItems];
incomingValue.forEach(newItem => {
const index = mergedItems.findIndex(ea => ea.id === newItem.id);
if (index !== -1) mergedItems[index] = _deepMergeObjects(mergedItems[index], newItem);
else mergedItems.push(newItem);
});
this.inProgressAliases = mergedItems;
} else this.inProgressAliases = incomingValue;
} else if (key === 'mapHistory') {
if (_isObject(incomingValue)) {
const currentMapHistory = this.mapHistory || { classic: [], kids: [] };
this.mapHistory = {
classic: [...new Set([...(currentMapHistory.classic || []), ...(incomingValue.classic || [])])],
kids: [...new Set([...(currentMapHistory.kids || []), ...(incomingValue.kids || [])])]
};
} else this.mapHistory = incomingValue;
} else if (this.hasOwnProperty(key)) {
// Default for other existing properties not specially handled: overwrite
this[key] = incomingValue;
}
}
}
this.updatedAt = new Date().toISOString(); this.updatedAt = new Date().toISOString();
return this; return this;
} }
@@ -110,10 +231,83 @@ class Account {
rank: this.rank, rank: this.rank,
scores: this.scores, scores: this.scores,
favorites: this.favorites, favorites: this.favorites,
songsPlayed: this.songsPlayed,
progression: this.progression,
history: this.history,
// New fields
skin: this.skin,
diamondPoints: this.diamondPoints,
unlockedAvatars: this.unlockedAvatars,
unlockedSkins: this.unlockedSkins,
unlockedAliases: this.unlockedAliases,
unlockedPortraitBorders: this.unlockedPortraitBorders,
wdfRank: this.wdfRank,
stars: this.stars,
unlocks: this.unlocks,
populations: this.populations,
inProgressAliases: this.inProgressAliases,
language: this.language,
firstPartyEnv: this.firstPartyEnv,
syncVersions: this.syncVersions,
otherPids: this.otherPids,
stats: this.stats,
mapHistory: this.mapHistory,
createdAt: this.createdAt, createdAt: this.createdAt,
updatedAt: this.updatedAt updatedAt: this.updatedAt
}; };
} }
/**
* Convert to plain object for public API responses, excluding sensitive data.
* @returns {Object} Sanitized plain object representation
*/
toPublicJSON() {
const publicData = {
profileId: this.profileId,
userId: this.userId,
username: this.username,
nickname: this.nickname,
name: this.name,
avatar: this.avatar,
country: this.country,
platformId: this.platformId,
alias: this.alias,
aliasGender: this.aliasGender,
jdPoints: this.jdPoints,
portraitBorder: this.portraitBorder,
rank: this.rank,
scores: this.scores,
favorites: this.favorites,
songsPlayed: this.songsPlayed,
progression: this.progression,
history: this.history,
// New fields
skin: this.skin,
diamondPoints: this.diamondPoints,
unlockedAvatars: this.unlockedAvatars,
unlockedSkins: this.unlockedSkins,
unlockedAliases: this.unlockedAliases,
unlockedPortraitBorders: this.unlockedPortraitBorders,
wdfRank: this.wdfRank,
stars: this.stars,
unlocks: this.unlocks,
populations: this.populations,
inProgressAliases: this.inProgressAliases,
language: this.language,
firstPartyEnv: this.firstPartyEnv,
syncVersions: this.syncVersions,
otherPids: this.otherPids,
stats: this.stats,
mapHistory: this.mapHistory,
createdAt: this.createdAt,
updatedAt: this.updatedAt
};
// Explicitly remove sensitive fields if they were somehow added
delete publicData.email;
delete publicData.password;
delete publicData.ticket;
return publicData;
}
} }
module.exports = Account; module.exports = Account;

View File

@@ -30,20 +30,48 @@ class AccountRepository {
rows.forEach(row => { rows.forEach(row => {
try { try {
const accountData = { const accountData = {
// Existing fields
profileId: row.profileId, profileId: row.profileId,
userId: row.userId,
username: row.username,
nickname: row.nickname,
name: row.name, name: row.name,
email: row.email,
password: row.password, // Should be handled securely if stored
ticket: row.ticket,
alias: row.alias, alias: row.alias,
aliasGender: row.aliasGender, aliasGender: row.aliasGender,
avatar: row.avatar, avatar: row.avatar,
country: row.country, country: row.country,
createdAt: row.createdAt,
ticket: row.ticket,
platformId: row.platformId, platformId: row.platformId,
jdPoints: row.jdPoints, jdPoints: row.jdPoints,
portraitBorder: row.portraitBorder, portraitBorder: row.portraitBorder,
rank: row.rank,
scores: row.scores ? JSON.parse(row.scores) : {}, scores: row.scores ? JSON.parse(row.scores) : {},
songsPlayed: row.songsPlayed ? JSON.parse(row.songsPlayed) : [], songsPlayed: row.songsPlayed ? JSON.parse(row.songsPlayed) : [],
favorites: row.favorites ? JSON.parse(row.favorites) : [] favorites: row.favorites ? JSON.parse(row.favorites) : {},
progression: row.progression ? JSON.parse(row.progression) : {},
history: row.history ? JSON.parse(row.history) : {},
createdAt: row.createdAt,
updatedAt: row.updatedAt,
// New fields
skin: row.skin,
diamondPoints: row.diamondPoints,
unlockedAvatars: row.unlockedAvatars ? JSON.parse(row.unlockedAvatars) : [],
unlockedSkins: row.unlockedSkins ? JSON.parse(row.unlockedSkins) : [],
unlockedAliases: row.unlockedAliases ? JSON.parse(row.unlockedAliases) : [],
unlockedPortraitBorders: row.unlockedPortraitBorders ? JSON.parse(row.unlockedPortraitBorders) : [],
wdfRank: row.wdfRank,
stars: row.stars,
unlocks: row.unlocks,
populations: row.populations ? JSON.parse(row.populations) : [],
inProgressAliases: row.inProgressAliases ? JSON.parse(row.inProgressAliases) : [],
language: row.language,
firstPartyEnv: row.firstPartyEnv,
syncVersions: row.syncVersions ? JSON.parse(row.syncVersions) : {},
otherPids: row.otherPids ? JSON.parse(row.otherPids) : [],
stats: row.stats ? JSON.parse(row.stats) : {},
mapHistory: row.mapHistory ? JSON.parse(row.mapHistory) : { classic: [], kids: [] }
}; };
accounts[row.profileId] = new Account(accountData); accounts[row.profileId] = new Account(accountData);
} catch (parseError) { } catch (parseError) {
@@ -75,27 +103,73 @@ class AccountRepository {
const scoresJson = JSON.stringify(accountData.scores || {}); const scoresJson = JSON.stringify(accountData.scores || {});
const songsPlayedJson = JSON.stringify(accountData.songsPlayed || []); const songsPlayedJson = JSON.stringify(accountData.songsPlayed || []);
const favoritesJson = JSON.stringify(accountData.favorites || []); const favoritesJson = JSON.stringify(accountData.favorites || []);
const progressionJson = JSON.stringify(accountData.progression || {});
const historyJson = JSON.stringify(accountData.history || {});
// Stringify new JSON fields
const unlockedAvatarsJson = JSON.stringify(accountData.unlockedAvatars || []);
const unlockedSkinsJson = JSON.stringify(accountData.unlockedSkins || []);
const unlockedAliasesJson = JSON.stringify(accountData.unlockedAliases || []);
const unlockedPortraitBordersJson = JSON.stringify(accountData.unlockedPortraitBorders || []);
const populationsJson = JSON.stringify(accountData.populations || []);
const inProgressAliasesJson = JSON.stringify(accountData.inProgressAliases || []);
const syncVersionsJson = JSON.stringify(accountData.syncVersions || {});
const otherPidsJson = JSON.stringify(accountData.otherPids || []);
const statsJson = JSON.stringify(accountData.stats || {});
const mapHistoryJson = JSON.stringify(accountData.mapHistory || { classic: [], kids: [] });
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
db.run(`INSERT OR REPLACE INTO user_profiles ( db.run(`INSERT OR REPLACE INTO user_profiles (
profileId, name, alias, aliasGender, avatar, country, createdAt, ticket, profileId, userId, username, nickname, name, email, password, ticket,
platformId, jdPoints, portraitBorder, scores, songsPlayed, favorites alias, aliasGender, avatar, country, platformId, jdPoints, portraitBorder, rank,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, scores, songsPlayed, favorites, progression, history,
skin, diamondPoints, unlockedAvatars, unlockedSkins, unlockedAliases, unlockedPortraitBorders,
wdfRank, stars, unlocks, populations, inProgressAliases, language, firstPartyEnv,
syncVersions, otherPids, stats, mapHistory,
createdAt, updatedAt
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[ [
accountData.profileId, accountData.profileId,
accountData.userId,
accountData.username,
accountData.nickname,
accountData.name, accountData.name,
accountData.email,
accountData.password, // Ensure this is handled securely (e.g., hashed) if stored
accountData.ticket,
accountData.alias, accountData.alias,
accountData.aliasGender, accountData.aliasGender,
accountData.avatar, accountData.avatar,
accountData.country, accountData.country,
accountData.createdAt,
accountData.ticket,
accountData.platformId, accountData.platformId,
accountData.jdPoints, accountData.jdPoints,
accountData.portraitBorder, accountData.portraitBorder,
accountData.rank,
scoresJson, scoresJson,
songsPlayedJson, songsPlayedJson,
favoritesJson favoritesJson,
progressionJson,
historyJson,
// New fields
accountData.skin,
accountData.diamondPoints,
unlockedAvatarsJson,
unlockedSkinsJson,
unlockedAliasesJson,
unlockedPortraitBordersJson,
accountData.wdfRank,
accountData.stars,
accountData.unlocks,
populationsJson,
inProgressAliasesJson,
accountData.language,
accountData.firstPartyEnv,
syncVersionsJson,
otherPidsJson,
statsJson,
mapHistoryJson,
// Timestamps
accountData.createdAt,
accountData.updatedAt
], ],
(err) => { (err) => {
if (err) { if (err) {
@@ -124,20 +198,48 @@ class AccountRepository {
} else if (row) { } else if (row) {
try { try {
const accountData = { const accountData = {
// Existing fields
profileId: row.profileId, profileId: row.profileId,
userId: row.userId,
username: row.username,
nickname: row.nickname,
name: row.name, name: row.name,
email: row.email,
password: row.password,
ticket: row.ticket,
alias: row.alias, alias: row.alias,
aliasGender: row.aliasGender, aliasGender: row.aliasGender,
avatar: row.avatar, avatar: row.avatar,
country: row.country, country: row.country,
createdAt: row.createdAt,
ticket: row.ticket,
platformId: row.platformId, platformId: row.platformId,
jdPoints: row.jdPoints, jdPoints: row.jdPoints,
portraitBorder: row.portraitBorder, portraitBorder: row.portraitBorder,
rank: row.rank,
scores: row.scores ? JSON.parse(row.scores) : {}, scores: row.scores ? JSON.parse(row.scores) : {},
songsPlayed: row.songsPlayed ? JSON.parse(row.songsPlayed) : [], songsPlayed: row.songsPlayed ? JSON.parse(row.songsPlayed) : [],
favorites: row.favorites ? JSON.parse(row.favorites) : [] favorites: row.favorites ? JSON.parse(row.favorites) : {},
progression: row.progression ? JSON.parse(row.progression) : {},
history: row.history ? JSON.parse(row.history) : {},
createdAt: row.createdAt,
updatedAt: row.updatedAt,
// New fields
skin: row.skin,
diamondPoints: row.diamondPoints,
unlockedAvatars: row.unlockedAvatars ? JSON.parse(row.unlockedAvatars) : [],
unlockedSkins: row.unlockedSkins ? JSON.parse(row.unlockedSkins) : [],
unlockedAliases: row.unlockedAliases ? JSON.parse(row.unlockedAliases) : [],
unlockedPortraitBorders: row.unlockedPortraitBorders ? JSON.parse(row.unlockedPortraitBorders) : [],
wdfRank: row.wdfRank,
stars: row.stars,
unlocks: row.unlocks,
populations: row.populations ? JSON.parse(row.populations) : [],
inProgressAliases: row.inProgressAliases ? JSON.parse(row.inProgressAliases) : [],
language: row.language,
firstPartyEnv: row.firstPartyEnv,
syncVersions: row.syncVersions ? JSON.parse(row.syncVersions) : {},
otherPids: row.otherPids ? JSON.parse(row.otherPids) : [],
stats: row.stats ? JSON.parse(row.stats) : {},
mapHistory: row.mapHistory ? JSON.parse(row.mapHistory) : { classic: [], kids: [] }
}; };
resolve(new Account(accountData)); resolve(new Account(accountData));
} catch (parseError) { } catch (parseError) {
@@ -166,20 +268,48 @@ class AccountRepository {
} else if (row) { } else if (row) {
try { try {
const accountData = { const accountData = {
// Existing fields
profileId: row.profileId, profileId: row.profileId,
userId: row.userId,
username: row.username,
nickname: row.nickname,
name: row.name, name: row.name,
email: row.email,
password: row.password,
ticket: row.ticket,
alias: row.alias, alias: row.alias,
aliasGender: row.aliasGender, aliasGender: row.aliasGender,
avatar: row.avatar, avatar: row.avatar,
country: row.country, country: row.country,
createdAt: row.createdAt,
ticket: row.ticket,
platformId: row.platformId, platformId: row.platformId,
jdPoints: row.jdPoints, jdPoints: row.jdPoints,
portraitBorder: row.portraitBorder, portraitBorder: row.portraitBorder,
rank: row.rank,
scores: row.scores ? JSON.parse(row.scores) : {}, scores: row.scores ? JSON.parse(row.scores) : {},
songsPlayed: row.songsPlayed ? JSON.parse(row.songsPlayed) : [], songsPlayed: row.songsPlayed ? JSON.parse(row.songsPlayed) : [],
favorites: row.favorites ? JSON.parse(row.favorites) : [] favorites: row.favorites ? JSON.parse(row.favorites) : {},
progression: row.progression ? JSON.parse(row.progression) : {},
history: row.history ? JSON.parse(row.history) : {},
createdAt: row.createdAt,
updatedAt: row.updatedAt,
// New fields
skin: row.skin,
diamondPoints: row.diamondPoints,
unlockedAvatars: row.unlockedAvatars ? JSON.parse(row.unlockedAvatars) : [],
unlockedSkins: row.unlockedSkins ? JSON.parse(row.unlockedSkins) : [],
unlockedAliases: row.unlockedAliases ? JSON.parse(row.unlockedAliases) : [],
unlockedPortraitBorders: row.unlockedPortraitBorders ? JSON.parse(row.unlockedPortraitBorders) : [],
wdfRank: row.wdfRank,
stars: row.stars,
unlocks: row.unlocks,
populations: row.populations ? JSON.parse(row.populations) : [],
inProgressAliases: row.inProgressAliases ? JSON.parse(row.inProgressAliases) : [],
language: row.language,
firstPartyEnv: row.firstPartyEnv,
syncVersions: row.syncVersions ? JSON.parse(row.syncVersions) : {},
otherPids: row.otherPids ? JSON.parse(row.otherPids) : [],
stats: row.stats ? JSON.parse(row.stats) : {},
mapHistory: row.mapHistory ? JSON.parse(row.mapHistory) : { classic: [], kids: [] }
}; };
resolve(new Account(accountData)); resolve(new Account(accountData));
} catch (parseError) { } catch (parseError) {
@@ -208,20 +338,48 @@ class AccountRepository {
} else if (row) { } else if (row) {
try { try {
const accountData = { const accountData = {
// Existing fields
profileId: row.profileId, profileId: row.profileId,
userId: row.userId,
username: row.username,
nickname: row.nickname,
name: row.name, name: row.name,
email: row.email,
password: row.password,
ticket: row.ticket,
alias: row.alias, alias: row.alias,
aliasGender: row.aliasGender, aliasGender: row.aliasGender,
avatar: row.avatar, avatar: row.avatar,
country: row.country, country: row.country,
createdAt: row.createdAt,
ticket: row.ticket,
platformId: row.platformId, platformId: row.platformId,
jdPoints: row.jdPoints, jdPoints: row.jdPoints,
portraitBorder: row.portraitBorder, portraitBorder: row.portraitBorder,
rank: row.rank,
scores: row.scores ? JSON.parse(row.scores) : {}, scores: row.scores ? JSON.parse(row.scores) : {},
songsPlayed: row.songsPlayed ? JSON.parse(row.songsPlayed) : [], songsPlayed: row.songsPlayed ? JSON.parse(row.songsPlayed) : [],
favorites: row.favorites ? JSON.parse(row.favorites) : [] favorites: row.favorites ? JSON.parse(row.favorites) : {},
progression: row.progression ? JSON.parse(row.progression) : {},
history: row.history ? JSON.parse(row.history) : {},
createdAt: row.createdAt,
updatedAt: row.updatedAt,
// New fields
skin: row.skin,
diamondPoints: row.diamondPoints,
unlockedAvatars: row.unlockedAvatars ? JSON.parse(row.unlockedAvatars) : [],
unlockedSkins: row.unlockedSkins ? JSON.parse(row.unlockedSkins) : [],
unlockedAliases: row.unlockedAliases ? JSON.parse(row.unlockedAliases) : [],
unlockedPortraitBorders: row.unlockedPortraitBorders ? JSON.parse(row.unlockedPortraitBorders) : [],
wdfRank: row.wdfRank,
stars: row.stars,
unlocks: row.unlocks,
populations: row.populations ? JSON.parse(row.populations) : [],
inProgressAliases: row.inProgressAliases ? JSON.parse(row.inProgressAliases) : [],
language: row.language,
firstPartyEnv: row.firstPartyEnv,
syncVersions: row.syncVersions ? JSON.parse(row.syncVersions) : {},
otherPids: row.otherPids ? JSON.parse(row.otherPids) : [],
stats: row.stats ? JSON.parse(row.stats) : {},
mapHistory: row.mapHistory ? JSON.parse(row.mapHistory) : { classic: [], kids: [] }
}; };
resolve(new Account(accountData)); resolve(new Account(accountData));
} catch (parseError) { } catch (parseError) {

View File

@@ -87,14 +87,37 @@ class AccountService {
this.logger.info(`Updating user ${profileId}`); this.logger.info(`Updating user ${profileId}`);
let account = await AccountRepository.findById(profileId); let account = await AccountRepository.findById(profileId);
const processedUserData = { ...userData };
// Pre-process favorites: if it's an array of mapNames, convert to model's object structure
if (Array.isArray(processedUserData.favorites)) {
const newFavorites = {};
for (const mapName of processedUserData.favorites) {
if (typeof mapName === 'string') { // Ensure items are strings
newFavorites[mapName] = { addedAt: new Date().toISOString() };
}
}
processedUserData.favorites = newFavorites;
this.logger.info(`Processed 'favorites' array to object for profile ${profileId}`);
}
// Pre-process songsPlayed: if it's a number (e.g., from older formats), ignore it
// to prevent corrupting the 'songsPlayed' array of map names in the model.
if (processedUserData.hasOwnProperty('songsPlayed') && typeof processedUserData.songsPlayed === 'number') {
this.logger.warn(`Received 'songsPlayed' as a number (${processedUserData.songsPlayed}) for profile ${profileId}. This will be ignored as the model expects an array of map names for 'songsPlayed'.`);
delete processedUserData.songsPlayed; // Do not pass it to account.update if it's a number
}
// Add any other necessary pre-processing for other fields here
if (!account) { if (!account) {
account = new Account({ account = new Account({
profileId, profileId,
...userData ...processedUserData // Use processed data for new account
}); });
this.logger.info(`Created new user ${profileId}`); this.logger.info(`Created new user ${profileId}`);
} else { } else {
account.update(userData); account.update(processedUserData); // Pass processed data for update
this.logger.info(`Updated existing user ${profileId}`); this.logger.info(`Updated existing user ${profileId}`);
} }

View File

@@ -10,28 +10,31 @@ class CarouselService {
* Generate a carousel based on search criteria and type. * Generate a carousel based on search criteria and type.
* @param {string} search - The search string or tag. * @param {string} search - The search string or tag.
* @param {string} type - The type of carousel (e.g., "partyMap", "sweatMap"). * @param {string} type - The type of carousel (e.g., "partyMap", "sweatMap").
* @param {string} [profileId=null] - The profile ID for personalization.
* @returns {Object} The generated carousel object. * @returns {Object} The generated carousel object.
*/ */
generateCarousel(search, type) { generateCarousel(search, type, profileId = null) {
return generateCarousel(search, type); return generateCarousel(search, type, profileId);
} }
/** /**
* Generate a cooperative carousel. * Generate a cooperative carousel.
* @param {string} search - The search string or tag. * @param {string} search - The search string or tag.
* @param {string} [profileId=null] - The profile ID for personalization.
* @returns {Object} The generated cooperative carousel object. * @returns {Object} The generated cooperative carousel object.
*/ */
generateCoopCarousel(search) { generateCoopCarousel(search, profileId = null) {
return generateCoopCarousel(search); return generateCoopCarousel(search, profileId);
} }
/** /**
* Generate a sweat carousel. * Generate a sweat carousel.
* @param {string} search - The search string or tag. * @param {string} search - The search string or tag.
* @param {string} [profileId=null] - The profile ID for personalization.
* @returns {Object} The generated sweat carousel object. * @returns {Object} The generated sweat carousel object.
*/ */
generateSweatCarousel(search) { generateSweatCarousel(search, profileId = null) {
return generateSweatCarousel(search); return generateSweatCarousel(search, profileId);
} }
/** /**

View File

@@ -0,0 +1,103 @@
{
"parameters": {
"us-staging": {
"fields": {
"spaceId": "59f82720-778f-427a-a0bd-e81cb112c044"
},
"relatedPopulation": null
},
"us-sdkClientClub": {
"fields": {
"ps4ClubWebsite": "https://static8.cdn.ubi.com/u/sites/uplay/index.html",
"xoneLaunchPath": "Ms-xbl-55C5EE27://default?url=/games/{deepLink}&context={context}&titleid={gameTitleId}&env={clubEnvName}&genomeid={applicationId}&actioncompleted={actionCompletedList}&debug={debug}&spaceId={spaceId}&applicationId={applicationId}",
"dynamicPanelUrl": "https://{env}public-ubiservices.ubi.com/v1/profiles/{profileId}/club/dynamicPanel/MyProfile.png?spaceId={spaceId}&noRedirect=true",
"psApiClubWebsite": "https://static8.cdn.ubi.com/u/sites/uplay/index.html",
"ps4PsnoLaunchPath": "psno://localhost/?response_type=code&client_id=171ca84a-371e-412b-a807-6531f3f1e454&scope=psn%3As2s&redirect_uri={ps4ClubWebsiteUrlEncoded}&state={ps4ClubWebsiteArgumentsPsnoEncoded}",
"psApiPsnoLaunchPath": "psno://localhost/?response_type=code&client_id=171ca84a-371e-412b-a807-6531f3f1e454&scope=psn%3As2s&redirect_uri={psApiClubWebsiteUrlEncoded}&state={psApiClubWebsiteArgumentsPsnoEncoded}",
"stadiaClubWebsiteUrl": "https://static8.cdn.ubi.com/u/sites/UbisoftClub/Stadia/index.html?url=/games/{deepLink}&spaceId={spaceId}&applicationId={applicationId}&profileId={profileId}&env={env}&displayMode={displayMode}&locale={locale}&actioncompleted={actionCompletedList}&jotunVersion={jotunVersion}&jotunBinaryVersion={jotunBinaryVersion}&ticket={ticket}",
"switchClubWebsiteUrl": "https://static8.cdn.ubi.com/u/sites/UbisoftClub/NintendoSwitch/index.html?url=/games/{deepLink}&ussdkversion={usSdkVersionName}&env={clubEnvName}&actioncompleted={actionCompletedList}&forceLang={forceLang}&profileId={profileId}&ticket={ticket}&context={context}&debug={debug}&spaceId={spaceId}&applicationId={applicationId}",
"ps4ClubWebsiteArguments": "url=/games/{deepLink}&spaceId={spaceId}&applicationId={applicationId}&context={context}&env={clubEnvName}&genomeid={applicationId}&actioncompleted={actionCompletedList}&ussdkversion={usSdkVersionName}&debug={debug}{geometry}&ticket={ticket}&profileid={profileId}",
"psApiClubWebsiteArguments": "url=/games/{deepLink}&spaceId={spaceId}&applicationId={applicationId}&context={context}&env={clubEnvName}&genomeid={applicationId}&actioncompleted={actionCompletedList}&ussdkversion={usSdkVersionName}&debug={debug}{geometry}&ticket={ticket}&profileId={profileId}&psEnterButton={psEnterButton}"
},
"relatedPopulation": null
},
"us-sdkClientUplay": {
"fields": {
"ps4UplayUrl": "https://{env}overlay.cdn.ubisoft.com/default/?microApp={microApp}&microAppParams={microAppParams}&spaceId={spaceId}&applicationId={applicationId}&profileId={profileId}&platform={platform}&locale={locale}&sdkVersion={sdkVersion}&ubiTicket={ubiTicket}&firstPartyToken={firstPartyToken}&env={environment}&deviceType=tv&playerSessionId={playerSessionId}&psEnterButton={psEnterButton}&platformVariant={platformVariant}",
"ps5UplayUrl": "https://{env}overlay.cdn.ubisoft.com/default/?microApp={microApp}&microAppParams={microAppParams}&spaceId={spaceId}&applicationId={applicationId}&profileId={profileId}&platform={platform}&locale={locale}&sdkVersion={sdkVersion}&ubiTicket={ubiTicket}&firstPartyToken={firstPartyToken}&env={environment}&deviceType=tv&playerSessionId={playerSessionId}&platformVariant={platformVariant}",
"ounceUplayUrl": "https://{env}connect.cdn.ubisoft.com/overlay/default/?microApp={microApp}&microAppParams={microAppParams}&spaceId={spaceId}&applicationId={applicationId}&profileId={profileId}&platform={platform}&locale={locale}&sdkVersion={sdkVersion}&ubiTicket={ubiTicket}&firstPartyToken={firstPartyToken}&env={environment}&deviceType=tv&playerSessionId={playerSessionId}&platformVariant={platformVariant}",
"switchUplayUrl": "https://{env}connect.cdn.ubisoft.com/overlay/default/?microApp={microApp}&microAppParams={microAppParams}&spaceId={spaceId}&applicationId={applicationId}&profileId={profileId}&platform={platform}&locale={locale}&sdkVersion={sdkVersion}&ubiTicket={ubiTicket}&firstPartyToken={firstPartyToken}&env={environment}&deviceType=tv&playerSessionId={playerSessionId}&platformVariant={platformVariant}",
"psnUplayClientId": "171ca84a-371e-412b-a807-6531f3f1e454",
"iosUplayWebLaunchUrl": "https://{env}overlay.cdn.ubisoft.com/default/?microApp={microApp}&microAppParams={microAppParams}&spaceId={spaceId}&applicationId={applicationId}&platform={platform}&locale={locale}&sdkVersion={sdkVersion}&ticket={ticket}&env={environment}&playerSessionId={playerSessionId}&deviceType=phone&isStandalone=false",
"xoneUplayUWPLaunchUrl": "Ms-xbl-55C5EE27://default?isUbisoftConnect=true&baseURL={env}overlay.cdn.ubisoft.com/default/&microApp={microApp}&microAppParams={microAppParams}&spaceId={spaceId}&applicationId={applicationId}&profileId={profileId}&platform={platform}&locale={locale}&sdkVersion={sdkVersion}&env={environment}&deviceType=tv&playerSessionId={playerSessionId}",
"xoneUplayWebLaunchUrl": "https://{env}overlay.cdn.ubisoft.com/default/?microApp={microApp}&microAppParams={microAppParams}&spaceId={spaceId}&applicationId={applicationId}&profileId={profileId}&platform={platform}&locale={locale}&sdkVersion={sdkVersion}&ubiTicket={ubiTicket}&firstPartyToken={firstPartyToken}&env={environment}&deviceType=tv&playerSessionId={playerSessionId}&platformVariant={platformVariant}",
"macosUplayWebLaunchUrl": "https://{env}overlay.cdn.ubisoft.com/default/?spaceId={spaceId}&applicationId={applicationId}&platform=macos&locale={locale}&env={environment}&deviceType=desktop",
"androidUplayWebLaunchUrl": "https://{env}overlay.cdn.ubisoft.com/default/?microApp={microApp}&microAppParams={microAppParams}&spaceId={spaceId}&applicationId={applicationId}&platform={platform}&locale={locale}&sdkVersion={sdkVersion}&ticket={ticket}&env={environment}&playerSessionId={playerSessionId}&deviceType=phone&isStandalone=false",
"iosUplayWebCompletionUrl": "ubisoftconnectscheme:",
"scarlettUplayUWPLaunchUrl": "Ms-xbl-55C5EE27://default?isUbisoftConnect=true&baseURL={env}overlay.cdn.ubisoft.com/default/&microApp={microApp}&microAppParams={microAppParams}&spaceId={spaceId}&applicationId={applicationId}&profileId={profileId}&platform={platform}&locale={locale}&sdkVersion={sdkVersion}&env={environment}&deviceType=tv&playerSessionId={playerSessionId}",
"scarlettUplayWebLaunchUrl": "https://{env}overlay.cdn.ubisoft.com/default/?microApp={microApp}&microAppParams={microAppParams}&spaceId={spaceId}&applicationId={applicationId}&profileId={profileId}&platform={platform}&locale={locale}&sdkVersion={sdkVersion}&ubiTicket={ubiTicket}&firstPartyToken={firstPartyToken}&env={environment}&deviceType=tv&playerSessionId={playerSessionId}&platformVariant={platformVariant}",
"xoneUplayWebCompletionUrl": "about:blank",
"macosUplayWebCompletionUrl": "ubisoftconnectscheme:",
"ounceUplayWebCompletionUrl": "uplayscheme:",
"switchUplayWebCompletionUrl": "uplayscheme:",
"androidUplayWebCompletionUrl": "ubisoftconnectscheme:",
"iosUplayWebLaunchUrlMicroApp": "https://{env}overlay.cdn.ubisoft.com/default/?microApp={microApp}&microAppParams={microAppParams}&spaceId={spaceId}&applicationId={applicationId}&platform={platform}&locale={locale}&sdkVersion={sdkVersion}&ticket={ticket}&env={environment}&playerSessionId={playerSessionId}&deviceType=phone&profileId={profileId}&isStandalone=false",
"scarlettUplayWebCompletionUrl": "about:blank",
"androidUplayWebLaunchUrlMicroApp": "https://{env}overlay.cdn.ubisoft.com/default/?microApp={microApp}&microAppParams={microAppParams}&spaceId={spaceId}&applicationId={applicationId}&platform={platform}&locale={locale}&sdkVersion={sdkVersion}&ticket={ticket}&env={environment}&playerSessionId={playerSessionId}&deviceType=phone&profileId={profileId}&isStandalone=false"
},
"relatedPopulation": null
},
"us-sdkClientUrlsPlaceholders": {
"fields": {
"baseurl_ws": {
"China": "wss://public-ws-ubiservices.ubisoft.cn/",
"Standard": "wss://{env}public-ws-ubiservices.ubi.com",
"China_GAAP": "wss://gaap.ubiservices.ubi.com:16000"
},
"baseurl_aws": {
"China": "https://public-ubiservices.ubisoft.cn",
"Standard": "https://{env}public-ubiservices.ubi.com",
"China_GAAP": "https://gaap.ubiservices.ubi.com:12000"
},
"baseurl_msr": {
"China": "https://public-ubiservices.ubisoft.cn",
"Standard": "https://msr-{env}public-ubiservices.ubi.com",
"China_GAAP": "https://gaap.ubiservices.ubi.com:12000"
}
},
"relatedPopulation": null
},
"us-sdkClientLogin": {
"fields": {
"populationHttpRequestOptimizationEnabled": true,
"populationHttpRequestOptimizationRetryCount": 0,
"populationHttpRequestOptimizationRetryTimeoutIntervalMsec": 5000,
"populationHttpRequestOptimizationRetryTimeoutIncrementMsec": 1000
},
"relatedPopulation": null
},
"us-sdkClientFeaturesSwitches": {
"fields": {
"populationsAutomaticUpdate": true,
"forcePrimarySecondaryStoreSyncOnReturnFromBackground": true
},
"relatedPopulation": null
},
"us-sdkClientChina": {
"fields": {
"websocketHost": "public-ws-ubiservices.ubi.com",
"tLogApplicationId": "",
"tLogApplicationKey": "",
"tLogApplicationName": ""
},
"relatedPopulation": null
},
"us-sdkClientUrls": {
"fields": {
"populations": "{baseurl_aws}/{version}/profiles/me/populations",
"profilesToken": "{baseurl_aws}/{version}/profiles/{profileId}/tokens/{token}"
},
"relatedPopulation": null
}
}
}

1655
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,10 +13,13 @@
"author": "PartyService", "author": "PartyService",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"archiver": "^7.0.1",
"axios": "^1.8.4", "axios": "^1.8.4",
"bcrypt": "^6.0.0",
"body-parser": "^1.20.3", "body-parser": "^1.20.3",
"chalk": "^5.4.1", "chalk": "^5.4.1",
"express": "^4.21.2", "express": "^4.21.2",
"express-session": "^1.18.1",
"fs": "^0.0.1-security", "fs": "^0.0.1-security",
"md5": "^2.3.0", "md5": "^2.3.0",
"pm2": "^6.0.5", "pm2": "^6.0.5",

View File

@@ -0,0 +1,704 @@
/**
* Admin Panel Plugin for OpenParty
* Provides a secure web interface for server management
*/
const express = require('express');
const session = require('express-session');
const bcrypt = require('bcrypt');
const { exec } = require('child_process');
const archiver = require('archiver'); // Moved to top
const fs = require('fs');
const path = require('path'); // Standard library
const Plugin = require('../../core/classes/Plugin'); // Adjusted path
const Logger = require('../../core/utils/logger'); // Adjusted path
class AdminPanelPlugin extends Plugin {
constructor() {
super('AdminPanelPlugin', 'Secure admin panel for server management');
this.logger = new Logger('AdminPanel');
this.sessionSecret = process.env.SESSION_SECRET || 'openparty-secure-session';
// Initialize admin password (logger might not be fully initialized with manifest name yet)
const plainPassword = process.env.ADMIN_PASSWORD || 'admin123';
console.log('[AdminPanelPlugin] Initializing admin password...'); // Use console.log before logger is guaranteed
console.log(`[AdminPanelPlugin] Using default password: ${!process.env.ADMIN_PASSWORD}`);
// Hash the admin password
try {
this.adminPassword = bcrypt.hashSync(plainPassword, 10);
this.logger.info('Admin password hashed successfully');
} catch (error) {
this.logger.error('Failed to hash admin password:', error);
throw error;
}
this.backupInterval = 24 * 60 * 60 * 1000; // 24 hours
this.startTime = Date.now();
this.stats = {
activeUsers: 0,
totalSongs: 0,
lastBackup: null,
activePlugins: 0
};
this.app = null; // To store the Express app instance
// Initialize stats update interval
setInterval(() => this.updateStats(), 30000); // Update every 30 seconds
}
initroute(app) {
this.app = app; // Store the Express app instance
this.logger.info('Initializing admin panel routes...');
// Body parsing middleware (assuming express.json and express.urlencoded are global)
// If not, they might need to be added here or in Core.js globally.
// For this integration, we'll assume they are handled globally.
// Session middleware - specific to the admin panel
app.use(session({
secret: this.sessionSecret,
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production', // Only use secure cookies in production
maxAge: 24 * 60 * 60 * 1000 // 24 hours
}
}));
// Serve static files
app.use('/panel', express.static(path.join(__dirname, 'panel/public')));
this.logger.info(`Serving static files from: ${path.join(__dirname, 'panel/public')}`);
// Make sure the directory exists
if (!fs.existsSync(path.join(__dirname, 'panel/public'))) {
this.logger.error(`Static files directory does not exist: ${path.join(__dirname, 'panel/public')}`);
fs.mkdirSync(path.join(__dirname, 'panel/public'), { recursive: true });
this.logger.info(`Created static files directory: ${path.join(__dirname, 'panel/public')}`);
}
// Authentication middleware
const requireAuth = (req, res, next) => {
if (req.session.authenticated) {
next();
} else {
res.redirect('/panel/login');
}
};
// TODO: Consider implementing rate limiting for login attempts to prevent brute-force attacks.
// Example: using a middleware like 'express-rate-limit'.
// Login route
app.get('/panel/login', (req, res) => {
res.sendFile(path.join(__dirname, 'panel/public/login.html'));
});
app.post('/panel/login', async (req, res) => {
try {
this.logger.info('Login attempt received');
this.logger.info('Request body:', req.body);
const { password } = req.body;
if (!password) {
this.logger.warn('No password provided');
return res.redirect('/panel/login?error=1');
}
this.logger.info('Comparing passwords...');
const match = await bcrypt.compare(password, this.adminPassword);
this.logger.info(`Password match result: ${match}`);
if (match) {
req.session.authenticated = true;
this.logger.info('Login successful');
res.redirect('/panel/dashboard');
} else {
this.logger.warn('Invalid password attempt');
res.redirect('/panel/login?error=1');
}
} catch (error) {
this.logger.error(`Login error: ${error.message}`);
this.logger.error(error.stack);
res.redirect('/panel/login?error=1');
}
});
// Dashboard
app.get('/panel/dashboard', requireAuth, (req, res) => {
res.sendFile(path.join(__dirname, 'panel/public/dashboard.html'));
});
// Plugin management
app.get('/panel/api/plugins', requireAuth, (req, res) => {
const pluginManager = req.app.get('pluginManager');
const pluginsMap = pluginManager.getPlugins();
const pluginsArray = Array.from(pluginsMap.values()).map(plugin => ({
name: plugin.name,
description: plugin.description,
enabled: plugin.isEnabled()
}));
res.json(pluginsArray);
});
app.post('/panel/api/plugins/toggle', requireAuth, (req, res) => {
try {
const pluginManager = req.app.get('pluginManager');
const { pluginName } = req.body; // Changed from 'name' to 'pluginName'
if (!pluginName) {
return res.status(400).json({ success: false, message: 'Plugin name (pluginName) is required' });
}
const plugin = pluginManager.getPlugin(pluginName); // Use getPlugin for direct access
if (!plugin) {
return res.status(404).json({ success: false, message: `Plugin ${pluginName} not found` });
}
let newStatusMessage;
if (plugin.isEnabled()) {
plugin.disable();
newStatusMessage = `Plugin ${pluginName} has been disabled`;
this.logger.info(newStatusMessage);
} else {
plugin.enable();
newStatusMessage = `Plugin ${pluginName} has been enabled`;
this.logger.info(newStatusMessage);
}
res.json({ success: true, message: newStatusMessage, enabled: plugin.isEnabled() });
} catch (error) {
this.logger.error(`Error toggling plugin: ${error.message}`);
res.status(500).json({ success: false, message: error.message });
}
});
// Server status endpoint
app.get('/panel/api/status', requireAuth, (req, res) => {
const settings = require('../../settings.json');
let currentVersion = 'N/A';
try {
const packageJsonPath = path.join(process.cwd(), 'package.json');
if (fs.existsSync(packageJsonPath)) {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
currentVersion = packageJson.version;
}
} catch (e) { this.logger.error("Failed to read package.json version for status API", e); }
res.json({
maintenance: settings.server.serverstatus.isMaintenance,
uptime: Math.floor((Date.now() - this.startTime) / 1000),
version: currentVersion
});
});
// Server stats endpoint
app.get('/panel/api/stats', requireAuth, (req, res) => {
res.json(this.stats);
});
// Server management endpoints
app.post('/panel/api/update', requireAuth, async (req, res) => {
try {
const { stdout, stderr } = await new Promise((resolve, reject) => {
exec('git pull && npm install', (error, stdout, stderr) => {
if (error) reject(error);
else resolve({ stdout, stderr });
});
});
this.logger.info('Update successful');
res.json({ message: 'Update successful', output: stdout, details: stderr });
} catch (error) {
this.logger.error(`Update error: ${error.message}`);
res.status(500).json({ error: error.message });
}
});
app.post('/panel/api/restart', requireAuth, (req, res) => {
res.json({ message: 'Server restarting...' });
process.exit(42); // Trigger restart through PM2
});
app.post('/panel/api/reload-plugins', requireAuth, (req, res) => {
try {
const pluginManager = req.app.get('pluginManager');
pluginManager.loadPlugins(); // No longer needs settings.modules
this.logger.info('Plugins reloaded via API.');
res.json({ message: 'Plugins reloaded successfully' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Savedata management
app.get('/panel/api/savedata', requireAuth, (req, res) => {
try {
const savedataPath = path.join(process.cwd(), 'database/data');
if (!fs.existsSync(savedataPath)) {
this.logger.error(`Savedata directory does not exist: ${savedataPath}`);
return res.status(404).json({ error: 'Savedata directory not found' });
}
const files = fs.readdirSync(savedataPath)
.filter(file => file.endsWith('.json'))
.map(file => {
const filePath = path.join(savedataPath, file);
const stats = fs.statSync(filePath);
return {
name: file,
size: stats.size,
modified: stats.mtime
};
});
res.json(files);
} catch (error) {
this.logger.error(`Error getting savedata: ${error.message}`);
res.status(500).json({ error: error.message });
}
});
app.post('/panel/api/savedata/:type', requireAuth, (req, res) => {
const { type } = req.params;
const { data } = req.body;
try {
fs.writeFileSync(
path.join(process.cwd(), 'database/data', `${type}.json`),
JSON.stringify(data, null, 2)
);
// Commit changes to Git
exec(`git add . && git commit -m "Update ${type} savedata" && git push`, {
cwd: process.cwd()
}, (error) => {
if (error) {
this.logger.error(`Git error: ${error.message}`);
}
});
res.json({ message: 'Savedata updated successfully' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Backup system
this.setupAutomaticBackup();
// Get backups list
app.get('/panel/api/backups', requireAuth, (req, res) => {
try {
const backupsPath = path.join(process.cwd(), 'backups');
if (!fs.existsSync(backupsPath)) {
this.logger.info(`Backups directory does not exist, creating: ${backupsPath}`);
fs.mkdirSync(backupsPath, { recursive: true });
return res.json([]);
}
const backups = fs.readdirSync(backupsPath)
.filter(dir => {
const dirPath = path.join(backupsPath, dir);
return fs.statSync(dirPath).isDirectory();
})
.map(dir => {
const dirPath = path.join(backupsPath, dir);
const stats = fs.statSync(dirPath);
// Calculate total size of backup
let totalSize = 0;
const dataPath = path.join(dirPath, 'data');
if (fs.existsSync(dataPath)) {
const calculateDirSize = (dirPath) => {
let size = 0;
const files = fs.readdirSync(dirPath);
for (const file of files) {
const filePath = path.join(dirPath, file);
const stat = fs.statSync(filePath);
if (stat.isDirectory()) {
size += calculateDirSize(filePath);
} else {
size += stat.size;
}
}
return size;
};
totalSize = calculateDirSize(dataPath);
}
return {
filename: dir,
date: new Date(dir.replace(/-/g, ':')),
size: totalSize,
type: 'Auto'
};
});
// Sort by date (newest first)
backups.sort((a, b) => b.date - a.date);
res.json(backups);
} catch (error) {
this.logger.error(`Error getting backups: ${error.message}`);
res.status(500).json({ error: error.message });
}
});
// Create backup
app.post('/panel/api/backup', requireAuth, (req, res) => {
this.createBackup()
.then(() => res.json({ success: true, message: 'Backup created successfully' }))
.catch(error => res.status(500).json({ success: false, error: error.message }));
});
// Download backup
app.get('/panel/api/backups/download/:filename', requireAuth, (req, res) => {
try {
const { filename } = req.params;
if (!filename) {
return res.status(400).json({ success: false, message: 'Backup filename is required' });
}
const backupPath = path.join(process.cwd(), 'backups', filename);
if (!fs.existsSync(backupPath)) {
this.logger.error(`Backup not found: ${backupPath}`);
return res.status(404).json({ success: false, message: 'Backup not found' });
}
// Create a zip file of the backup
const zipFilename = `${filename}.zip`;
const zipPath = path.join(process.cwd(), 'backups', zipFilename);
// Create a write stream for the zip file
const output = fs.createWriteStream(zipPath);
const archive = archiver('zip', {
zlib: { level: 9 } // Maximum compression
});
// Listen for all archive data to be written
output.on('close', () => {
this.logger.info(`Backup archive created: ${zipPath} (${archive.pointer()} bytes)`);
// Send the zip file
res.download(zipPath, zipFilename, (err) => {
if (err) {
this.logger.error(`Error sending backup: ${err.message}`);
}
// Delete the temporary zip file after sending
fs.unlink(zipPath, (unlinkErr) => {
if (unlinkErr) {
this.logger.error(`Error deleting temporary zip file: ${unlinkErr.message}`);
}
});
});
});
// Handle errors
archive.on('error', (err) => {
this.logger.error(`Error creating backup archive: ${err.message}`);
res.status(500).json({ success: false, message: `Error creating backup archive: ${err.message}` });
});
// Pipe archive data to the output file
archive.pipe(output);
// Add the backup directory to the archive
archive.directory(backupPath, false);
// Finalize the archive
archive.finalize();
} catch (error) {
this.logger.error(`Error downloading backup: ${error.message}`);
res.status(500).json({ success: false, message: error.message });
}
});
// Delete backup
app.delete('/panel/api/backups/delete/:filename', requireAuth, (req, res) => {
try {
const { filename } = req.params;
if (!filename) {
return res.status(400).json({ success: false, message: 'Backup filename is required' });
}
const backupPath = path.join(process.cwd(), 'backups', filename);
if (!fs.existsSync(backupPath)) {
this.logger.error(`Backup not found: ${backupPath}`);
return res.status(404).json({ success: false, message: 'Backup not found' });
}
// Delete the backup directory recursively
fs.rmSync(backupPath, { recursive: true, force: true });
this.logger.info(`Backup deleted: ${filename}`);
res.json({ success: true, message: 'Backup deleted successfully' });
} catch (error) {
this.logger.error(`Error deleting backup: ${error.message}`);
res.status(500).json({ success: false, message: error.message });
}
});
// Check for updates
app.get('/panel/api/check-updates', requireAuth, async (req, res) => {
try {
// Get current version from package.json
const packagePath = path.join(process.cwd(), 'package.json');
let currentVersion = '1.0.0';
if (fs.existsSync(packagePath)) {
const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
currentVersion = packageJson.version || '1.0.0';
}
// For demo purposes, we'll simulate checking for updates
// In a real implementation, you would fetch from a remote repository
const hasUpdate = Math.random() > 0.5; // Randomly determine if update is available
if (hasUpdate) {
// Simulate a newer version
const newVersion = currentVersion.split('.')
.map((part, index) => index === 2 ? parseInt(part) + 1 : part)
.join('.');
res.json({
available: true,
currentVersion,
version: newVersion,
changelog: 'Bug fixes and performance improvements.'
});
} else {
res.json({
available: false,
currentVersion
});
}
} catch (error) {
this.logger.error(`Error checking for updates: ${error.message}`);
res.status(500).json({ error: error.message });
}
});
// Maintenance mode
app.post('/panel/api/maintenance', requireAuth, (req, res) => {
try {
const settingsPath = path.join(process.cwd(), 'settings.json');
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
// Toggle maintenance mode
settings.server.serverstatus.isMaintenance = !settings.server.serverstatus.isMaintenance;
// Write updated settings back to file
fs.writeFileSync(
settingsPath,
JSON.stringify(settings, null, 2)
);
res.json({
success: true,
enabled: settings.server.serverstatus.isMaintenance
});
} catch (error) {
this.logger.error(`Error toggling maintenance mode: ${error.message}`);
res.status(500).json({
success: false,
error: error.message
});
}
});
// Settings endpoint
app.get('/panel/api/settings', requireAuth, (req, res) => {
try {
const settingsPath = path.join(process.cwd(), 'settings.json'); // process.cwd() is the root
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
// Return a sanitized version of settings (remove sensitive data if needed)
res.json({
server: {
port: settings.server.port,
isPublic: settings.server.isPublic,
enableSSL: settings.server.enableSSL,
domain: settings.server.domain,
modName: settings.server.modName,
maintenance: settings.server.serverstatus.isMaintenance,
channel: settings.server.serverstatus.channel
},
// Get plugin info from PluginManager and manifests
plugins: [] // Placeholder, will be populated below
});
const pluginManager = req.app.get('pluginManager');
if (pluginManager) {
const pluginsMap = pluginManager.getPlugins();
res.locals.plugins = Array.from(pluginsMap.values()).map(p => ({
name: p.manifest.name,
description: p.manifest.description,
execution: p.manifest.execution,
enabled: p.isEnabled(),
version: p.manifest.version
}));
}
res.json(res.locals); // Send the combined data
} catch (error) {
this.logger.error(`Error getting settings: ${error.message}`);
res.status(500).json({ error: error.message });
}
});
app.post('/panel/api/settings', requireAuth, (req, res) => {
try {
const { server } = req.body;
if (!server) {
return res.status(400).json({ success: false, message: 'Server settings are required' });
}
const settingsPath = path.join(process.cwd(), 'settings.json');
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
// Update only allowed settings
if (server.port !== undefined) settings.server.port = server.port;
if (server.isPublic !== undefined) settings.server.isPublic = server.isPublic;
if (server.enableSSL !== undefined) settings.server.enableSSL = server.enableSSL;
if (server.domain !== undefined) settings.server.domain = server.domain;
if (server.modName !== undefined) settings.server.modName = server.modName;
if (server.maintenance !== undefined) settings.server.serverstatus.isMaintenance = server.maintenance;
if (server.channel !== undefined) settings.server.serverstatus.channel = server.channel;
// Write updated settings back to file
fs.writeFileSync(
settingsPath,
JSON.stringify(settings, null, 2)
);
res.json({
success: true,
message: 'Settings updated successfully'
});
} catch (error) {
this.logger.error(`Error updating settings: ${error.message}`);
res.status(500).json({
success: false,
error: error.message
});
}
});
// Logs endpoint
app.get('/panel/api/logs', requireAuth, (req, res) => {
try {
const { level = 'all', limit = 100 } = req.query;
const logsDir = path.join(process.cwd(), 'logs');
// Create logs directory if it doesn't exist
if (!fs.existsSync(logsDir)) {
fs.mkdirSync(logsDir, { recursive: true });
}
// For demo purposes, generate some sample logs if no log file exists
const logFile = path.join(logsDir, 'server.log');
if (!fs.existsSync(logFile)) {
const sampleLogs = [
'[INFO] Server started successfully',
'[INFO] Loaded 3 plugins',
'[WARNING] Plugin XYZ is using deprecated API',
'[ERROR] Failed to connect to database',
'[INFO] User logged in: admin',
'[DEBUG] Processing request: GET /api/songs',
'[INFO] Request completed in 120ms'
];
fs.writeFileSync(logFile, sampleLogs.join('\n'));
}
// Read log file
let logs = fs.readFileSync(logFile, 'utf8').split('\n').filter(Boolean);
// Filter by level if specified
if (level !== 'all') {
const levelUpper = level.toUpperCase();
logs = logs.filter(log => log.includes(`[${levelUpper}]`));
}
// Limit number of logs
logs = logs.slice(-parseInt(limit));
res.json({
logs,
total: logs.length
});
} catch (error) {
this.logger.error(`Error getting logs: ${error.message}`);
res.status(500).json({ error: error.message });
}
});
this.logger.info('Admin panel routes initialized');
}
async updateStats() {
try {
// Update active users (example: count connected clients)
this.stats.activeUsers = Object.keys(this.app?.io?.sockets?.sockets || global.io?.sockets?.sockets || {}).length; // Prefer app.io if available
// Update total songs
const songPath = path.join(process.cwd(), 'database/data/songs.json');
if (fs.existsSync(songPath)) {
const songs = JSON.parse(fs.readFileSync(songPath, 'utf8'));
this.stats.totalSongs = Object.keys(songs).length;
} else { this.stats.totalSongs = 0; }
// Update active plugins count
const pluginManager = this.app?.get('pluginManager');
if (pluginManager) {
this.stats.activePlugins = Array.from(pluginManager.getPlugins().values()).filter(p => p.isEnabled()).length;
}
} catch (error) {
this.logger.error(`Stats update error: ${error.message}`);
}
}
async createBackup() {
try {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupDir = path.join(process.cwd(), 'backups', timestamp);
fs.mkdirSync(backupDir, { recursive: true });
// Backup database and savedata
const dataDir = path.join(process.cwd(), 'database/data');
fs.cpSync(dataDir, path.join(backupDir, 'data'), { recursive: true });
// Create Git tag for the backup
await new Promise((resolve, reject) => {
exec(`git tag backup-${timestamp} && git push origin backup-${timestamp}`, {
cwd: process.cwd()
}, (error, stdout, stderr) => {
if (error) {
this.logger.error(`Git tagging/pushing error during backup: ${error.message}`);
this.logger.error(`Git stderr: ${stderr}`);
reject(error);
} else {
this.logger.info(`Git tag and push successful for backup-${timestamp}`);
this.logger.info(`Git stdout: ${stdout}`);
resolve();
}
});
});
// Update last backup timestamp
this.stats.lastBackup = timestamp;
this.logger.info(`Backup created successfully: ${timestamp}`);
} catch (error) {
this.logger.error(`Backup creation failed: ${error.message}`);
throw error;
}
}
setupAutomaticBackup() {
setInterval(() => {
this.createBackup().catch(error => {
this.logger.error(`Automatic backup failed: ${error.message}`);
});
}, this.backupInterval);
}
}
module.exports = new AdminPanelPlugin();

View File

@@ -0,0 +1,71 @@
# OpenParty Admin Panel Plugin
## Overview
Secure web-based administration interface for OpenParty server management. Features a modern dark theme UI and comprehensive server management capabilities.
## Features
- Secure session-based authentication
- Server status monitoring
- Plugin management
- Savedata modification and Git integration
- Automated backups
- Update management
- Maintenance mode control
- Server logs viewer
## Installation
1. Install dependencies:
```bash
cd plugins/panel
npm install
```
2. Add the plugin to `settings.json`:
```json
{
"modules": [
{
"name": "AdminPanelPlugin",
"description": "Secure admin panel for server management",
"path": "{dirname}/plugins/AdminPanelPlugin.js",
"execution": "init"
}
]
}
```
3. Set environment variables (optional):
- `SESSION_SECRET`: Custom session secret (default: 'openparty-secure-session')
- `ADMIN_PASSWORD`: Admin password (default: 'admin123')
## Security
- Session-based authentication
- Password hashing with bcrypt
- HTTPS-only cookie security
- Session expiration
## Usage
1. Access the panel at: `https://your-server/panel`
2. Login with admin credentials
3. Use the dashboard to manage:
- Server status and statistics
- Plugin management
- Savedata modifications
- Backup management
- Server updates
- Maintenance mode
- Server logs
## Automated Features
- Daily automated backups
- Git integration for savedata changes
- Plugin hot-reloading
- Server update management
## Development
The panel uses a modern tech stack:
- Express.js for backend
- SQLite for session storage
- Modern CSS with dark theme
- Responsive design
- Vanilla JavaScript for frontend interactions

View File

@@ -0,0 +1,11 @@
{
"name": "openparty-admin-panel",
"version": "1.0.0",
"description": "Admin panel plugin for OpenParty server",
"private": true,
"dependencies": {
"bcrypt": "^5.1.1",
"express-session": "^1.17.3",
"connect-sqlite3": "^0.9.13"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,67 @@
// Plugin management functionality
async function loadPlugins() {
try {
const response = await fetch('/panel/api/plugins');
const plugins = await response.json();
const pluginsList = document.getElementById('pluginsList');
pluginsList.innerHTML = plugins.map(plugin => `
<tr class="plugin-row ${plugin.enabled ? 'plugin-enabled' : 'plugin-disabled'}">
<td class="plugin-name">${plugin.name}</td>
<td class="plugin-description">${plugin.description || 'No description available.'}</td>
<td class="plugin-status">
<span class="badge ${plugin.enabled ? 'bg-success' : 'bg-secondary'}">
<!-- Example using Font Awesome icons: replace with your icon library or remove if not using icons -->
<!-- <i class="fas ${plugin.enabled ? 'fa-check-circle' : 'fa-times-circle'}"></i> -->
${plugin.enabled ? 'Enabled' : 'Disabled'}
</span>
</td>
<td class="plugin-actions">
<button
class="btn btn-sm ${plugin.enabled ? 'btn-outline-warning' : 'btn-outline-success'}"
onclick="togglePlugin('${plugin.name}')"
title="${plugin.enabled ? 'Disable' : 'Enable'} ${plugin.name}">
<!-- Example using Font Awesome icons: replace with your icon library or remove if not using icons -->
<!-- <i class="fas ${plugin.enabled ? 'fa-toggle-off' : 'fa-toggle-on'}"></i> -->
${plugin.enabled ? 'Disable' : 'Enable'}
</button>
</td>
</tr>
`).join('');
/*
Suggested CSS for the new classes (add to your panel's CSS file):
.plugin-row.plugin-disabled { opacity: 0.7; }
.plugin-name { font-weight: bold; }
.badge { padding: 0.4em 0.6em; font-size: 0.9em; }
.bg-success { background-color: #28a745; color: white; }
.bg-secondary { background-color: #6c757d; color: white; }
.btn-sm { padding: 0.25rem 0.5rem; font-size: .875rem; }
.btn-outline-warning { border-color: #ffc107; color: #ffc107; }
.btn-outline-warning:hover { background-color: #ffc107; color: #212529; }
.btn-outline-success { border-color: #28a745; color: #28a745; }
.btn-outline-success:hover { background-color: #28a745; color: white; }
*/
} catch (error) {
showToast(error.message, 'error');
}
}
async function togglePlugin(pluginName) {
try {
const response = await fetch('/panel/api/plugins/toggle', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ pluginName })
});
const result = await response.json();
showToast(result.message);
loadPlugins(); // Refresh the plugins list
} catch (error) {
showToast(error.message, 'error');
}
}
// Load plugins when the plugins section is shown
document.querySelector('[onclick="showSection(\'plugins\')"]')
.addEventListener('click', loadPlugins);

View File

@@ -0,0 +1,100 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OpenParty Admin Panel - Login</title>
<!-- Tailwind CSS CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- Google Fonts: Inter -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
primary: '#0f172a', // Dark blue/slate
secondary: '#1e293b', // Lighter blue/slate
tertiary: '#334155', // Even lighter blue/slate
accent: '#3b82f6', // Bright blue
success: '#10b981', // Green
warning: '#f59e0b', // Amber
error: '#ef4444', // Red
info: '#06b6d4' // Cyan
},
fontFamily: {
sans: ['Inter', 'sans-serif'],
mono: ['Fira Code', 'monospace']
}
}
}
}
</script>
<style>
/* Custom animations */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fadeIn {
animation: fadeIn 0.5s ease-out forwards;
}
</style>
</head>
<body class="bg-primary min-h-screen flex items-center justify-center p-4 font-sans text-white">
<div class="animate-fadeIn bg-secondary rounded-lg shadow-xl w-full max-w-md p-8 border border-tertiary">
<div class="text-center mb-8">
<i class="fas fa-lock text-accent text-4xl mb-4"></i>
<h1 class="text-2xl font-bold mb-2">OpenParty Admin Panel</h1>
<p class="text-gray-400">Enter your password to continue</p>
</div>
<form class="space-y-6" action="/panel/login" method="POST">
<div class="space-y-2">
<label for="password" class="block text-sm font-medium text-gray-400">Password</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-key text-gray-500"></i>
</div>
<input
type="password"
id="password"
name="password"
required
class="bg-tertiary border border-gray-700 text-white pl-10 block w-full rounded-md py-3 px-4 focus:ring-2 focus:ring-accent focus:border-accent focus:outline-none transition-all duration-200"
placeholder="Enter admin password"
>
</div>
</div>
<button
type="submit"
class="w-full flex justify-center py-3 px-4 border border-transparent rounded-md shadow-sm text-white bg-accent hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-accent transition-all duration-200"
>
<i class="fas fa-sign-in-alt mr-2"></i> Login
</button>
</form>
<div id="error-message" class="mt-4 text-center text-error hidden">
<i class="fas fa-exclamation-circle mr-1"></i>
Invalid password. Please try again.
</div>
</div>
<script>
// Check for error parameter in URL
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('error') === '1') {
document.getElementById('error-message').classList.remove('hidden');
}
</script>
</body>
</html>

View File

@@ -3,8 +3,8 @@
* Handles World Dance Floor (WDF) related routes as a plugin * Handles World Dance Floor (WDF) related routes as a plugin
*/ */
const axios = require('axios'); const axios = require('axios');
const Plugin = require('../classes/Plugin'); // Assuming Plugin is located at ../core/classes/Plugin.js const Plugin = require('../../core/classes/Plugin'); // Assuming Plugin is located at ../core/classes/Plugin.js
const Logger = require('../utils/logger'); const Logger = require('../../core/utils/logger');
class WDFPlugin extends Plugin { class WDFPlugin extends Plugin {
/** /**

View File

@@ -0,0 +1,8 @@
{
"name": "FakeWdfPlugin",
"version": "1.0.0",
"description": "Create a fake response for WDF so that the mod can run on older version.",
"main": "FakeWdfPlugin.js",
"author": "OpenParty",
"execution": "init"
}

View File

@@ -2,7 +2,7 @@
* Example HelloWorld Plugin for OpenParty * Example HelloWorld Plugin for OpenParty
* Demonstrates how to create a plugin using the new class-based architecture * Demonstrates how to create a plugin using the new class-based architecture
*/ */
const Plugin = require('../core/classes/Plugin'); const Plugin = require('../../core/classes/Plugin'); // Adjusted path
class HelloWorldPlugin extends Plugin { class HelloWorldPlugin extends Plugin {
/** /**

View File

@@ -0,0 +1,8 @@
{
"name": "HelloWorld",
"version": "1.0.0",
"description": "A simple Hello World plugin.",
"main": "HelloWorld.js",
"author": "OpenParty",
"execution": "init"
}

View File

@@ -8,7 +8,7 @@
"forcePort": false, "forcePort": false,
"isPublic": true, "isPublic": true,
"enableSSL": true, "enableSSL": true,
"domain": "jdp.justdancenext.xyz", "domain": "justdanceservices.prjktla.online",
"modName": "Just Dance Unlimited Mod", "modName": "Just Dance Unlimited Mod",
"serverstatus": { "serverstatus": {
"isMaintenance": false, "isMaintenance": false,
@@ -16,17 +16,17 @@
} }
}, },
"modules": [ "modules": [
{
"name": "WDFPlugin",
"description": "Create a fake response for WDF so that the mod can run on older version",
"path": "{dirname}/core/wdf/FakeWdfPlugin.js",
"execution": "init"
},
{ {
"name": "HelloWorldPlugin", "name": "HelloWorldPlugin",
"description": "A simple example plugin that demonstrates the plugin system", "description": "A simple example plugin that demonstrates the plugin system",
"path": "{dirname}/plugins/HelloWorldPlugin.js", "path": "{dirname}/plugins/HelloWorldPlugin.js",
"execution": "init" "execution": "init"
},
{
"name": "AdminPanelPlugin",
"description": "Secure admin panel for server management and maintenance",
"path": "{dirname}/plugins/AdminPanelPlugin.js",
"execution": "init"
} }
] ]
} }