forked from snxraven/p2ns
252 lines
9.5 KiB
JavaScript
252 lines
9.5 KiB
JavaScript
/**
|
|
* 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 = `<?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 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 = `<?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}`);
|
|
|
|
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
|
|
};
|