diff --git a/public/scripts/user.js b/public/scripts/user.js
index c432e090b..c9a91a6cf 100644
--- a/public/scripts/user.js
+++ b/public/scripts/user.js
@@ -1,7 +1,7 @@
-import { getRequestHeaders } from '../script.js';
+import { callPopup, getCropPopup, getRequestHeaders } from '../script.js';
import { POPUP_RESULT, POPUP_TYPE, callGenericPopup } from './popup.js';
import { renderTemplateAsync } from './templates.js';
-import { humanFileSize } from './utils.js';
+import { ensureImageFormatSupported, getBase64Async, humanFileSize } from './utils.js';
/**
* @type {import('../../src/users.js').UserViewModel} Logged in user
@@ -683,6 +683,26 @@ async function openUserProfile() {
});
template.find('.userResetSettingsButton').on('click', () => resetSettings(currentUser.handle, () => location.reload()));
template.find('.userResetAllButton').on('click', () => resetEverything(() => location.reload()));
+ template.find('.userAvatarChange').on('click', () => template.find('.avatarUpload').trigger('click'));
+ template.find('.avatarUpload').on('change', async function () {
+ if (!(this instanceof HTMLInputElement)) {
+ return;
+ }
+
+ const file = this.files[0];
+ if (!file) {
+ return;
+ }
+
+ await cropAndUploadAvatar(currentUser.handle, file);
+ await getCurrentUser();
+ template.find('.avatar img').attr('src', currentUser.avatar);
+ });
+ template.find('.userAvatarRemove').on('click', async function () {
+ await changeAvatar(currentUser.handle, '');
+ await getCurrentUser();
+ template.find('.avatar img').attr('src', currentUser.avatar);
+ });
if (!accountsEnabled) {
template.find('[data-require-accounts]').hide();
@@ -699,6 +719,48 @@ async function openUserProfile() {
callGenericPopup(template, POPUP_TYPE.TEXT, '', popupOptions);
}
+/**
+ * Crop and upload an avatar image.
+ * @param {string} handle User handle
+ * @param {File} file Avatar file
+ * @returns {Promise}
+ */
+async function cropAndUploadAvatar(handle, file) {
+ const dataUrl = await getBase64Async(await ensureImageFormatSupported(file));
+ const croppedImage = await callPopup(getCropPopup(dataUrl), 'avatarToCrop', '', { cropAspect: 1 });
+ if (!croppedImage) {
+ return;
+ }
+
+ await changeAvatar(handle, String(croppedImage));
+
+ return croppedImage;
+}
+
+/**
+ * Change the avatar of the user.
+ * @param {string} handle User handle
+ * @param {string} avatar File to upload or base64 string
+ * @returns {Promise} Avatar URL
+ */
+async function changeAvatar(handle, avatar) {
+ try {
+ const response = await fetch('/api/users/change-avatar', {
+ method: 'POST',
+ headers: getRequestHeaders(),
+ body: JSON.stringify({ avatar, handle }),
+ });
+
+ if (!response.ok) {
+ const data = await response.json();
+ toastr.error(data.error || 'Unknown error', 'Failed to change avatar');
+ return;
+ }
+ } catch (error) {
+ console.error('Error changing avatar:', error);
+ }
+}
+
async function openAdminPanel() {
async function renderUsers() {
const users = await getUsers();
@@ -724,6 +786,24 @@ async function openAdminPanel() {
$(this).addClass('disabled').off('click');
backupUserData(user.handle, renderUsers);
});
+ userBlock.find('.userAvatarChange').on('click', () => userBlock.find('.avatarUpload').trigger('click'));
+ userBlock.find('.avatarUpload').on('change', async function () {
+ if (!(this instanceof HTMLInputElement)) {
+ return;
+ }
+
+ const file = this.files[0];
+ if (!file) {
+ return;
+ }
+
+ await cropAndUploadAvatar(user.handle, file);
+ renderUsers();
+ });
+ userBlock.find('.userAvatarRemove').on('click', async function () {
+ await changeAvatar(user.handle, '');
+ renderUsers();
+ });
template.find('.usersList').append(userBlock);
}
}
diff --git a/src/endpoints/users-admin.js b/src/endpoints/users-admin.js
index 5d7879f64..a67233c46 100644
--- a/src/endpoints/users-admin.js
+++ b/src/endpoints/users-admin.js
@@ -19,23 +19,29 @@ const { DEFAULT_USER } = require('../constants');
const router = express.Router();
-router.post('/get', requireAdminMiddleware, jsonParser, async (request, response) => {
+router.post('/get', requireAdminMiddleware, jsonParser, async (_request, response) => {
try {
/** @type {import('../users').User[]} */
const users = await storage.values(x => x.key.startsWith(KEY_PREFIX));
- const viewModels = users
- .sort((x, y) => x.created - y.created)
- .map(user => ({
- handle: user.handle,
- name: user.name,
- avatar: getUserAvatar(user.handle),
- admin: user.admin,
- enabled: user.enabled,
- created: user.created,
- password: !!user.password,
+ /** @type {Promise[]} */
+ const viewModelPromises = users
+ .map(user => new Promise(resolve => {
+ getUserAvatar(user.handle).then(avatar =>
+ resolve({
+ handle: user.handle,
+ name: user.name,
+ avatar: avatar,
+ admin: user.admin,
+ enabled: user.enabled,
+ created: user.created,
+ password: !!user.password,
+ }),
+ );
}));
+ const viewModels = await Promise.all(viewModelPromises);
+ viewModels.sort((x, y) => (x.created ?? 0) - (y.created ?? 0));
return response.json(viewModels);
} catch (error) {
console.error('User list failed:', error);
diff --git a/src/endpoints/users-private.js b/src/endpoints/users-private.js
index 0bec02b42..40b0cbe03 100644
--- a/src/endpoints/users-private.js
+++ b/src/endpoints/users-private.js
@@ -4,7 +4,7 @@ const storage = require('node-persist');
const express = require('express');
const crypto = require('crypto');
const { jsonParser } = require('../express-common');
-const { getUserAvatar, toKey, getPasswordHash, getPasswordSalt, createBackupArchive, ensurePublicDirectoriesExist } = require('../users');
+const { getUserAvatar, toKey, getPasswordHash, getPasswordSalt, createBackupArchive, ensurePublicDirectoriesExist, toAvatarKey } = require('../users');
const { SETTINGS_FILE } = require('../constants');
const contentManager = require('./content-manager');
const { color, Cache } = require('../util');
@@ -39,7 +39,7 @@ router.get('/me', async (request, response) => {
const viewModel = {
handle: user.handle,
name: user.name,
- avatar: getUserAvatar(user.handle),
+ avatar: await getUserAvatar(user.handle),
admin: user.admin,
password: !!user.password,
created: user.created,
@@ -52,6 +52,41 @@ router.get('/me', async (request, response) => {
}
});
+router.post('/change-avatar', jsonParser, async (request, response) => {
+ try {
+ if (!request.body.handle) {
+ console.log('Change avatar failed: Missing required fields');
+ return response.status(400).json({ error: 'Missing required fields' });
+ }
+
+ if (request.body.handle !== request.user.profile.handle && !request.user.profile.admin) {
+ console.log('Change avatar failed: Unauthorized');
+ return response.status(403).json({ error: 'Unauthorized' });
+ }
+
+ // Avatar is not a data URL or not an empty string
+ if (!request.body.avatar.startsWith('data:image/') && request.body.avatar !== '') {
+ console.log('Change avatar failed: Invalid data URL');
+ return response.status(400).json({ error: 'Invalid data URL' });
+ }
+
+ /** @type {import('../users').User} */
+ const user = await storage.getItem(toKey(request.body.handle));
+
+ if (!user) {
+ console.log('Change avatar failed: User not found');
+ return response.status(404).json({ error: 'User not found' });
+ }
+
+ await storage.setItem(toAvatarKey(request.body.handle), request.body.avatar);
+
+ return response.sendStatus(204);
+ } catch (error) {
+ console.error(error);
+ return response.sendStatus(500);
+ }
+});
+
router.post('/change-password', jsonParser, async (request, response) => {
try {
if (!request.body.handle) {
@@ -185,7 +220,7 @@ router.post('/reset-step1', jsonParser, async (request, response) => {
});
router.post('/reset-step2', jsonParser, async (request, response) => {
- try{
+ try {
if (!request.body.code) {
console.log('Recover step 2 failed: Missing required fields');
return response.status(400).json({ error: 'Missing required fields' });
diff --git a/src/endpoints/users-public.js b/src/endpoints/users-public.js
index 1dea9173a..9eb765ca3 100644
--- a/src/endpoints/users-public.js
+++ b/src/endpoints/users-public.js
@@ -27,16 +27,24 @@ router.post('/list', async (_request, response) => {
/** @type {import('../users').User[]} */
const users = await storage.values(x => x.key.startsWith(KEY_PREFIX));
- const viewModels = users
+
+ /** @type {Promise[]} */
+ const viewModelPromises = users
.filter(x => x.enabled)
- .sort((x, y) => x.created - y.created)
- .map(user => ({
- handle: user.handle,
- name: user.name,
- avatar: getUserAvatar(user.handle),
- password: !!user.password,
+ .map(user => new Promise(async (resolve) => {
+ getUserAvatar(user.handle).then(avatar =>
+ resolve({
+ handle: user.handle,
+ name: user.name,
+ created: user.created,
+ avatar: avatar,
+ password: !!user.password,
+ }),
+ );
}));
+ const viewModels = await Promise.all(viewModelPromises);
+ viewModels.sort((x, y) => (x.created ?? 0) - (y.created ?? 0));
return response.json(viewModels);
} catch (error) {
console.error('User list failed:', error);
diff --git a/src/users.js b/src/users.js
index 6d149d32a..25aeb6981 100644
--- a/src/users.js
+++ b/src/users.js
@@ -15,6 +15,7 @@ const { getConfigValue, color, delay, setConfigValue, generateTimestamp } = requ
const { readSecret, writeSecret } = require('./endpoints/secrets');
const KEY_PREFIX = 'user:';
+const AVATAR_PREFIX = 'avatar:';
const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false);
const ANON_CSRF_SECRET = crypto.randomBytes(64).toString('base64');
@@ -51,7 +52,7 @@ const STORAGE_KEYS = {
* @property {string} handle - The user's short handle. Used for directories and other references
* @property {string} name - The user's name. Displayed in the UI
* @property {string} avatar - The user's avatar image
- * @property {boolean} admin - Whether the user is an admin (can manage other users)
+ * @property {boolean} [admin] - Whether the user is an admin (can manage other users)
* @property {boolean} password - Whether the user is password protected
* @property {boolean} [enabled] - Whether the user is enabled
* @property {number} [created] - The timestamp when the user was created
@@ -315,6 +316,15 @@ function toKey(handle) {
return `${KEY_PREFIX}${handle}`;
}
+/**
+ * Converts a user handle to a storage key for avatars.
+ * @param {string} handle User handle
+ * @returns {string} The key for the avatar storage
+ */
+function toAvatarKey(handle) {
+ return `${AVATAR_PREFIX}${handle}`;
+}
+
/**
* Initializes the user storage. Currently a no-op.
* @param {string} dataRoot The root directory for user data
@@ -435,10 +445,19 @@ function getUserDirectories(handle) {
/**
* Gets the avatar URL for the provided user.
* @param {string} handle User handle
- * @returns {string} User avatar URL
+ * @returns {Promise} User avatar URL
*/
-function getUserAvatar(handle) {
+async function getUserAvatar(handle) {
try {
+ // Check if the user has a custom avatar
+ const avatarKey = toAvatarKey(handle);
+ const avatar = await storage.getItem(avatarKey);
+
+ if (avatar) {
+ return avatar;
+ }
+
+ // Fallback to reading from files if custom avatar is not set
const directory = getUserDirectories(handle);
const pathToSettings = path.join(directory.root, SETTINGS_FILE);
const settings = fs.existsSync(pathToSettings) ? JSON.parse(fs.readFileSync(pathToSettings, 'utf8')) : {};
@@ -665,6 +684,7 @@ router.use('/scripts/extensions/third-party/*', createRouteHandler(req => req.us
module.exports = {
KEY_PREFIX,
toKey,
+ toAvatarKey,
initUserStorage,
ensurePublicDirectoriesExist,
getAllUserHandles,