From 1a372abaff67fceaaf7eefa4c9dfe993b94d7478 Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Sat, 13 Apr 2024 17:52:37 +0300 Subject: [PATCH] Customizable avatars for users --- public/css/login.css | 11 ++- public/css/st-tailwind.css | 8 +++ public/login.html | 2 +- public/script.js | 6 +- public/scripts/login.js | 4 +- public/scripts/templates/admin.html | 15 +++- public/scripts/templates/userProfile.html | 15 +++- public/scripts/user.js | 84 ++++++++++++++++++++++- src/endpoints/users-admin.js | 28 +++++--- src/endpoints/users-private.js | 41 ++++++++++- src/endpoints/users-public.js | 22 ++++-- src/users.js | 26 ++++++- 12 files changed, 225 insertions(+), 37 deletions(-) diff --git a/public/css/login.css b/public/css/login.css index d93ae1b85..94f8e9953 100644 --- a/public/css/login.css +++ b/public/css/login.css @@ -20,7 +20,7 @@ body.login .userSelect { border: 1px solid var(--SmartThemeBorderColor); border-radius: 5px; padding: 3px 5px; - width: min-content; + width: 30%; cursor: pointer; margin: 5px 0; transition: background-color 0.15s ease-in-out; @@ -28,6 +28,15 @@ body.login .userSelect { align-items: center; justify-content: center; text-align: center; + overflow: hidden; +} + +body.login .userSelect .userName, +body.login .userSelect .userHandle { + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } body.login .userSelect:hover { diff --git a/public/css/st-tailwind.css b/public/css/st-tailwind.css index 6018577b2..20c68542a 100644 --- a/public/css/st-tailwind.css +++ b/public/css/st-tailwind.css @@ -98,6 +98,14 @@ justify-content: space-between; } +.justifySpaceEvenly { + justify-content: space-evenly; +} + +.justifySpaceAround { + justify-content: space-around; +} + .alignitemsflexstart { align-items: flex-start !important; } diff --git a/public/login.html b/public/login.html index 5c6cd2366..44c9bd23f 100644 --- a/public/login.html +++ b/public/login.html @@ -45,7 +45,7 @@ Enter Login Details
-
+
diff --git a/public/script.js b/public/script.js index c7d5270c3..6aba237d8 100644 --- a/public/script.js +++ b/public/script.js @@ -7073,10 +7073,10 @@ function onScenarioOverrideRemoveClick() { * @param {string} type * @param {string} inputValue - Value to set the input to. * @param {PopupOptions} options - Options for the popup. - * @typedef {{okButton?: string, rows?: number, wide?: boolean, large?: boolean, allowHorizontalScrolling?: boolean, allowVerticalScrolling?: boolean }} PopupOptions - Options for the popup. + * @typedef {{okButton?: string, rows?: number, wide?: boolean, large?: boolean, allowHorizontalScrolling?: boolean, allowVerticalScrolling?: boolean, cropAspect?: number }} PopupOptions - Options for the popup. * @returns */ -function callPopup(text, type, inputValue = '', { okButton, rows, wide, large, allowHorizontalScrolling, allowVerticalScrolling } = {}) { +function callPopup(text, type, inputValue = '', { okButton, rows, wide, large, allowHorizontalScrolling, allowVerticalScrolling, cropAspect } = {}) { dialogueCloseStop = true; if (type) { popup_type = type; @@ -7133,7 +7133,7 @@ function callPopup(text, type, inputValue = '', { okButton, rows, wide, large, a crop_data = undefined; $('#avatarToCrop').cropper({ - aspectRatio: 2 / 3, + aspectRatio: cropAspect ?? 2 / 3, autoCropArea: 1, viewMode: 2, rotatable: false, diff --git a/public/scripts/login.js b/public/scripts/login.js index a425d6aef..50e075939 100644 --- a/public/scripts/login.js +++ b/public/scripts/login.js @@ -215,8 +215,8 @@ function configureNormalLogin(userList) { const avatarBlock = $('
').addClass('avatar'); avatarBlock.append($('').attr('src', user.avatar)); userBlock.append(avatarBlock); - userBlock.append($('').text(user.name)); - userBlock.append($('').text(user.handle)); + userBlock.append($('').addClass('userName').text(user.name)); + userBlock.append($('').addClass('userHandle').text(user.handle)); userBlock.on('click', () => onUserSelected(user)); $('#userList').append(userBlock); } diff --git a/public/scripts/templates/admin.html b/public/scripts/templates/admin.html index 6519e76f1..5984c041c 100644 --- a/public/scripts/templates/admin.html +++ b/public/scripts/templates/admin.html @@ -9,10 +9,21 @@
-
-
+
+
avatar
+
+
+ +
+
+ +
+
+
+ +
diff --git a/public/scripts/templates/userProfile.html b/public/scripts/templates/userProfile.html index 0aa40eb0d..cf17c9c85 100644 --- a/public/scripts/templates/userProfile.html +++ b/public/scripts/templates/userProfile.html @@ -15,10 +15,21 @@ Account Info
-
-
+
+
avatar
+
+
+ +
+
+ +
+
+
+ +
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,