Customizable avatars for users

This commit is contained in:
Cohee
2024-04-13 17:52:37 +03:00
parent 10aa268ea2
commit 1a372abaff
12 changed files with 225 additions and 37 deletions

View File

@@ -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<import('../users').UserViewModel>[]} */
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);

View File

@@ -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' });

View File

@@ -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<import('../users').UserViewModel>[]} */
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);

View File

@@ -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<string>} 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,