This commit is contained in:
Raven Scott
2025-12-17 20:05:50 -05:00
commit 742e27d3f7
276 changed files with 89838 additions and 0 deletions
@@ -0,0 +1,250 @@
/**
* Global Profile Plugin - Avatar Routes
*
* Handles avatar-related API endpoints
*/
const sdk = require('../../../includes/plugins/sdk');
const { getLocalPeerId, parseMultipartFormData } = require('../utils');
const { processAndStoreAvatar, getAvatarFromDB, 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 = `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
<rect width="${size}" height="${size}" fill="#4b5563"/>
<text x="50%" y="50%" text-anchor="middle" dominant-baseline="central" fill="#d1d5db" font-size="${fontSize}" font-family="Arial, sans-serif" font-weight="bold">${char}</text>
</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;
}
sdk.log.debug('global.profile', `Serving avatar for peer ${peerId.slice(0, 16)} at size ${size} (${avatarData.length} bytes)`);
const headers = {
'Content-Type': 'image/png',
'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 = `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
<rect width="${size}" height="${size}" fill="#4b5563"/>
<text x="50%" y="50%" text-anchor="middle" dominant-baseline="central" fill="#d1d5db" font-size="${Math.floor(size * 0.4)}" font-family="Arial, sans-serif" font-weight="bold">?</text>
</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}`);
// Process and store avatar
const avatarHash = await processAndStoreAvatar(localPeerId, avatarFile.data, avatarFile.filename);
// Update profile with avatar hash
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;
await saveProfileToDB(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 = [];
}
// 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 {
// Delete all avatar records for this peer
await sdk.db.ready();
const avatarRecords = await sdk.db.find('@profile/avatars', { peerId: localPeerId });
for (const record of avatarRecords) {
await sdk.db.delete('@profile/avatars', { peerId: localPeerId, size: record.size });
}
// Clear avatarHash from profile
const { getProfileFromDB, saveProfileToDB } = require('../database');
let profile = await getProfileFromDB(localPeerId);
if (profile) {
profile.avatarHash = '';
await saveProfileToDB(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 = [];
}
// Broadcast update
broadcastProfileUpdate({
type: 'profile-update',
profile: profile
});
}
await sdk.db.flush();
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
};