11import { Router } from 'express' ;
22import 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' ;
38import { query } from '../config/db.js' ;
49import { generateToken , authenticateToken } from '../middleware/auth.js' ;
510import { createPteroUser , updatePteroPassword , updatePteroEmail , deletePteroUser , getServersByUser , deletePteroServer } from '../services/pyrodactyl.js' ;
611import { verifyCap } from '../config/cap.js' ;
712import { logActivity } from '../services/activity.js' ;
813
14+ const __dirname = dirname ( fileURLToPath ( import . meta. url ) ) ;
15+ const UPLOAD_DIR = join ( __dirname , '..' , 'uploads' , 'avatars' ) ;
16+
917const router = Router ( ) ;
1018
19+ const MIME_TYPES = { png : 'image/png' , jpg : 'image/jpeg' , jpeg : 'image/jpeg' , gif : 'image/gif' , webp : 'image/webp' } ;
20+
1121function 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 ( / ^ d a t a : i m a g e \/ ( p n g | j p e g | j p g | g i f | w e b p ) ; b a s e 6 4 , ( .+ ) $ / ) ;
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+
405523router . post ( '/logout' , ( req , res ) => {
406524 res . clearCookie ( 'token' , { httpOnly : true , secure : true , sameSite : 'strict' } ) ;
407525 res . json ( { message : 'Logged out' } ) ;
0 commit comments