diff --git a/config/Router.cfc b/config/Router.cfc index ca024b9..1b2c9fa 100644 --- a/config/Router.cfc +++ b/config/Router.cfc @@ -13,6 +13,13 @@ component { } ) .toHandler( "API" ); + // User avatar endpoint + route( "/api/v1/users/:id/avatar" ) + .withAction( { + "GET" : "avatar" + } ) + .toHandler( "api.v1.Users" ); + apiResources( resource = "/api/v1/users", handler = "api.v1.Users" diff --git a/handlers/api/v1/Users.cfc b/handlers/api/v1/Users.cfc index 71e1524..affc024 100644 --- a/handlers/api/v1/Users.cfc +++ b/handlers/api/v1/Users.cfc @@ -18,12 +18,31 @@ component extends="BaseAPIHandler" secured="StacheboxUser"{ } + // ( GET ) /api/v1/users/:id/avatar - Returns user avatar + function avatar( event, rc, prc ){ + + var user = getInstance( "User@stachebox" ).getOrFail( rc.id ); + var userMemento = user.getMemento( includes = "avatar" ); + + prc.response.setData({ + "id": user.getId(), + "avatar": userMemento.avatar ?: javacast( "null", 0 ) + }); + + } + // ( POST ) /api/v1/users function create( event, rc, prc ) secured="StacheboxAdministrator"{ var user = getInstance( "User@stachebox" ) .new( rc ) .encryptPassword() .validateOrFail(); + + // Process and validate avatar if provided + if( !isNull( user.getAvatar() ) && len( user.getAvatar() ) ){ + user.processAvatar(); + } + prc.response.setData( user .save() @@ -40,6 +59,12 @@ component extends="BaseAPIHandler" secured="StacheboxUser"{ user.encryptPassword(); } + // Process and validate avatar if provided + // Check if user has avatar after population (not just if it's in request) + if( !isNull( user.getAvatar() ) && len( user.getAvatar() ) ){ + user.processAvatar(); + } + prc.response.setData( user.save().getMemento() ); } diff --git a/models/User.cfc b/models/User.cfc index a750f7f..0e8471d 100644 --- a/models/User.cfc +++ b/models/User.cfc @@ -57,7 +57,6 @@ component "isActive", "isApproved", "isAdministrator", - "avatar", "allowLogin" ], "defaults" :{ @@ -145,7 +144,7 @@ component function save(){ var userDoc = newDocument().new( index=getSearchIndexName() - ).populate( this.getMemento( includes="password,resetToken" ) ); + ).populate( this.getMemento( includes="password,resetToken,avatar" ) ); if( !isNull( getId() ) && len( getId() ) ){ userDoc.setId( getId() ); @@ -170,6 +169,81 @@ component return this; } + function processAvatar(){ + if( !isNull( variables.avatar ) && len( variables.avatar ) ){ + try { + // Check if it's a valid data URI + if( !reFindNoCase( "^data:image/", variables.avatar ) ){ + throw( + type = "ValidationException", + message = "Invalid avatar format. Must be a base64-encoded image." + ); + } + + // Extract the base64 data (everything after the comma) + var base64Data = listLast( variables.avatar, "," ); + + // Decode base64 to binary + var imageData = toBinary( base64Data ); + + // Validate max upload size (10MB for raw upload) + var maxUploadSize = 10 * 1024 * 1024; // 10MB + if( arrayLen( imageData ) > maxUploadSize ){ + throw( + type = "ValidationException", + message = "Avatar image is too large. Maximum upload size is 10MB." + ); + } + + // Create image object from binary data + var img = imageNew( imageData ); + + // Get image dimensions + var width = imageGetWidth( img ); + var height = imageGetHeight( img ); + + // Resize if necessary (max 200x200, maintaining aspect ratio) + if( width > 200 || height > 200 ){ + imageScaleToFit( img, 200, 200 ); + } + + // Ensure it's exactly 200x200 by cropping from center + var currentWidth = imageGetWidth( img ); + var currentHeight = imageGetHeight( img ); + + if( currentWidth != 200 || currentHeight != 200 ){ + var cropX = max( 0, floor( ( currentWidth - 200 ) / 2 ) ); + var cropY = max( 0, floor( ( currentHeight - 200 ) / 2 ) ); + imageCrop( img, cropX, cropY, min( 200, currentWidth ), min( 200, currentHeight ) ); + } + + // Convert to PNG for consistent format (supports transparency, lossless) + // Write to byte array and convert to base64 + var baos = createObject( "java", "java.io.ByteArrayOutputStream" ).init(); + + // Write the image as PNG to the byte array output stream + var ImageIO = createObject( "java", "javax.imageio.ImageIO" ); + var bufferedImage = imageGetBufferedImage( img ); + ImageIO.write( bufferedImage, "png", baos ); + + // Convert to base64 and create data URI + var processedBase64 = toBase64( baos.toByteArray() ); + variables.avatar = "data:image/png;base64," & processedBase64; + + } catch( any e ){ + // Log the full error for debugging + writeLog( file="application", text="Avatar processing error: #e.message# - Detail: #e.detail#" ); + + throw( + type = "ValidationException", + message = "Failed to process avatar image: #e.message# #e.detail#", + extendedInfo = serializeJSON( { "avatar": ["Failed to process image: #e.message#"] } ) + ); + } + } + return this; + } + function setFirstName( string firstName ){ variables.firstName = trim( arguments.firstName ?: "" ); return this; diff --git a/resources/assets/js/api/users.js b/resources/assets/js/api/users.js index daf5a65..ba80899 100644 --- a/resources/assets/js/api/users.js +++ b/resources/assets/js/api/users.js @@ -13,6 +13,7 @@ const defaultAPI = Axios.create({ export const finalAPI = { apiInstance : defaultAPI, fetch :( id, params, token ) => defaultAPI.get( '/' + id, { params : params, headers : { 'Authorization' : 'Bearer ' + token } } ), + fetchAvatar :( id, token ) => defaultAPI.get( '/' + id + '/avatar', { headers : { 'Authorization' : 'Bearer ' + token } } ), list :( params, token ) => defaultAPI.get( '', { params : params, headers : { 'Authorization' : 'Bearer ' + token } } ), create : ( params, token ) => defaultAPI.post( '', JSON.stringify( params ), { headers : { 'Authorization' : 'Bearer ' + token } } ), update : ( params, token ) => defaultAPI.put( '/' + params.id, JSON.stringify( params ), { headers : { 'Authorization' : 'Bearer ' + token } } ), diff --git a/resources/assets/js/layouts/inc/Header.vue b/resources/assets/js/layouts/inc/Header.vue index 6472165..c8b299c 100644 --- a/resources/assets/js/layouts/inc/Header.vue +++ b/resources/assets/js/layouts/inc/Header.vue @@ -33,7 +33,7 @@ > Your avatar @@ -71,6 +71,7 @@