Skip to content

Commit b721f01

Browse files
committed
feat: add profile picture upload with secure avatar serving
1 parent 77d5fd4 commit b721f01

5 files changed

Lines changed: 275 additions & 1 deletion

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ logs/
2727
port.txt
2828
reboot.sh
2929

30+
# Uploads
31+
uploads/
32+
3033
# Secrets (if any local dump)
3134
*.pem
3235
*.key

config/migrate.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const tables = {
1313
{ name: 'last_name', def: 'VARCHAR(255)' },
1414
{ name: 'password_set', def: 'TINYINT(1) NOT NULL DEFAULT 0' },
1515
{ name: 'is_admin', def: 'TINYINT(1) NOT NULL DEFAULT 0' },
16+
{ name: 'avatar', def: 'VARCHAR(255) DEFAULT NULL' },
1617
{ name: 'ptero_client_api_key', def: 'VARCHAR(255) DEFAULT NULL' },
1718
{ name: 'created_at', def: 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP' },
1819
],

public/css/style.css

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1392,6 +1392,50 @@ tbody tr:hover {
13921392
margin-top: 2px;
13931393
}
13941394

1395+
/* ===== AVATAR UPLOAD ===== */
1396+
.avatar-upload {
1397+
display: flex;
1398+
gap: 20px;
1399+
align-items: flex-start;
1400+
flex-wrap: wrap;
1401+
}
1402+
1403+
.avatar-upload-preview {
1404+
width: 100px;
1405+
height: 100px;
1406+
border-radius: 50%;
1407+
background: var(--accent-1);
1408+
display: flex;
1409+
align-items: center;
1410+
justify-content: center;
1411+
font-size: 2.5rem;
1412+
font-weight: 700;
1413+
color: #fff;
1414+
flex-shrink: 0;
1415+
overflow: hidden;
1416+
position: relative;
1417+
}
1418+
1419+
.avatar-upload-preview img {
1420+
width: 100%;
1421+
height: 100%;
1422+
object-fit: cover;
1423+
border-radius: 50%;
1424+
}
1425+
1426+
.avatar-upload-placeholder {
1427+
position: absolute;
1428+
inset: 0;
1429+
display: flex;
1430+
align-items: center;
1431+
justify-content: center;
1432+
}
1433+
1434+
.avatar-upload-info {
1435+
flex: 1;
1436+
min-width: 200px;
1437+
}
1438+
13951439
/* ===== SERVER EXPIRY ===== */
13961440
.server-card-expiry {
13971441
display: flex;

public/js/app.js

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,21 @@ function gravatarUrl(email, size = 32) {
112112
return `https://www.gravatar.com/avatar/${hash}?s=${size}&d=identicon`;
113113
}
114114

115+
function avatarUrl(userId) {
116+
return `${API_BASE}/api/auth/avatar/${userId}`;
117+
}
118+
119+
window.handleAvatarError = function(img) {
120+
if (!img.dataset.fallbackTried) {
121+
img.dataset.fallbackTried = 'gravatar';
122+
img.src = gravatarUrl(state.user?.email, 32);
123+
} else {
124+
img.style.display = 'none';
125+
const fallback = document.getElementById('avatar-fallback');
126+
if (fallback) fallback.style.display = 'flex';
127+
}
128+
};
129+
115130
function html(strings, ...values) {
116131
return strings.reduce((acc, str, i) => acc + str + (values[i] ?? ''), '');
117132
}
@@ -501,7 +516,7 @@ async function renderDashboard() {
501516
<div class="sidebar-footer">
502517
<div class="user-info" id="sidebar-user-info" style="display:flex;align-items:center;justify-content:space-between;padding:8px 12px;cursor:pointer">
503518
<div style="display:flex;align-items:center;gap:10px">
504-
<div class="user-avatar" id="avatar-container"><img src="${gravatarUrl(state.user?.email, 32)}" alt="" width="32" height="32" style="border-radius:50%;width:32px;height:32px;object-fit:cover" onerror="this.style.display='none';document.getElementById('avatar-fallback').style.display='flex'"/><span id="avatar-fallback" style="display:none">${state.user?.username?.[0]?.toUpperCase() || 'U'}</span></div>
519+
<div class="user-avatar" id="avatar-container"><img src="${avatarUrl(state.user?.id)}" alt="" width="32" height="32" style="border-radius:50%;width:32px;height:32px;object-fit:cover" onerror="handleAvatarError(this)"/><span id="avatar-fallback" style="display:none">${state.user?.username?.[0]?.toUpperCase() || 'U'}</span></div>
505520
<div>
506521
<div class="user-name">${state.user?.username || 'User'}</div>
507522
<div class="user-email">${state.user?.email || ''}</div>
@@ -1415,6 +1430,27 @@ function renderAccountEdit() {
14151430
</div>
14161431
</div>
14171432
<div class="account-grid">
1433+
<div class="card">
1434+
<h2 class="card-title" style="margin-bottom:20px">Profile Picture</h2>
1435+
<div class="avatar-upload">
1436+
<div class="avatar-upload-preview">
1437+
<img id="avatar-preview-img" src="${avatarUrl(state.user?.id)}" alt="" onerror="this.style.display='none';document.getElementById('avatar-preview-placeholder').style.display='flex'" onload="document.getElementById('avatar-preview-placeholder').style.display='none'" />
1438+
<div class="avatar-upload-placeholder" id="avatar-preview-placeholder">${state.user?.username?.[0]?.toUpperCase() || 'U'}</div>
1439+
</div>
1440+
<div class="avatar-upload-info">
1441+
<p style="color:var(--text-secondary);font-size:0.85rem;line-height:1.6;margin-bottom:12px">
1442+
Upload a profile picture. Supported formats: PNG, JPEG, GIF, WebP. Max size: 2MB.
1443+
</p>
1444+
<input type="file" id="avatar-file-input" accept="image/png,image/jpeg,image/gif,image/webp" hidden />
1445+
<div style="display:flex;gap:8px">
1446+
<button class="btn btn-primary" id="avatar-choose-btn">Choose Image</button>
1447+
<button class="btn btn-primary" id="avatar-upload-btn" disabled style="display:none">Upload</button>
1448+
</div>
1449+
<div id="avatar-status" style="margin-top:8px;font-size:0.82rem;color:var(--text-muted)"></div>
1450+
</div>
1451+
</div>
1452+
</div>
1453+
14181454
<div class="card">
14191455
<h2 class="card-title" style="margin-bottom:20px">Change Email</h2>
14201456
<form id="change-email-form" style="width:100%">
@@ -1473,9 +1509,81 @@ function renderAccountEdit() {
14731509
$('#change-password-form').addEventListener('submit', handleChangePassword);
14741510
$('#api-key-form').addEventListener('submit', handleSaveApiKey);
14751511

1512+
$('#avatar-choose-btn').addEventListener('click', () => {
1513+
$('#avatar-file-input').click();
1514+
});
1515+
1516+
$('#avatar-file-input').addEventListener('change', (e) => {
1517+
const file = e.target.files[0];
1518+
if (!file) return;
1519+
1520+
const validTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
1521+
if (!validTypes.includes(file.type)) {
1522+
$('#avatar-status').textContent = 'Invalid file type. Please use PNG, JPEG, GIF, or WebP.';
1523+
$('#avatar-status').style.color = 'var(--accent-red)';
1524+
return;
1525+
}
1526+
1527+
if (file.size > 2 * 1024 * 1024) {
1528+
$('#avatar-status').textContent = 'File too large. Maximum size is 2MB.';
1529+
$('#avatar-status').style.color = 'var(--accent-red)';
1530+
return;
1531+
}
1532+
1533+
const reader = new FileReader();
1534+
reader.onload = (ev) => {
1535+
$('#avatar-preview-img').src = ev.target.result;
1536+
$('#avatar-preview-img').style.display = 'block';
1537+
$('#avatar-preview-placeholder').style.display = 'none';
1538+
$('#avatar-upload-btn').style.display = 'inline-flex';
1539+
$('#avatar-upload-btn').disabled = false;
1540+
$('#avatar-status').textContent = 'Click "Upload" to save your new profile picture.';
1541+
$('#avatar-status').style.color = 'var(--text-muted)';
1542+
};
1543+
reader.readAsDataURL(file);
1544+
});
1545+
1546+
$('#avatar-upload-btn').addEventListener('click', handleAvatarUpload);
1547+
14761548
checkApiKeyStatus();
14771549
}
14781550

1551+
async function handleAvatarUpload() {
1552+
const btn = $('#avatar-upload-btn');
1553+
const status = $('#avatar-status');
1554+
const img = $('#avatar-preview-img');
1555+
1556+
btn.disabled = true;
1557+
btn.innerHTML = '<span class="spinner"></span>';
1558+
status.textContent = '';
1559+
1560+
try {
1561+
const data = await api('/auth/upload-avatar', {
1562+
method: 'POST',
1563+
body: JSON.stringify({ image: img.src }),
1564+
});
1565+
showToast('Profile picture updated successfully', 'success');
1566+
status.textContent = 'Profile picture updated!';
1567+
status.style.color = 'var(--accent-green)';
1568+
btn.style.display = 'none';
1569+
1570+
const sidebarImg = document.querySelector('#avatar-container img');
1571+
if (sidebarImg) {
1572+
sidebarImg.src = avatarUrl(state.user.id);
1573+
sidebarImg.dataset.fallbackTried = '';
1574+
sidebarImg.style.display = '';
1575+
const fallback = document.getElementById('avatar-fallback');
1576+
if (fallback) fallback.style.display = 'none';
1577+
}
1578+
} catch (err) {
1579+
status.textContent = err.message;
1580+
status.style.color = 'var(--accent-red)';
1581+
} finally {
1582+
btn.disabled = false;
1583+
btn.innerHTML = 'Upload';
1584+
}
1585+
}
1586+
14791587
async function handleSaveApiKey(e) {
14801588
e.preventDefault();
14811589
const btn = $('#save-api-key-btn');

routes/auth.js

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,23 @@
11
import { Router } from 'express';
22
import argon2 from 'argon2';
3+
import jwt from 'jsonwebtoken';
4+
import { readdir, writeFile, unlink, mkdir } from 'fs/promises';
5+
import { existsSync } from 'fs';
6+
import { join, dirname, extname } from 'path';
7+
import { fileURLToPath } from 'url';
38
import { query } from '../config/db.js';
49
import { generateToken, authenticateToken } from '../middleware/auth.js';
510
import { createPteroUser, updatePteroPassword, updatePteroEmail, deletePteroUser, getServersByUser, deletePteroServer } from '../services/pyrodactyl.js';
611
import { verifyCap } from '../config/cap.js';
712
import { logActivity } from '../services/activity.js';
813

14+
const __dirname = dirname(fileURLToPath(import.meta.url));
15+
const UPLOAD_DIR = join(__dirname, '..', 'uploads', 'avatars');
16+
917
const router = Router();
1018

19+
const MIME_TYPES = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp' };
20+
1121
function getClientIp(req) {
1222
const forwarded = req.headers['x-forwarded-for'];
1323
if (forwarded) return forwarded.split(',')[0].trim();
@@ -390,6 +400,20 @@ router.post('/delete-account', authenticateToken, async (req, res) => {
390400
}
391401
}
392402

403+
// Clean up avatar file
404+
try {
405+
if (existsSync(UPLOAD_DIR)) {
406+
const files = await readdir(UPLOAD_DIR);
407+
for (const file of files) {
408+
if (file.startsWith(`avatar_${user.id}.`)) {
409+
await unlink(join(UPLOAD_DIR, file));
410+
}
411+
}
412+
}
413+
} catch (err) {
414+
console.error('Failed to clean up avatar:', err.message);
415+
}
416+
393417
await logActivity(user.id, 'account_deleted', 'Deleted account');
394418

395419
// Delete from local DB (cascades to user_ips)
@@ -402,6 +426,100 @@ router.post('/delete-account', authenticateToken, async (req, res) => {
402426
}
403427
});
404428

429+
router.post('/upload-avatar', authenticateToken, async (req, res) => {
430+
try {
431+
const { image } = req.body;
432+
if (!image) {
433+
return res.status(400).json({ error: 'No image provided' });
434+
}
435+
436+
const matches = image.match(/^data:image\/(png|jpeg|jpg|gif|webp);base64,(.+)$/);
437+
if (!matches) {
438+
return res.status(400).json({ error: 'Invalid image format. Use PNG, JPEG, GIF, or WebP.' });
439+
}
440+
441+
const ext = matches[1] === 'jpeg' ? 'jpg' : matches[1];
442+
const data = Buffer.from(matches[2], 'base64');
443+
444+
if (data.length > 2 * 1024 * 1024) {
445+
return res.status(400).json({ error: 'Image too large. Maximum size is 2MB.' });
446+
}
447+
448+
if (!existsSync(UPLOAD_DIR)) {
449+
await mkdir(UPLOAD_DIR, { recursive: true });
450+
}
451+
452+
const files = await readdir(UPLOAD_DIR);
453+
for (const file of files) {
454+
if (file.startsWith(`avatar_${req.user.userId}.`)) {
455+
await unlink(join(UPLOAD_DIR, file));
456+
}
457+
}
458+
459+
const filename = `avatar_${req.user.userId}.${ext}`;
460+
await writeFile(join(UPLOAD_DIR, filename), data);
461+
462+
await query('UPDATE users SET avatar = ? WHERE id = ?', [filename, req.user.userId]);
463+
464+
await logActivity(req.user.userId, 'avatar_updated', 'Updated profile picture');
465+
466+
res.json({ message: 'Avatar updated successfully' });
467+
} catch (err) {
468+
console.error('Upload avatar error:', err.message);
469+
res.status(500).json({ error: 'Failed to upload avatar' });
470+
}
471+
});
472+
473+
router.get('/avatar/:userId', async (req, res) => {
474+
try {
475+
const requestedId = parseInt(req.params.userId, 10);
476+
if (isNaN(requestedId)) {
477+
return res.status(400).json({ error: 'Invalid user ID' });
478+
}
479+
480+
let token = null;
481+
const authHeader = req.headers['authorization'];
482+
if (authHeader && authHeader.startsWith('Bearer ')) {
483+
token = authHeader.split(' ')[1];
484+
} else if (req.cookies && req.cookies.token) {
485+
token = req.cookies.token;
486+
}
487+
488+
if (!token) {
489+
return res.status(401).json({ error: 'Authentication required' });
490+
}
491+
492+
const decoded = jwt.verify(token, process.env.JWT_SECRET);
493+
494+
if (decoded.userId !== requestedId) {
495+
return res.status(403).json({ error: 'Access denied' });
496+
}
497+
498+
if (!existsSync(UPLOAD_DIR)) {
499+
return res.status(404).json({ error: 'No avatar found' });
500+
}
501+
502+
const files = await readdir(UPLOAD_DIR);
503+
const avatarFile = files.find(f => f.startsWith(`avatar_${requestedId}.`));
504+
505+
if (!avatarFile) {
506+
return res.status(404).json({ error: 'No avatar found' });
507+
}
508+
509+
const mimeType = MIME_TYPES[extname(avatarFile).toLowerCase().slice(1)] || 'application/octet-stream';
510+
511+
res.set('Content-Type', mimeType);
512+
res.set('Cache-Control', 'private, max-age=3600');
513+
res.sendFile(join(UPLOAD_DIR, avatarFile));
514+
} catch (err) {
515+
if (err.name === 'JsonWebTokenError' || err.name === 'TokenExpiredError') {
516+
return res.status(401).json({ error: 'Invalid or expired token' });
517+
}
518+
console.error('Avatar serve error:', err.message);
519+
res.status(500).json({ error: 'Failed to serve avatar' });
520+
}
521+
});
522+
405523
router.post('/logout', (req, res) => {
406524
res.clearCookie('token', { httpOnly: true, secure: true, sameSite: 'strict' });
407525
res.json({ message: 'Logged out' });

0 commit comments

Comments
 (0)