forked from snxraven/p2ns
420 lines
15 KiB
JavaScript
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
|
|
};
|
|
|
|
|
|
|