/** * Global Profile Plugin - Profile Routes * * Handles profile-related API endpoints */ const sdk = require('../../../includes/plugins/sdk'); const { getLocalPeerId } = require('../utils'); const { getProfileFromDB, saveProfileToDB, deleteAvatarsForPeer } = require('../database'); const { dbErrorResponse } = require('../db-errors'); const { broadcastProfileUpdate } = require('../websocket'); /** * Helper to call router methods with CORS support */ const router = { json: (res, data, status, req) => sdk.router.json(res, data, status, req), error: (res, message, status, req) => sdk.router.error(res, message, status, req), notFound: (res, message, req) => sdk.router.notFound(res, message, req) }; /** * Handle profile-related routes */ async function handleProfileRoutes(req, res, path, query, method) { const localPeerId = getLocalPeerId(); // GET /api/profile - Get local user's profile if (path === 'api/profile' && method === 'GET') { let requestTimeout = null; try { if (!localPeerId) { return router.json(res, { error: 'Local peer ID not available' }, 500, req); } // Add overall timeout for the entire request requestTimeout = setTimeout(() => { if (!res.headersSent) { sdk.log.error('global.profile', 'GET /api/profile request timeout'); try { router.error(res, 'Request timeout - database may not be ready', 503, req); } catch (e) { // Ignore if response already sent } } }, 10000); // 10 second timeout const profile = await getProfileFromDB(localPeerId); if (requestTimeout) { clearTimeout(requestTimeout); requestTimeout = null; } if (!profile) { // Return default profile return router.json(res, { peerId: localPeerId, displayName: '', bio: '', website: '', email: '', xUsername: '', avatarHash: '', customFields: {}, tags: [], lastUpdated: null }, 200, req); } // Parse JSON fields try { profile.customFields = profile.customFields ? JSON.parse(profile.customFields) : {}; profile.tags = profile.tags ? JSON.parse(profile.tags) : []; } catch (err) { profile.customFields = {}; profile.tags = []; } return router.json(res, profile, 200, req); } catch (err) { if (requestTimeout) { clearTimeout(requestTimeout); } sdk.log.error('global.profile', `Error getting profile: ${err.message}`); if (!res.headersSent) { const { status, body } = dbErrorResponse(err); return router.json(res, body, status, req); } } } // PUT /api/profile - Update local user's profile if (path === 'api/profile' && method === 'PUT') { sdk.log.info('global.profile', '[PUT /api/profile] Request received'); const startTime = Date.now(); // Require authentication for write operations sdk.log.debug('global.profile', '[PUT /api/profile] Checking authentication...'); const peerId = await sdk.auth.requireLocalPeer(req, res); if (!peerId) { sdk.log.warn('global.profile', '[PUT /api/profile] Authentication failed'); return true; // 401 already sent } sdk.log.debug('global.profile', `[PUT /api/profile] Authenticated as ${peerId.slice(0, 16)}...`); if (!localPeerId) { sdk.log.error('global.profile', '[PUT /api/profile] Local peer ID not available'); return router.json(res, { error: 'Local peer ID not available' }, 500, req); } let body = ''; req.on('data', chunk => { body += chunk.toString(); }); req.on('end', async () => { try { sdk.log.debug('global.profile', `[PUT /api/profile] Body received (${body.length} bytes)`); if (!body) { sdk.log.warn('global.profile', '[PUT /api/profile] Empty request body'); return sdk.router.error(res, 'Request body is required', 400, req); } let updates; try { updates = JSON.parse(body); sdk.log.debug('global.profile', `[PUT /api/profile] Parsed updates: tags=${JSON.stringify(updates.tags)}`); } catch (parseErr) { sdk.log.error('global.profile', `[PUT /api/profile] JSON parse error: ${parseErr.message}`); return sdk.router.error(res, 'Invalid JSON in request body', 400, req); } // Get existing profile or create new (with retry for DB initialization) sdk.log.debug('global.profile', '[PUT /api/profile] Getting existing profile from DB...'); const getProfileStart = Date.now(); let profile = null; const MAX_RETRIES = 10; for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { try { profile = await getProfileFromDB(localPeerId); sdk.log.debug('global.profile', `[PUT /api/profile] Got profile in ${Date.now() - getProfileStart}ms`); break; } catch (err) { if (err.message && err.message.includes('Database not initialized') && attempt < MAX_RETRIES) { sdk.log.warn('global.profile', `[PUT /api/profile] Database not initialized when getting profile, retrying... (${attempt}/${MAX_RETRIES})`); await sdk.utils.sleep(1000); continue; } // If it's not a DB init error, or we've exhausted retries, throw if (!err.message || !err.message.includes('Database not initialized')) { throw err; } // If we've exhausted retries, profile will be null and we'll create a new one sdk.log.warn('global.profile', '[PUT /api/profile] Exhausted retries, creating new profile'); break; } } if (!profile) { sdk.log.info('global.profile', '[PUT /api/profile] Creating new profile'); profile = { peerId: localPeerId, displayName: '', bio: '', website: '', email: '', xUsername: '', avatarHash: '', customFields: JSON.stringify({}), tags: JSON.stringify([]), lastUpdated: Date.now() }; } // Update fields sdk.log.debug('global.profile', '[PUT /api/profile] Updating profile fields...'); if (updates.displayName !== undefined) profile.displayName = updates.displayName; if (updates.bio !== undefined) profile.bio = updates.bio; if (updates.website !== undefined) profile.website = updates.website; if (updates.email !== undefined) profile.email = updates.email; if (updates.xUsername !== undefined) profile.xUsername = updates.xUsername; if (updates.tags !== undefined) { const tagsArray = Array.isArray(updates.tags) ? updates.tags : []; sdk.log.debug('global.profile', `[PUT /api/profile] Setting tags: ${JSON.stringify(tagsArray)}`); profile.tags = JSON.stringify(tagsArray); } if (updates.customFields !== undefined) { profile.customFields = JSON.stringify(updates.customFields); } sdk.log.info('global.profile', '[PUT /api/profile] Saving profile to DB...'); const saveStart = Date.now(); await saveProfileToDB(profile); sdk.log.info('global.profile', `[PUT /api/profile] Profile saved in ${Date.now() - saveStart}ms`); // Parse JSON fields for response try { profile.customFields = profile.customFields ? JSON.parse(profile.customFields) : {}; profile.tags = profile.tags ? JSON.parse(profile.tags) : []; } catch (err) { profile.customFields = {}; profile.tags = []; } // Broadcast update sdk.log.debug('global.profile', '[PUT /api/profile] Broadcasting update...'); broadcastProfileUpdate({ type: 'profile-update', profile: profile }); sdk.log.info('global.profile', `[PUT /api/profile] Request completed in ${Date.now() - startTime}ms`); return sdk.router.json(res, { success: true, profile }, 200, req); } catch (err) { sdk.log.error('global.profile', `[PUT /api/profile] Error after ${Date.now() - startTime}ms: ${err.message}`); sdk.log.error('global.profile', `[PUT /api/profile] Stack: ${err.stack}`); // Provide user-friendly error message let errorMessage = err.message; if (errorMessage.includes('Database not available') || errorMessage.includes('Database not initialized')) { errorMessage = 'Database is not ready yet. Please wait a moment and try again.'; return router.error(res, errorMessage, 503, req); // 503 Service Unavailable } return router.error(res, errorMessage, 500, req); } }); return true; } // PATCH /api/profile - Partial update local user's profile if (path === 'api/profile' && method === 'PATCH') { // Require authentication for write operations const peerId = await sdk.auth.requireLocalPeer(req, res); if (!peerId) return true; // 401 already sent if (!localPeerId) { return router.json(res, { error: 'Local peer ID not available' }, 500, req); } let body = ''; req.on('data', chunk => { body += chunk.toString(); }); req.on('end', async () => { try { if (!body) { return sdk.router.error(res, 'Request body is required', 400, req); } let updates; try { updates = JSON.parse(body); } catch (parseErr) { return sdk.router.error(res, 'Invalid JSON in request body', 400, req); } // Get existing profile let profile = await getProfileFromDB(localPeerId); if (!profile) { return sdk.router.json(res, { error: 'Profile not found' }, 404, req); } // Update only provided fields if (updates.displayName !== undefined) profile.displayName = updates.displayName; if (updates.bio !== undefined) profile.bio = updates.bio; if (updates.website !== undefined) profile.website = updates.website; if (updates.email !== undefined) profile.email = updates.email; if (updates.xUsername !== undefined) profile.xUsername = updates.xUsername; if (updates.github !== undefined) profile.github = updates.github; if (updates.discord !== undefined) profile.discord = updates.discord; if (updates.location !== undefined) profile.location = updates.location; if (updates.tags !== undefined) { profile.tags = JSON.stringify(Array.isArray(updates.tags) ? updates.tags : []); } if (updates.customFields !== undefined) { profile.customFields = JSON.stringify(updates.customFields); } await saveProfileToDB(profile); // Parse JSON fields for response try { profile.customFields = profile.customFields ? JSON.parse(profile.customFields) : {}; profile.tags = profile.tags ? JSON.parse(profile.tags) : []; } catch (err) { profile.customFields = {}; profile.tags = []; } // Broadcast update broadcastProfileUpdate({ type: 'profile-update', profile: profile }); return sdk.router.json(res, { success: true, profile }, 200, req); } catch (err) { sdk.log.error('global.profile', `Error patching profile: ${err.message}`); if (err.message && err.message.includes('Unexpected token')) { return sdk.router.error(res, 'Invalid JSON in request body', 400, req); } let errorMessage = err.message; if (errorMessage.includes('Database not available') || errorMessage.includes('Database not initialized')) { errorMessage = 'Database is not ready yet. Please wait a moment and try again.'; return sdk.router.error(res, errorMessage, 503, req); } return sdk.router.error(res, errorMessage, 500, req); } }); return true; } // GET /api/profile/:peerId - Get specific peer's profile // MUST come after /api/profile/avatar/:peerId/:size to avoid route conflicts const profileMatch = path.match(/^api\/profile\/(.+)$/); if (profileMatch && method === 'GET') { try { let peerId = profileMatch[1]; // Remove trailing slash if present if (peerId.endsWith('/')) { peerId = peerId.slice(0, -1); } peerId = decodeURIComponent(peerId); if (!peerId || peerId.trim() === '') { return sdk.router.error(res, 'Invalid peer ID', 400, req); } sdk.log.debug('global.profile', `Fetching profile for peer: ${peerId.slice(0, 16)}...`); const profile = await getProfileFromDB(peerId); if (!profile) { sdk.log.debug('global.profile', `Profile not found for peer: ${peerId.slice(0, 16)}...`); // Return a default profile structure instead of 404, so the modal can still display // This is useful when the profile hasn't been replicated yet return sdk.router.json(res, { peerId: peerId, displayName: '', bio: '', website: '', email: '', xUsername: '', avatarHash: '', customFields: {}, tags: [], lastUpdated: null }, 200, req); } // Parse JSON fields try { profile.customFields = profile.customFields ? JSON.parse(profile.customFields) : {}; profile.tags = profile.tags ? JSON.parse(profile.tags) : []; } catch (err) { profile.customFields = {}; profile.tags = []; } return sdk.router.json(res, profile, 200, req); } catch (err) { sdk.log.error('global.profile', `Error getting peer profile: ${err.message}`); return sdk.router.error(res, 'Failed to retrieve profile', 500, req); } } // DELETE /api/profile - Delete local user's profile if (path === 'api/profile' && method === 'DELETE') { // Require authentication for write operations const peerId = await sdk.auth.requireLocalPeer(req, res); if (!peerId) return true; // 401 already sent if (!localPeerId) { return sdk.router.json(res, { error: 'Local peer ID not available' }, 500, req); } try { const profile = await getProfileFromDB(localPeerId); if (!profile) { return sdk.router.json(res, { error: 'Profile not found' }, 404, req); } // Delete all avatars for this profile try { await sdk.db.runWrite(async () => { await deleteAvatarsForPeer(localPeerId); await sdk.db.flush(); }); sdk.log.debug('global.profile', `Deleted all avatars for ${localPeerId.slice(0, 16)}...`); } catch (avatarErr) { sdk.log.warn('global.profile', `Error deleting avatars: ${avatarErr.message}`); } // Delete profile from database await sdk.db.delete('@profile/profiles', { peerId: localPeerId }); // Broadcast deletion broadcastProfileUpdate({ type: 'profile-deleted', peerId: localPeerId }); return sdk.router.json(res, { success: true, message: 'Profile deleted successfully' }, 200, req); } catch (err) { sdk.log.error('global.profile', `Error deleting profile: ${err.message}`); return sdk.router.error(res, err.message, 500, req); } } return false; // Not handled by this module } module.exports = { handleProfileRoutes };