Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions config/Router.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
25 changes: 25 additions & 0 deletions handlers/api/v1/Users.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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() );
}

Expand Down
78 changes: 76 additions & 2 deletions models/User.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@ component
"isActive",
"isApproved",
"isAdministrator",
"avatar",
"allowLogin"
],
"defaults" :{
Expand Down Expand Up @@ -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() );
Expand All @@ -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;
Expand Down
1 change: 1 addition & 0 deletions resources/assets/js/api/users.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 } } ),
Expand Down
40 changes: 37 additions & 3 deletions resources/assets/js/layouts/inc/Header.vue
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
>
<img
class="h-full w-full object-cover"
:src="user.avatar"
:src="avatarSrc"
alt="Your avatar"
/>
</button>
Expand Down Expand Up @@ -71,6 +71,7 @@

<script>
import SearchForm from "@/components/search/SearchForm";
import usersAPI from "@/api/users";
import { mapState } from "vuex";

export default {
Expand All @@ -91,15 +92,34 @@ export default {
searchFilters : {
search : "",
terms : []
}
},
userAvatar : null
}
},
computed : {
...mapState({
user : ( state ) => state.authUser,
authToken : ( state ) => state.authToken,
baseHref : ( state ) => state.globals.stachebox.baseHref,
internalSecurityEnabled : ( state ) => state.globals.stachebox.internalSecurity
})
}),
avatarSrc(){
return this.userAvatar || "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/2wBDAQMDAwQDBAgEBAgQCwkLEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBD/wAARCADIAMgDASIAAhEBAxEB/8QAHQABAAIDAQEBAQAAAAAAAAAAAAcIBAUGAgEDCf/EAEYQAAEDAwICBAsHAgMGBwAAAAEAAgMEBQYHERIhMVFhcQgTFRYiQVSRobHRFDI1c4GS8P8";
}
},
watch : {
user : {
immediate : true,
handler( newUser ){
if( newUser && newUser.id ){
this.fetchUserAvatar();
}
}
},
// Watch for store changes to trigger avatar refresh
'$store.state.avatarRefreshTrigger'(){
this.fetchUserAvatar();
}
},
mounted(){
if( this.$route.params.search ){
Expand All @@ -113,6 +133,20 @@ export default {
}
},
methods : {
fetchUserAvatar(){
if( this.user && this.user.id && this.authToken ){
usersAPI.fetchAvatar( this.user.id, this.authToken )
.then( result => {
if( result.data && result.data.avatar ){
this.userAvatar = result.data.avatar;
}
})
.catch( () => {
// If avatar fetch fails, use default
this.userAvatar = null;
});
}
},
logout(){
this.$store.dispatch( "logout" )
.finally(
Expand Down
6 changes: 5 additions & 1 deletion resources/assets/js/store/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export default createStore({
authUser : null,
navAggregations : null,
beatsEnabled : true,
avatarRefreshTrigger : 0,
globals : window ? window.globalData : {}
},
getters:{
Expand All @@ -44,6 +45,9 @@ export default createStore({
},
setBeatsEnabled : ( state, value ) => {
state.beatsEnabled = value;
},
triggerAvatarRefresh : ( state ) => {
state.avatarRefreshTrigger++;
}
},
actions: {
Expand Down Expand Up @@ -82,7 +86,7 @@ export default createStore({
} );
}
return new Promise( ( resolve, reject ) => {
// if( !context.state.authId ) reject();
// Fetch user by ID from API
usersAPI.fetch( context.state.authId, params, context.state.authToken )
.then( ( response ) => {
context.state.authUser = response.data;
Expand Down
30 changes: 27 additions & 3 deletions resources/assets/js/views/UserDirectory.vue
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<div class="flex-shrink-0 h-10 w-10">
<img class="h-10 w-10 rounded-full" :src="user.avatar" alt="">
<img class="h-10 w-10 rounded-full" :src="user.avatar || defaultAvatar" alt="">
</div>
<div class="ml-4">
<div class="text-sm font-medium text-gray-900">
Expand Down Expand Up @@ -94,7 +94,8 @@ export default {
usersData : undefined,
userFilters : {
allowLogin : true
}
},
defaultAvatar : "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/2wBDAQMDAwQDBAgEBAgQCwkLEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBD/wAARCADIAMgDASIAAhEBAxEB/8QAHQABAAIDAQEBAQAAAAAAAAAAAAcIBAUGAgEDCf/EAEYQAAEDAwICBAsHAgMGBwAAAAEAAgMEBQYHERIhMVFhcQgTFRYiQVWRobHRFDI1c4GS8P8"
}
},
computed :{
Expand Down Expand Up @@ -122,7 +123,30 @@ export default {
methods : {
fetchUsers(){
usersAPI.list( { "sortOrder" : "lastName DESC, firstName DESC" }, this.authToken )
.then( result => this.usersData = result.data )
.then( result => {
this.usersData = result.data;
// Fetch avatars for all users
if( this.users ){
this.users.forEach( user => {
this.fetchUserAvatar( user );
});
}
})
},
fetchUserAvatar( user ){
if( user && user.id && this.authToken ){
usersAPI.fetchAvatar( user.id, this.authToken )
.then( result => {
if( result.data && result.data.avatar ){
user.avatar = result.data.avatar;
} else {
user.avatar = this.defaultAvatar;
}
})
.catch( () => {
user.avatar = this.defaultAvatar;
});
}
}
},
created(){
Expand Down
Loading
Loading