Files
p2ns/plugin-sites/global.profile/index.js
T
2026-05-30 22:27:42 -04:00

253 lines
8.8 KiB
JavaScript

/**
* Global Profile Plugin
*
* Provides universal user identity system across all P2NS plugins with:
* - HyperDB-backed profile storage with P2P replication
* - Multi-size avatar storage via HyperDB (base64 encoded)
* - Extensible field system for plugin-registered custom fields
* - Real-time profile updates via WebSocket
*/
const sdk = require('../../includes/plugins/sdk');
// Import modules
const { getProfileFromDB, saveProfileToDB } = require('./database');
const { broadcastProfileUpdate, setupWebSocketHandlers, teardownWebSocketHandlers, getReplicationStatus } = require('./websocket');
const { setupDatabaseWatcher, teardownDatabaseWatcher } = require('./watcher');
const { handleProfileRoutes } = require('./routes/profile');
const { handleAvatarRoutes } = require('./routes/avatar');
const { handleProfilesRoutes } = require('./routes/profiles');
const { handleFieldsRoutes } = require('./routes/fields');
const { handleStatsRoutes } = require('./routes/stats');
const { handleDocsRoutes } = require('./routes/docs');
let peerConnectedHandler = null;
let peerDisconnectedHandler = null;
/**
* Plugin Handler
*/
async function handler(req, res) {
try {
// Handle CORS (including OPTIONS preflight)
if (sdk.router.handleCORS(req, res, { origin: '*' })) {
return true; // OPTIONS request handled
}
const { path, query, method } = sdk.router.parseRequest(req);
// Handle root path - serve index.html
if (path === '' || path === '/') {
return false;
}
// Try each route module in order
// Avatar routes must come BEFORE profile routes to avoid conflicts with /api/profile/:peerId
const routeModules = [
{ name: 'avatar', handler: handleAvatarRoutes },
{ name: 'profile', handler: handleProfileRoutes },
{ name: 'profiles', handler: handleProfilesRoutes },
{ name: 'fields', handler: handleFieldsRoutes },
{ name: 'stats', handler: handleStatsRoutes },
{ name: 'docs', handler: handleDocsRoutes }
];
for (const routeModule of routeModules) {
const result = await routeModule.handler(req, res, path, query, method);
if (result !== false) {
return result;
}
}
// Return false for all other routes to allow static file serving
return false;
} catch (err) {
sdk.log.error('global.profile', `Error handling request: ${err.message}`);
return sdk.router.error(res, 'Internal Server Error', 500);
}
}
/**
* Plugin Initialization Hook
*/
async function onInit() {
// Store the plugin domain globally for easy access
global.PLUGIN_DOMAIN_GLOBAL = process.env.PLUGIN_DOMAIN || 'global.profile';
sdk.log.info('global.profile', 'Initializing Global Profile plugin...');
try {
// Database is opened by plugin-handler before onInit; a single ready() is enough.
try {
await sdk.db.ready(5000);
sdk.log.info('global.profile', 'Database ready');
} catch (err) {
sdk.log.warn('global.profile', `Database not ready during init: ${err.message}`);
}
// Avatars are now stored in HyperDB, so no separate drive initialization needed
sdk.log.info('global.profile', 'Avatars will be stored in HyperDB (automatic replication)');
try {
sdk.channels.register('profile-updates', {
methods: {
'profile.update': async (data, { peerId }) => {
sdk.log.debug('global.profile', `Received profile update from peer ${peerId.substring(0, 16)}... via RPC`);
sdk.websocket.broadcast({
...data,
timestamp: data.timestamp || Date.now()
});
return null;
}
},
onPeerOpen: (peerId) => {
sdk.log.debug('global.profile', `Peer ${peerId.substring(0, 16)}... connected to profile-updates RPC`);
},
onPeerClose: (peerId) => {
sdk.log.debug('global.profile', `Peer ${peerId.substring(0, 16)}... disconnected from profile-updates RPC`);
}
});
sdk.log.info('global.profile', 'P2P RPC protocol for profile updates registered');
} catch (err) {
sdk.log.error('global.profile', `Error creating profile-updates channel: ${err.message}`);
}
// Setup WebSocket handlers
setupWebSocketHandlers();
// Setup database watcher (async, but don't wait - it will handle errors internally)
setupDatabaseWatcher().catch(err => {
sdk.log.error('global.profile', `Error in database watcher setup: ${err.message}`);
});
// Watch for peer connections/disconnections to update online status
peerConnectedHandler = async (data) => {
const { getProfileFromDB } = require('./database');
const profile = await getProfileFromDB(data.peerId);
if (profile) {
// Parse JSON fields for broadcast
try {
profile.customFields = profile.customFields ? JSON.parse(profile.customFields) : {};
profile.tags = profile.tags ? JSON.parse(profile.tags) : [];
} catch (err) {
profile.customFields = {};
profile.tags = [];
}
broadcastProfileUpdate({
type: 'profile-update',
profile: profile
});
}
// Trigger active replication update when peer connects (backup mechanism)
// The replication manager will also handle this, but this ensures updates happen
try {
// Give replication a moment to establish
await new Promise(resolve => setTimeout(resolve, 1000));
// Check if database replication is active and trigger update
const dbReplication = sdk.db.replication;
if (dbReplication && dbReplication.isActive()) {
const core = sdk.db.getCore();
if (core && !core.closed) {
const oldLength = core.length;
await core.update({ wait: true });
const newLength = core.length;
if (newLength > oldLength) {
sdk.log.info('global.profile', `Database updated after peer ${data.peerId.slice(0, 16)} connection, length: ${oldLength} -> ${newLength}`);
}
}
}
// Avatars are now in HyperDB, so they replicate automatically with the database
// No separate drive update needed
} catch (err) {
sdk.log.debug('global.profile', `Error triggering replication update after peer connection: ${err.message}`);
}
};
sdk.events.on('peer-connected', peerConnectedHandler);
peerDisconnectedHandler = async (data) => {
const { getProfileFromDB } = require('./database');
const profile = await getProfileFromDB(data.peerId);
if (profile) {
// Parse JSON fields for broadcast
try {
profile.customFields = profile.customFields ? JSON.parse(profile.customFields) : {};
profile.tags = profile.tags ? JSON.parse(profile.tags) : [];
} catch (err) {
profile.customFields = {};
profile.tags = [];
}
broadcastProfileUpdate({
type: 'profile-update',
profile: profile
});
}
};
sdk.events.on('peer-disconnected', peerDisconnectedHandler);
// Verify replication is enabled
try {
const dbReplication = sdk.db.replication;
if (dbReplication) {
const dbStatus = dbReplication.getStatus();
if (dbStatus && dbStatus.active) {
sdk.log.info('global.profile', `Database replication active (${dbStatus.peers} peers)`);
} else {
sdk.log.info('global.profile', 'Database replication will be enabled automatically');
}
}
} catch (err) {
sdk.log.debug('global.profile', `Could not check database replication status: ${err.message}`);
}
// Replication is now handled automatically via global Corestore and replication managers
// Active updates are triggered both by replication managers and peer connection events
sdk.log.info('global.profile', 'Plugin initialized successfully');
} catch (err) {
sdk.log.error('global.profile', `Error during initialization: ${err.message}`);
if (err.stack) {
sdk.log.error('global.profile', `Stack trace: ${err.stack}`);
}
}
}
/**
* Plugin Shutdown Hook
*/
async function onShutdown() {
sdk.log.info('global.profile', 'Shutting down Global Profile plugin...');
try {
teardownWebSocketHandlers();
teardownDatabaseWatcher();
if (peerConnectedHandler) {
sdk.events.off('peer-connected', peerConnectedHandler);
peerConnectedHandler = null;
}
if (peerDisconnectedHandler) {
sdk.events.off('peer-disconnected', peerDisconnectedHandler);
peerDisconnectedHandler = null;
}
sdk.websocket.close();
sdk.log.info('global.profile', 'Plugin shutdown complete');
} catch (err) {
sdk.log.error('global.profile', `Error during shutdown: ${err.message}`);
}
}
// Export the plugin interface
module.exports = {
handler,
onInit,
onShutdown
};