Files
p2ns/plugin-sites/global.profile/routes/profile.js
T
2026-05-30 17:49:25 -04:00

420 lines
15 KiB
JavaScript

/**
* 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
};