| 1 | import { promises as fsPromises } from 'node:fs'; |
| 2 | |
| 3 | import storage from 'node-persist'; |
| 4 | import express from 'express'; |
| 5 | import lodash from 'lodash'; |
| 6 | import { checkForNewContent, CONTENT_TYPES } from './content-manager.js'; |
| 7 | import { |
| 8 | KEY_PREFIX, |
| 9 | toKey, |
| 10 | requireAdminMiddleware, |
| 11 | getUserAvatar, |
| 12 | getAllUserHandles, |
| 13 | getPasswordSalt, |
| 14 | getPasswordHash, |
| 15 | getUserDirectories, |
| 16 | ensurePublicDirectoriesExist, |
| 17 | } from '../users.js'; |
| 18 | import { DEFAULT_USER } from '../constants.js'; |
| 19 | |
| 20 | export const router = express.Router(); |
| 21 | |
| 22 | /** |
| 23 | * Slugifies a given text string. |
| 24 | * - Converts to lowercase |
| 25 | * - Trims whitespace |
| 26 | * - Replaces spaces and special characters with hyphens |
| 27 | * - Removes leading and trailing hyphens |
| 28 | * - Uses lodash.deburr to remove diacritical marks |
| 29 | * @param {string} text Text to slugify |
| 30 | * @returns {string} Slugified text |
| 31 | */ |
| 32 | function slugify(text) { |
| 33 | return lodash.deburr(String(text ?? '').toLowerCase().trim()).replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); |
| 34 | } |
| 35 | |
| 36 | router.post('/get', requireAdminMiddleware, async (_request, response) => { |
| 37 | try { |
| 38 | /** @type {import('../users.js').User[]} */ |
| 39 | const users = await storage.values(x => x.key.startsWith(KEY_PREFIX)); |
| 40 | |
| 41 | /** @type {Promise<import('../users.js').UserViewModel>[]} */ |
| 42 | const viewModelPromises = users |
| 43 | .map(user => new Promise(resolve => { |
| 44 | getUserAvatar(user.handle).then(avatar => |
| 45 | resolve({ |
| 46 | handle: user.handle, |
| 47 | name: user.name, |
| 48 | avatar: avatar, |
| 49 | admin: user.admin, |
| 50 | enabled: user.enabled, |
| 51 | created: user.created, |
| 52 | password: !!user.password, |
| 53 | }), |
| 54 | ); |
| 55 | })); |
| 56 | |
| 57 | const viewModels = await Promise.all(viewModelPromises); |
| 58 | viewModels.sort((x, y) => (x.created ?? 0) - (y.created ?? 0)); |
| 59 | return response.json(viewModels); |
| 60 | } catch (error) { |
| 61 | console.error('User list failed:', error); |
| 62 | return response.sendStatus(500); |
| 63 | } |
| 64 | }); |
| 65 | |
| 66 | router.post('/disable', requireAdminMiddleware, async (request, response) => { |
| 67 | try { |
| 68 | if (!request.body.handle) { |
| 69 | console.warn('Disable user failed: Missing required fields'); |
| 70 | return response.status(400).json({ error: 'Missing required fields' }); |
| 71 | } |
| 72 | |
| 73 | if (request.body.handle === request.user.profile.handle) { |
| 74 | console.warn('Disable user failed: Cannot disable yourself'); |
| 75 | return response.status(400).json({ error: 'Cannot disable yourself' }); |
| 76 | } |
| 77 | |
| 78 | /** @type {import('../users.js').User} */ |
| 79 | const user = await storage.getItem(toKey(request.body.handle)); |
| 80 | |
| 81 | if (!user) { |
| 82 | console.error('Disable user failed: User not found'); |
| 83 | return response.status(404).json({ error: 'User not found' }); |
| 84 | } |
| 85 | |
| 86 | user.enabled = false; |
| 87 | await storage.setItem(toKey(request.body.handle), user); |
| 88 | return response.sendStatus(204); |
| 89 | } catch (error) { |
| 90 | console.error('User disable failed:', error); |
| 91 | return response.sendStatus(500); |
| 92 | } |
| 93 | }); |
| 94 | |
| 95 | router.post('/enable', requireAdminMiddleware, async (request, response) => { |
| 96 | try { |
| 97 | if (!request.body.handle) { |
| 98 | console.warn('Enable user failed: Missing required fields'); |
| 99 | return response.status(400).json({ error: 'Missing required fields' }); |
| 100 | } |
| 101 | |
| 102 | /** @type {import('../users.js').User} */ |
| 103 | const user = await storage.getItem(toKey(request.body.handle)); |
| 104 | |
| 105 | if (!user) { |
| 106 | console.error('Enable user failed: User not found'); |
| 107 | return response.status(404).json({ error: 'User not found' }); |
| 108 | } |
| 109 | |
| 110 | user.enabled = true; |
| 111 | await storage.setItem(toKey(request.body.handle), user); |
| 112 | return response.sendStatus(204); |
| 113 | } catch (error) { |
| 114 | console.error('User enable failed:', error); |
| 115 | return response.sendStatus(500); |
| 116 | } |
| 117 | }); |
| 118 | |
| 119 | router.post('/promote', requireAdminMiddleware, async (request, response) => { |
| 120 | try { |
| 121 | if (!request.body.handle) { |
| 122 | console.warn('Promote user failed: Missing required fields'); |
| 123 | return response.status(400).json({ error: 'Missing required fields' }); |
| 124 | } |
| 125 | |
| 126 | /** @type {import('../users.js').User} */ |
| 127 | const user = await storage.getItem(toKey(request.body.handle)); |
| 128 | |
| 129 | if (!user) { |
| 130 | console.error('Promote user failed: User not found'); |
| 131 | return response.status(404).json({ error: 'User not found' }); |
| 132 | } |
| 133 | |
| 134 | user.admin = true; |
| 135 | await storage.setItem(toKey(request.body.handle), user); |
| 136 | return response.sendStatus(204); |
| 137 | } catch (error) { |
| 138 | console.error('User promote failed:', error); |
| 139 | return response.sendStatus(500); |
| 140 | } |
| 141 | }); |
| 142 | |
| 143 | router.post('/demote', requireAdminMiddleware, async (request, response) => { |
| 144 | try { |
| 145 | if (!request.body.handle) { |
| 146 | console.warn('Demote user failed: Missing required fields'); |
| 147 | return response.status(400).json({ error: 'Missing required fields' }); |
| 148 | } |
| 149 | |
| 150 | if (request.body.handle === request.user.profile.handle) { |
| 151 | console.warn('Demote user failed: Cannot demote yourself'); |
| 152 | return response.status(400).json({ error: 'Cannot demote yourself' }); |
| 153 | } |
| 154 | |
| 155 | /** @type {import('../users.js').User} */ |
| 156 | const user = await storage.getItem(toKey(request.body.handle)); |
| 157 | |
| 158 | if (!user) { |
| 159 | console.error('Demote user failed: User not found'); |
| 160 | return response.status(404).json({ error: 'User not found' }); |
| 161 | } |
| 162 | |
| 163 | user.admin = false; |
| 164 | await storage.setItem(toKey(request.body.handle), user); |
| 165 | return response.sendStatus(204); |
| 166 | } catch (error) { |
| 167 | console.error('User demote failed:', error); |
| 168 | return response.sendStatus(500); |
| 169 | } |
| 170 | }); |
| 171 | |
| 172 | router.post('/create', requireAdminMiddleware, async (request, response) => { |
| 173 | try { |
| 174 | if (!request.body.handle || !request.body.name) { |
| 175 | console.warn('Create user failed: Missing required fields'); |
| 176 | return response.status(400).json({ error: 'Missing required fields' }); |
| 177 | } |
| 178 | |
| 179 | const handles = await getAllUserHandles(); |
| 180 | const handle = slugify(request.body.handle); |
| 181 | |
| 182 | if (!handle) { |
| 183 | console.warn('Create user failed: Invalid handle'); |
| 184 | return response.status(400).json({ error: 'Invalid handle' }); |
| 185 | } |
| 186 | |
| 187 | if (handles.some(x => x === handle)) { |
| 188 | console.warn('Create user failed: User with that handle already exists'); |
| 189 | return response.status(409).json({ error: 'User already exists' }); |
| 190 | } |
| 191 | |
| 192 | const salt = getPasswordSalt(); |
| 193 | const password = request.body.password ? getPasswordHash(request.body.password, salt) : ''; |
| 194 | |
| 195 | const newUser = { |
| 196 | handle: handle, |
| 197 | name: request.body.name || 'Anonymous', |
| 198 | created: Date.now(), |
| 199 | password: password, |
| 200 | salt: salt, |
| 201 | admin: !!request.body.admin, |
| 202 | enabled: true, |
| 203 | }; |
| 204 | |
| 205 | await storage.setItem(toKey(handle), newUser); |
| 206 | |
| 207 | // Create user directories |
| 208 | console.info('Creating data directories for', newUser.handle); |
| 209 | await ensurePublicDirectoriesExist(); |
| 210 | const directories = getUserDirectories(newUser.handle); |
| 211 | await checkForNewContent([directories], [CONTENT_TYPES.SETTINGS]); |
| 212 | return response.json({ handle: newUser.handle }); |
| 213 | } catch (error) { |
| 214 | console.error('User create failed:', error); |
| 215 | return response.sendStatus(500); |
| 216 | } |
| 217 | }); |
| 218 | |
| 219 | router.post('/delete', requireAdminMiddleware, async (request, response) => { |
| 220 | try { |
| 221 | if (!request.body.handle) { |
| 222 | console.warn('Delete user failed: Missing required fields'); |
| 223 | return response.status(400).json({ error: 'Missing required fields' }); |
| 224 | } |
| 225 | |
| 226 | if (request.body.handle === request.user.profile.handle) { |
| 227 | console.warn('Delete user failed: Cannot delete yourself'); |
| 228 | return response.status(400).json({ error: 'Cannot delete yourself' }); |
| 229 | } |
| 230 | |
| 231 | if (request.body.handle === DEFAULT_USER.handle) { |
| 232 | console.warn('Delete user failed: Cannot delete default user'); |
| 233 | return response.status(400).json({ error: 'Sorry, but the default user cannot be deleted. It is required as a fallback.' }); |
| 234 | } |
| 235 | |
| 236 | await storage.removeItem(toKey(request.body.handle)); |
| 237 | |
| 238 | if (request.body.purge) { |
| 239 | const directories = getUserDirectories(request.body.handle); |
| 240 | console.info('Deleting data directories for', request.body.handle); |
| 241 | await fsPromises.rm(directories.root, { recursive: true, force: true }); |
| 242 | } |
| 243 | |
| 244 | return response.sendStatus(204); |
| 245 | } catch (error) { |
| 246 | console.error('User delete failed:', error); |
| 247 | return response.sendStatus(500); |
| 248 | } |
| 249 | }); |
| 250 | |
| 251 | router.post('/slugify', requireAdminMiddleware, async (request, response) => { |
| 252 | try { |
| 253 | if (!request.body.text) { |
| 254 | console.warn('Slugify failed: Missing required fields'); |
| 255 | return response.status(400).json({ error: 'Missing required fields' }); |
| 256 | } |
| 257 | |
| 258 | const text = slugify(request.body.text); |
| 259 | |
| 260 | return response.send(text); |
| 261 | } catch (error) { |
| 262 | console.error('Slugify failed:', error); |
| 263 | return response.sendStatus(500); |
| 264 | } |
| 265 | }); |