/** * Global Profile Plugin - Avatar Routes * * Handles avatar-related API endpoints */ const sdk = require('../../../includes/plugins/sdk'); const { getLocalPeerId, parseMultipartFormData } = require('../utils'); const { processAndStoreAvatar, getAvatarFromDB, deleteAvatarsForPeer, AVATAR_SIZES } = require('../database'); const { broadcastProfileUpdate } = require('../websocket'); /** * Handle avatar-related routes */ async function handleAvatarRoutes(req, res, path, query, method) { const localPeerId = getLocalPeerId(); // GET /api/profile/avatar/:peerId/:size - Get avatar image // MUST come BEFORE the default avatar route to avoid redirect loops const avatarMatch = path.match(/^api\/profile\/avatar\/(.+?)\/(\d+)$/); if (avatarMatch && method === 'GET') { try { const peerId = decodeURIComponent(avatarMatch[1]); const size = parseInt(avatarMatch[2], 10); if (!AVATAR_SIZES.includes(size)) { return sdk.router.error(res, 'Invalid avatar size', 400, req); } sdk.log.debug('global.profile', `Requesting avatar for peer ${peerId.slice(0, 16)} at size ${size}`); const avatarData = await getAvatarFromDB(peerId, size); if (!avatarData) { sdk.log.debug('global.profile', `No avatar found for peer ${peerId.slice(0, 16)}, returning default SVG`); // Return a default SVG avatar instead of 404 // Use first character of peerId or a default icon const char = peerId && peerId.length > 0 ? peerId.charAt(0).toUpperCase() : '?'; const fontSize = Math.floor(size * 0.4); const svg = ` ${char} `; const headers = { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'public, max-age=3600' }; sdk.router.setCORS(res, { origin: '*' }); res.writeHead(200, headers); res.end(svg); return true; } sdk.log.debug('global.profile', `Serving avatar for peer ${peerId.slice(0, 16)} at size ${size} (${avatarData.length} bytes)`); const isPng = avatarData.length > 1 && avatarData[0] === 0x89 && avatarData[1] === 0x50; const headers = { 'Content-Type': isPng ? 'image/png' : 'image/jpeg', 'Cache-Control': 'public, max-age=3600' }; sdk.router.setCORS(res, { origin: '*' }); res.writeHead(200, headers); res.end(avatarData); return true; } catch (err) { sdk.log.error('global.profile', `Error serving avatar: ${err.message}`); // Return SVG placeholder on error const size = parseInt(avatarMatch[2], 10) || 128; const svg = ` ? `; const headers = { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'public, max-age=3600' }; sdk.router.setCORS(res, { origin: '*' }); res.writeHead(200, headers); res.end(svg); return true; } } // GET /api/profile/avatar/:peerId - Get default avatar (64px) // MUST come AFTER /api/profile/avatar/:peerId/:size to avoid redirect loops // Only match paths that don't already have a size parameter const avatarDefaultMatch = path.match(/^api\/profile\/avatar\/(.+)$/); if (avatarDefaultMatch && method === 'GET') { // Check if this path already has a size parameter - if so, skip (should have been handled above) const hasSizeParam = path.match(/^api\/profile\/avatar\/(.+?)\/(\d+)$/); if (hasSizeParam) { // This should have been handled by the size-specific route above return false; } try { const peerId = decodeURIComponent(avatarDefaultMatch[1]); // Redirect to default size (64px) sdk.router.setCORS(res, { origin: '*' }); res.writeHead(302, { 'Location': `/api/profile/avatar/${encodeURIComponent(peerId)}/64` }); res.end(); return true; } catch (err) { sdk.log.error('global.profile', `Error redirecting avatar: ${err.message}`); return sdk.router.error(res, 'Invalid peer ID', 400, req); } } // POST /api/profile/avatar - Upload new avatar if (path === 'api/profile/avatar' && method === 'POST') { // 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 { sdk.log.info('global.profile', 'Parsing multipart form data for avatar upload'); sdk.log.info('global.profile', `Content-Type: ${req.headers['content-type']}`); sdk.log.info('global.profile', `Content-Length: ${req.headers['content-length']}`); const { fields, files } = await parseMultipartFormData(req); sdk.log.info('global.profile', `Parsed fields: ${Object.keys(fields).join(', ')}`); sdk.log.info('global.profile', `Parsed files: ${Object.keys(files).join(', ')}`); const avatarFile = files.avatar; if (!avatarFile) { sdk.log.error('global.profile', 'No avatar file in parsed files'); sdk.log.error('global.profile', `Available files: ${Object.keys(files).join(', ')}`); return sdk.router.error(res, 'No avatar file provided', 400, req); } if (!avatarFile.data || avatarFile.data.length === 0) { sdk.log.error('global.profile', 'Avatar file has no data'); return sdk.router.error(res, 'Avatar file is empty', 400, req); } sdk.log.info('global.profile', `Avatar file received: ${avatarFile.filename}, size: ${avatarFile.data.length}, type: ${avatarFile.contentType}`); const uploadStart = Date.now(); const avatarHash = await processAndStoreAvatar(localPeerId, avatarFile.data, avatarFile.filename); sdk.log.info('global.profile', `[POST /api/profile/avatar] Avatar stored in ${Date.now() - uploadStart}ms`); const { getProfileFromDB, saveProfileToDB } = require('../database'); let profile = await getProfileFromDB(localPeerId); if (!profile) { profile = { peerId: localPeerId, displayName: '', bio: '', website: '', email: '', location: '', xUsername: '', github: '', discord: '', avatarHash: '', customFields: JSON.stringify({}), tags: JSON.stringify([]), lastUpdated: Date.now() }; } profile.avatarHash = avatarHash; const profileSaveStart = Date.now(); await saveProfileToDB(profile); sdk.log.info('global.profile', `[POST /api/profile/avatar] Profile updated in ${Date.now() - profileSaveStart}ms`); // 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 = []; } // Broadcast update broadcastProfileUpdate({ type: 'profile-update', profile: profile }); return sdk.router.json(res, { success: true, avatarHash }, 200, req); } catch (err) { sdk.log.error('global.profile', `Error uploading avatar: ${err.message}`); return sdk.router.error(res, err.message, 500, req); } } // DELETE /api/profile/avatar - Delete local user's avatar if (path === 'api/profile/avatar' && 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 { await sdk.db.ready(); const { getProfileFromDB } = require('../database'); let profile = await getProfileFromDB(localPeerId); await sdk.db.runWrite(async () => { await deleteAvatarsForPeer(localPeerId); if (profile) { profile.avatarHash = ''; profile.lastUpdated = Date.now(); await sdk.db.insert('@profile/profiles', profile); } await sdk.db.flush(); }); if (profile) { 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 }); } return sdk.router.json(res, { success: true, message: 'Avatar deleted successfully' }, 200, req); } catch (err) { sdk.log.error('global.profile', `Error deleting avatar: ${err.message}`); return sdk.router.error(res, err.message, 500, req); } } return false; // Not handled by this module } module.exports = { handleAvatarRoutes };