| 1 | import path from 'node:path'; |
| 2 | import { promises as fsPromises } from 'node:fs'; |
| 3 | import crypto from 'node:crypto'; |
| 4 | |
| 5 | import storage from 'node-persist'; |
| 6 | import express from 'express'; |
| 7 | |
| 8 | import { getUserAvatar, toKey, getPasswordHash, getPasswordSalt, createBackupArchive, ensurePublicDirectoriesExist, toAvatarKey, getAccountVersion } from '../users.js'; |
| 9 | import { SETTINGS_FILE } from '../constants.js'; |
| 10 | import { checkForNewContent, CONTENT_TYPES } from './content-manager.js'; |
| 11 | import { color, Cache, getConfigValue } from '../util.js'; |
| 12 | |
| 13 | const RESET_CACHE = new Cache(5 * 60 * 1000); |
| 14 | |
| 15 | export const router = express.Router(); |
| 16 | |
| 17 | router.post('/logout', async (request, response) => { |
| 18 | try { |
| 19 | if (!request.session) { |
| 20 | console.error('Session not available'); |
| 21 | return response.sendStatus(500); |
| 22 | } |
| 23 | |
| 24 | request.session.handle = null; |
| 25 | request.session.csrfToken = null; |
| 26 | request.session.version = null; |
| 27 | request.session = null; |
| 28 | return response.sendStatus(204); |
| 29 | } catch (error) { |
| 30 | console.error(error); |
| 31 | return response.sendStatus(500); |
| 32 | } |
| 33 | }); |
| 34 | |
| 35 | router.get('/me', async (request, response) => { |
| 36 | try { |
| 37 | if (!request.user) { |
| 38 | return response.sendStatus(403); |
| 39 | } |
| 40 | |
| 41 | const user = request.user.profile; |
| 42 | const viewModel = { |
| 43 | handle: user.handle, |
| 44 | name: user.name, |
| 45 | avatar: await getUserAvatar(user.handle), |
| 46 | admin: user.admin, |
| 47 | password: !!user.password, |
| 48 | created: user.created, |
| 49 | }; |
| 50 | |
| 51 | return response.json(viewModel); |
| 52 | } catch (error) { |
| 53 | console.error(error); |
| 54 | return response.sendStatus(500); |
| 55 | } |
| 56 | }); |
| 57 | |
| 58 | router.post('/change-avatar', async (request, response) => { |
| 59 | try { |
| 60 | if (!request.body.handle) { |
| 61 | console.warn('Change avatar failed: Missing required fields'); |
| 62 | return response.status(400).json({ error: 'Missing required fields' }); |
| 63 | } |
| 64 | |
| 65 | if (request.body.handle !== request.user.profile.handle && !request.user.profile.admin) { |
| 66 | console.error('Change avatar failed: Unauthorized'); |
| 67 | return response.status(403).json({ error: 'Unauthorized' }); |
| 68 | } |
| 69 | |
| 70 | // Avatar is not a data URL or not an empty string |
| 71 | if (!request.body.avatar.startsWith('data:image/') && request.body.avatar !== '') { |
| 72 | console.warn('Change avatar failed: Invalid data URL'); |
| 73 | return response.status(400).json({ error: 'Invalid data URL' }); |
| 74 | } |
| 75 | |
| 76 | /** @type {import('../users.js').User} */ |
| 77 | const user = await storage.getItem(toKey(request.body.handle)); |
| 78 | |
| 79 | if (!user) { |
| 80 | console.error('Change avatar failed: User not found'); |
| 81 | return response.status(404).json({ error: 'User not found' }); |
| 82 | } |
| 83 | |
| 84 | await storage.setItem(toAvatarKey(request.body.handle), request.body.avatar); |
| 85 | |
| 86 | return response.sendStatus(204); |
| 87 | } catch (error) { |
| 88 | console.error(error); |
| 89 | return response.sendStatus(500); |
| 90 | } |
| 91 | }); |
| 92 | |
| 93 | router.post('/change-password', async (request, response) => { |
| 94 | try { |
| 95 | if (!request.body.handle) { |
| 96 | console.warn('Change password failed: Missing required fields'); |
| 97 | return response.status(400).json({ error: 'Missing required fields' }); |
| 98 | } |
| 99 | |
| 100 | if (request.body.handle !== request.user.profile.handle && !request.user.profile.admin) { |
| 101 | console.error('Change password failed: Unauthorized'); |
| 102 | return response.status(403).json({ error: 'Unauthorized' }); |
| 103 | } |
| 104 | |
| 105 | /** @type {import('../users.js').User} */ |
| 106 | const user = await storage.getItem(toKey(request.body.handle)); |
| 107 | |
| 108 | if (!user) { |
| 109 | console.error('Change password failed: User not found'); |
| 110 | return response.status(404).json({ error: 'User not found' }); |
| 111 | } |
| 112 | |
| 113 | if (!user.enabled) { |
| 114 | console.error('Change password failed: User is disabled'); |
| 115 | return response.status(403).json({ error: 'User is disabled' }); |
| 116 | } |
| 117 | |
| 118 | if (!request.user.profile.admin && user.password && user.password !== getPasswordHash(request.body.oldPassword, user.salt)) { |
| 119 | console.error('Change password failed: Incorrect password'); |
| 120 | return response.status(403).json({ error: 'Incorrect password' }); |
| 121 | } |
| 122 | |
| 123 | if (request.body.newPassword) { |
| 124 | const salt = getPasswordSalt(); |
| 125 | user.password = getPasswordHash(request.body.newPassword, salt); |
| 126 | user.salt = salt; |
| 127 | } else { |
| 128 | user.password = ''; |
| 129 | user.salt = ''; |
| 130 | } |
| 131 | |
| 132 | await storage.setItem(toKey(request.body.handle), user); |
| 133 | |
| 134 | // Update session version to keep the current session valid after password change |
| 135 | if (request.session && request.session.handle === user.handle) { |
| 136 | request.session.version = getAccountVersion(user); |
| 137 | } |
| 138 | |
| 139 | return response.sendStatus(204); |
| 140 | } catch (error) { |
| 141 | console.error(error); |
| 142 | return response.sendStatus(500); |
| 143 | } |
| 144 | }); |
| 145 | |
| 146 | router.post('/backup', async (request, response) => { |
| 147 | try { |
| 148 | const allowFullDataBackup = !!getConfigValue('backups.allowFullDataBackup', true, 'boolean'); |
| 149 | |
| 150 | if (!allowFullDataBackup) { |
| 151 | console.warn('Backup failed: Full data backup is disabled in configuration'); |
| 152 | return response.status(403).json({ error: 'Full data backup is disabled' }); |
| 153 | } |
| 154 | |
| 155 | const handle = request.body.handle; |
| 156 | |
| 157 | if (!handle) { |
| 158 | console.warn('Backup failed: Missing required fields'); |
| 159 | return response.status(400).json({ error: 'Missing required fields' }); |
| 160 | } |
| 161 | |
| 162 | if (handle !== request.user.profile.handle && !request.user.profile.admin) { |
| 163 | console.error('Backup failed: Unauthorized'); |
| 164 | return response.status(403).json({ error: 'Unauthorized' }); |
| 165 | } |
| 166 | |
| 167 | await createBackupArchive(handle, response); |
| 168 | } catch (error) { |
| 169 | console.error('Backup failed', error); |
| 170 | return response.sendStatus(500); |
| 171 | } |
| 172 | }); |
| 173 | |
| 174 | router.post('/reset-settings', async (request, response) => { |
| 175 | try { |
| 176 | const password = request.body.password; |
| 177 | |
| 178 | if (request.user.profile.password && request.user.profile.password !== getPasswordHash(password, request.user.profile.salt)) { |
| 179 | console.warn('Reset settings failed: Incorrect password'); |
| 180 | return response.status(403).json({ error: 'Incorrect password' }); |
| 181 | } |
| 182 | |
| 183 | const pathToFile = path.join(request.user.directories.root, SETTINGS_FILE); |
| 184 | await fsPromises.rm(pathToFile, { force: true }); |
| 185 | await checkForNewContent([request.user.directories], [CONTENT_TYPES.SETTINGS]); |
| 186 | |
| 187 | return response.sendStatus(204); |
| 188 | } catch (error) { |
| 189 | console.error('Reset settings failed', error); |
| 190 | return response.sendStatus(500); |
| 191 | } |
| 192 | }); |
| 193 | |
| 194 | router.post('/change-name', async (request, response) => { |
| 195 | try { |
| 196 | if (!request.body.name || !request.body.handle) { |
| 197 | console.warn('Change name failed: Missing required fields'); |
| 198 | return response.status(400).json({ error: 'Missing required fields' }); |
| 199 | } |
| 200 | |
| 201 | if (request.body.handle !== request.user.profile.handle && !request.user.profile.admin) { |
| 202 | console.error('Change name failed: Unauthorized'); |
| 203 | return response.status(403).json({ error: 'Unauthorized' }); |
| 204 | } |
| 205 | |
| 206 | /** @type {import('../users.js').User} */ |
| 207 | const user = await storage.getItem(toKey(request.body.handle)); |
| 208 | |
| 209 | if (!user) { |
| 210 | console.warn('Change name failed: User not found'); |
| 211 | return response.status(404).json({ error: 'User not found' }); |
| 212 | } |
| 213 | |
| 214 | user.name = request.body.name; |
| 215 | await storage.setItem(toKey(request.body.handle), user); |
| 216 | |
| 217 | return response.sendStatus(204); |
| 218 | } catch (error) { |
| 219 | console.error('Change name failed', error); |
| 220 | return response.sendStatus(500); |
| 221 | } |
| 222 | }); |
| 223 | |
| 224 | router.post('/reset-step1', async (request, response) => { |
| 225 | try { |
| 226 | const resetCode = String(crypto.randomInt(1000, 9999)); |
| 227 | console.log(); |
| 228 | console.log(color.magenta(`${request.user.profile.name}, your account reset code is: `) + color.red(resetCode)); |
| 229 | console.log(); |
| 230 | RESET_CACHE.set(request.user.profile.handle, resetCode); |
| 231 | return response.sendStatus(204); |
| 232 | } catch (error) { |
| 233 | console.error('Recover step 1 failed:', error); |
| 234 | return response.sendStatus(500); |
| 235 | } |
| 236 | }); |
| 237 | |
| 238 | router.post('/reset-step2', async (request, response) => { |
| 239 | try { |
| 240 | if (!request.body.code) { |
| 241 | console.warn('Recover step 2 failed: Missing required fields'); |
| 242 | return response.status(400).json({ error: 'Missing required fields' }); |
| 243 | } |
| 244 | |
| 245 | if (request.user.profile.password && request.user.profile.password !== getPasswordHash(request.body.password, request.user.profile.salt)) { |
| 246 | console.warn('Recover step 2 failed: Incorrect password'); |
| 247 | return response.status(400).json({ error: 'Incorrect password' }); |
| 248 | } |
| 249 | |
| 250 | const code = RESET_CACHE.get(request.user.profile.handle); |
| 251 | |
| 252 | if (!code || code !== request.body.code) { |
| 253 | console.warn('Recover step 2 failed: Incorrect code'); |
| 254 | return response.status(400).json({ error: 'Incorrect code' }); |
| 255 | } |
| 256 | |
| 257 | console.info('Resetting account data:', request.user.profile.handle); |
| 258 | await fsPromises.rm(request.user.directories.root, { recursive: true, force: true }); |
| 259 | |
| 260 | await ensurePublicDirectoriesExist(); |
| 261 | await checkForNewContent([request.user.directories]); |
| 262 | |
| 263 | RESET_CACHE.remove(request.user.profile.handle); |
| 264 | return response.sendStatus(204); |
| 265 | } catch (error) { |
| 266 | console.error('Recover step 2 failed:', error); |
| 267 | return response.sendStatus(500); |
| 268 | } |
| 269 | }); |