Skip to content
This repository was archived by the owner on Apr 21, 2026. It is now read-only.

Commit 9f72f31

Browse files
bpamiriclaude
andcommitted
feat: Replace image upload avatars with Gravatar
Avatars are now generated from the user's email via Gravatar instead of uploaded image files. When no Gravatar exists, the existing letter-circle fallback is preserved via an onerror handler on the img element. - Add gravatarUrl() view helper (MD5 hash + ?d=404) - Store session.email instead of session.profilePic on login/verify/remember-me - Replace all 12+ avatar blocks across views with Gravatar + onerror fallback - Replace upload form with Gravatar instructions page - Remove uploadProfilePic() handler and dead upload code - Update "Update Profile Pic" nav links to point to gravatar.com Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e0390aa commit 9f72f31

12 files changed

Lines changed: 145 additions & 235 deletions

File tree

app/controllers/admin/UserController.cfc

Lines changed: 5 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@ component extends="app.Controllers.Controller" {
33

44
function config() {
55
super.config();
6-
verifies(except="index,loadUsers,loadRoles,addUser,store,delete,profile,changePassword,updatePassword,uploadProfilePic,updateProfilePic,checkAdminAccess,unlockUser,toggleUserLock", params="key", paramsTypes="integer", handler="index");
7-
usesLayout(template="/admin/AdminController/layout", except="changePassword,updatePassword,uploadProfilePic,updateProfilePic" );
8-
filters(through="checkAdminAccess", except="changePassword,updatePassword,uploadProfilePic,updateProfilePic,unlockUser,toggleUserLock");
9-
filters(through="checkUserAccess", only="changePassword,updatePassword,uploadProfilePic,updateProfilePic");
6+
verifies(except="index,loadUsers,loadRoles,addUser,store,delete,profile,changePassword,updatePassword,updateProfilePic,checkAdminAccess,unlockUser,toggleUserLock", params="key", paramsTypes="integer", handler="index");
7+
usesLayout(template="/admin/AdminController/layout", except="changePassword,updatePassword,updateProfilePic" );
8+
filters(through="checkAdminAccess", except="changePassword,updatePassword,updateProfilePic,unlockUser,toggleUserLock");
9+
filters(through="checkUserAccess", only="changePassword,updatePassword,updateProfilePic");
1010
filters(through="checkRoleAccess", only="index,addUser,delete");
1111
}
1212

@@ -147,62 +147,8 @@ component extends="app.Controllers.Controller" {
147147
return;
148148
}
149149

150-
// user profile pic form
150+
// user profile pic page (now shows Gravatar instructions)
151151
function updateProfilePic(){}
152-
153-
// update user profile pic
154-
function uploadProfilePic(){
155-
156-
var uploadPath = expandPath("/images");
157-
158-
if (structKeyExists(form, "profilePic")) {
159-
try {
160-
// Ensure directory exists
161-
if (!directoryExists(uploadPath)) {
162-
directoryCreate(uploadPath);
163-
}
164-
165-
// Accept only image files
166-
var uploadedFile = fileUpload(
167-
destination = uploadPath,
168-
nameConflict = "makeUnique",
169-
fileField = form.profilePic,
170-
accept = "image/*"
171-
);
172-
var fileExt = lcase(listLast(uploadedFile.serverFile, "."));
173-
var allowedTypes = "jpg,jpeg,png,gif,webp";
174-
var allowedContentTypes = "image/jpeg,image/png,image/gif,image/webp";
175-
176-
if (!listFindNoCase(allowedTypes, fileExt)) {
177-
fileDelete(uploadPath & "/" & uploadedFile.serverFile);
178-
renderText("Please upload a valid image file (png, jpg, jpeg, gif, webp).");
179-
return;
180-
}
181-
182-
// Validate MIME content type
183-
if (structKeyExists(uploadedFile, "contentType") && structKeyExists(uploadedFile, "contentSubType")) {
184-
var detectedContentType = uploadedFile.contentType & "/" & uploadedFile.contentSubType;
185-
if (!listFindNoCase(allowedContentTypes, detectedContentType)) {
186-
fileDelete(uploadPath & "/" & uploadedFile.serverFile);
187-
renderText("Invalid file content type. Only JPEG, PNG, GIF, and WebP images are allowed.");
188-
return;
189-
}
190-
}
191-
192-
var savedFileName = uploadedFile.serverFile;
193-
model("User").updateAll(profilePicture = savedFileName, where = "id = #val(session.userID)#");
194-
session.profilePic = savedFileName;
195-
renderText("Profile picture uploaded successfully!");
196-
return;
197-
} catch (any e) {
198-
renderText("Error uploading file: #e.message#");
199-
return;
200-
}
201-
} else {
202-
renderText("Please select a profile picture to upload!");
203-
return;
204-
}
205-
}
206152
// Business Logic
207153

208154
private function checkUserAccess() {

app/controllers/web/AuthController.cfc

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ component extends="app.Controllers.Controller" {
177177
session.userID = user.id;
178178
session.username = user.fullname;
179179
session.role = (isObject(user.role) ? user.role.name : "");
180-
session.profilePic = user.profilePicture;
180+
session.email = user.email;
181181
session.lastActivity = now();
182182
// Handle remember me
183183
if (structKeyExists(params, "rememberMe")) {
@@ -547,6 +547,7 @@ component extends="app.Controllers.Controller" {
547547
session.userID = user.id;
548548
session.username = user.fullname;
549549
session.role = user.role.name;
550+
session.email = user.email;
550551

551552
session.message = "Register and Login Successfully!"
552553
redirectto(route="home");

app/events/onrequeststart.cfm

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ if (!structKeyExists(session, "userID") && structKeyExists(cookie, "remember_me"
3434
session.userID = user.id;
3535
session.username = user.fullname;
3636
session.role = (isObject(user.role) ? user.role.name : "");
37-
session.profilePic = user.profilePicture;
37+
session.email = user.email;
3838
session.lastActivity = now();
3939
// rotate token
4040
var newToken = createUUID() & generateSecretKey("AES");

app/views/admin/AdminController/layout.cfm

Lines changed: 18 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -175,22 +175,19 @@
175175
Back to Wheels.dev
176176
</a>
177177
<ul class="navbar-nav navbar-nav-icons flex-row">
178-
<cfif !structKeyExists(session, "profilePic") OR session.profilePic == "">
179-
<cfset session.profilePic = "avatar-rounded.webp">
180-
</cfif>
181178
<div class="nav-item dropdown">
182179
<a class="nav-link lh-1 pe-0" id="navbarDropdownUser" href="javascript:void(0)" role="button" data-bs-toggle="dropdown" data-bs-auto-close="outside" aria-haspopup="true" aria-expanded="false">
183180
<div class="avatar avatar-l ">
184181
<cfoutput>
185-
<cfif !len(session.profilePic) OR findNoCase("avatar-rounded", session.profilePic)>
186-
<div
187-
class="d-flex align-items-center justify-content-center #getAvatarColorByLetter(ucase(left(listLast(session.username, " "), 1)))# text-white rounded-circle fw-bold text-uppercase"
188-
style="width:40px; height:40px;">
189-
#ucase(left(listLast(session.username, " "), 1))#
190-
</div>
191-
<cfelse>
192-
<img src = '/img/#session.profilePic#' class="rounded-circle" alt="user profile picture">
193-
</cfif>
182+
<img src="#gravatarUrl(session.email, 80)#"
183+
class="rounded-circle"
184+
style="width:40px; height:40px;"
185+
onerror="this.style.display='none';this.nextElementSibling.style.display='flex';"
186+
alt="avatar">
187+
<div style="display:none;width:40px;height:40px;"
188+
class="align-items-center justify-content-center #getAvatarColorByLetter(ucase(left(listLast(session.username, ' '), 1)))# text-white rounded-circle fw-bold text-uppercase">
189+
#ucase(left(listLast(session.username, " "), 1))#
190+
</div>
194191
</cfoutput>
195192
</div>
196193
</a>
@@ -200,15 +197,15 @@
200197
<div class="text-center pt-4">
201198
<div class="avatar avatar-xl ">
202199
<cfoutput>
203-
<cfif !len(session.profilePic) OR findNoCase("avatar-rounded", session.profilePic)>
204-
<div
205-
class="d-flex align-items-center fs-24 justify-content-center #getAvatarColorByLetter(ucase(left(listLast(session.username, " "), 1)))# text-white rounded-circle fw-bold text-uppercase"
206-
style="width:3rem; height:3rem;">
207-
#ucase(left(listLast(session.username, " "), 1))#
208-
</div>
209-
<cfelse>
210-
<img src = '/img/#session.profilePic#' class="rounded-circle" alt="profile-picture">
211-
</cfif>
200+
<img src="#gravatarUrl(session.email, 96)#"
201+
class="rounded-circle"
202+
style="width:3rem; height:3rem;"
203+
onerror="this.style.display='none';this.nextElementSibling.style.display='flex';"
204+
alt="avatar">
205+
<div style="display:none;width:3rem;height:3rem;"
206+
class="align-items-center justify-content-center fs-24 #getAvatarColorByLetter(ucase(left(listLast(session.username, ' '), 1)))# text-white rounded-circle fw-bold text-uppercase">
207+
#ucase(left(listLast(session.username, " "), 1))#
208+
</div>
212209
</cfoutput>
213210
</div>
214211
<h6 class="mt-2 text-body-emphasis"><cfoutput>#session.username#</cfoutput></h6>

app/views/admin/AdminController/showBlog.cfm

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,15 @@
3131
<div class="col-12 d-flex flex-column">
3232
<div class="d-flex my-3 align-items-center gap-3">
3333
<div>
34-
<cfif !len(blog.user.profilePicture) OR findNoCase("avatar-rounded", blog.user.profilePicture)>
35-
<div
36-
class="d-flex align-items-center justify-content-center #getAvatarColorByLetter(ucase(left(listLast(blog.user.fullName, " "), 1)))# text-white rounded-circle fw-bold text-uppercase"
37-
style="width:3rem; height:3rem;">
38-
#ucase(left(listLast(blog.user.fullName, " "), 1))#
39-
</div>
40-
<cfelse>
41-
<img src="/img/#blog.user.profilePicture#" style="width:3rem; height:3rem" class="bg-body-secondary rounded-5" alt="profile-picture">
42-
</cfif>
34+
<img src="#gravatarUrl(blog.user.email, 96)#"
35+
class="rounded-circle"
36+
style="width:3rem; height:3rem;"
37+
onerror="this.style.display='none';this.nextElementSibling.style.display='flex';"
38+
alt="avatar">
39+
<div style="display:none;width:3rem;height:3rem;"
40+
class="d-flex align-items-center justify-content-center #getAvatarColorByLetter(ucase(left(listLast(blog.user.fullName, ' '), 1)))# text-white rounded-circle fw-bold text-uppercase">
41+
#ucase(left(listLast(blog.user.fullName, " "), 1))#
42+
</div>
4343
</div>
4444
<p class="fs-18 text--secondary fw-semibold p-0 m-0">#blog.user.fullName#</p>
4545
</div>

app/views/admin/UserController/profile.cfm

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,10 +69,18 @@
6969
<label class="form-label mb-1 fs-14 fw-medium">
7070
Profile Picture
7171
</label>
72-
<cfif len(user.profilePicture)>
73-
<img src="/img/#user.profilePicture#" width="100" alt="Wheels.dev Profile Picture">
74-
</cfif>
75-
<input class="form-control fs-14" type="file" name="profilePicture" accept="image/*">
72+
<div class="d-flex align-items-center gap-3 mt-1">
73+
<img src="#gravatarUrl(email, 200)#"
74+
class="rounded-circle"
75+
style="width:100px; height:100px;"
76+
onerror="this.style.display='none';this.nextElementSibling.style.display='flex';"
77+
alt="avatar">
78+
<div style="display:none;width:100px;height:100px;font-size:2rem;"
79+
class="align-items-center justify-content-center #getAvatarColorByLetter(ucase(left(listLast(firstName & ' ' & lastName, ' '), 1)))# text-white rounded-circle fw-bold text-uppercase">
80+
#ucase(left(listLast(firstName & " " & lastName, " "), 1))#
81+
</div>
82+
<span class="fs-12 text-muted">Powered by <a href="https://gravatar.com" target="_blank" rel="noopener noreferrer">Gravatar</a></span>
83+
</div>
7684
</div>
7785

7886

app/views/admin/UserController/updateProfilePic.cfm

Lines changed: 20 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -2,65 +2,33 @@
22
<div class="row w-100 m-lg-auto m-2">
33
<div class="col-lg-5 bg-white col-12 mx-auto p-3 rounded-18">
44
<div class="mt-2">
5-
<h1 class="fs-24 mb-0 fw-bold text--secondary text-center">Change Profile Picture</h1>
5+
<h1 class="fs-24 mb-0 fw-bold text--secondary text-center">Profile Picture</h1>
66

77
<div class="text-center pb-3 my-4">
8-
<cfif !structKeyExists(session, "profilePic") OR session.profilePic == "">
9-
<cfset session.profilePic = "avatar-rounded.webp">
10-
</cfif>
118
<cfoutput>
12-
<img src = '/img/#session.profilePic#' alt="user profile pic" height="150" width="150" class="rounded-circle">
9+
<img src="#gravatarUrl(session.email, 300)#"
10+
class="rounded-circle"
11+
style="width:150px; height:150px;"
12+
onerror="this.style.display='none';this.nextElementSibling.style.display='flex';"
13+
alt="avatar">
14+
<div style="display:none;width:150px;height:150px;font-size:3rem;"
15+
class="align-items-center justify-content-center mx-auto #getAvatarColorByLetter(ucase(left(listLast(session.username, ' '), 1)))# text-white rounded-circle fw-bold text-uppercase">
16+
#ucase(left(listLast(session.username, " "), 1))#
17+
</div>
1318
</cfoutput>
1419
</div>
15-
<form class="pt-3 px-1 needs-validation" id="profilePicForm" enctype="multipart/form-data" novalidate hx-post="/admin/user/upload-profile-pic" hx-validate="true">
16-
<cfoutput>#authenticityTokenField()#</cfoutput>
17-
<div class="mb-3">
18-
<label for="formFile" class="form-label">Profile Picture</label>
19-
<input type="file" id="imageInput" class="form-control" name="profilePic" accept="image/*" required>
20-
<div id="fileError" class="text-danger fs-14 mt-1"></div>
21-
</div>
2220

23-
<div class="d-flex flex-wrap justify-content-center gap-2 align-items-start">
24-
<button type="button" class="bg--default text-dark px-3 py-2 rounded fs-14" onclick="history.back()">Cancel</button>
25-
<button type="submit" class="bg--primary text-white px-3 py-2 rounded fs-14">Save</button>
26-
</div>
27-
</form>
21+
<div class="text-center px-3">
22+
<p class="fs-14 text--secondary">
23+
Your profile picture is powered by <strong>Gravatar</strong>.
24+
To change your avatar, update it on Gravatar using the email address associated with your account.
25+
</p>
26+
<a href="https://gravatar.com" target="_blank" rel="noopener noreferrer" class="bg--primary text-white px-4 py-2 rounded fs-14 text-decoration-none d-inline-block mt-2">
27+
Manage Avatar on Gravatar
28+
</a>
29+
<button type="button" class="bg--default text-dark px-3 py-2 rounded fs-14 d-inline-block mt-2 ms-2 border-0" onclick="history.back()">Back</button>
30+
</div>
2831
</div>
2932
</div>
3033
</div>
3134
</main>
32-
<script>
33-
document.addEventListener("htmx:configRequest", function (event) {
34-
const form = document.getElementById("profilePicForm");
35-
if (!form.contains(event.target)) return;
36-
const fileInput = document.getElementById("imageInput");
37-
const errorDiv = document.getElementById("fileError");
38-
const file = fileInput.files[0];
39-
40-
// Clear previous error message
41-
errorDiv.textContent = "";
42-
43-
if (file) {
44-
const validExtensions = ["image/png", "image/jpeg", "image/jpg", "image/gif", "image/webp"];
45-
if (!validExtensions.includes(file.type) || file.name.toLowerCase().endsWith(".jfif")) {
46-
event.preventDefault(); // Prevent form submission
47-
errorDiv.textContent = "Please upload a valid image file (png, jpg, jpeg, gif, webp).";
48-
}
49-
} else {
50-
event.preventDefault();
51-
errorDiv.textContent = "Please select an image file to upload.";
52-
}
53-
});
54-
document.getElementById("profilePicForm").addEventListener("htmx:afterRequest", function(e) {
55-
const xhr = e.detail.xhr;
56-
const responseText = xhr.responseText.trim().toLowerCase();
57-
if (xhr.status === 200 && responseText.includes("success")) {
58-
notifier.show("Success", "Profile picture updated!", "success", "", 3000);
59-
setTimeout(() => {
60-
window.location.href = "/";
61-
}, 3000);
62-
} else if (responseText) {
63-
notifier.show("Error", responseText, "danger", "", 4000);
64-
}
65-
});
66-
</script>

app/views/helpers.cfm

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,9 @@
3636
function verifyCSRFToken(required string token) {
3737
return structKeyExists(session, "csrf_token") && token == session.csrf_token;
3838
}
39+
40+
string function gravatarUrl(required string email, numeric size=80) {
41+
var emailHash = lcase(hash(lcase(trim(arguments.email)), "MD5"));
42+
return "https://www.gravatar.com/avatar/" & emailHash & "?s=" & arguments.size & "&d=404";
43+
}
3944
</cfscript>

0 commit comments

Comments
 (0)