source: Klonkt/src/routes/account.js@ 89d6536

main
Last change on this file since 89d6536 was 7bc636b, checked in by Robin <robin@…>, 4 months ago

Initial commit — PrutFolio v1 source (pulled from Hetzner /srv/prutfolio)

  • Property mode set to 100644
File size: 5.8 KB
RevLine 
[7bc636b]1/**
2 * Account routes — profile, password, avatar.
3 *
4 * Sections:
5 * GET / -> render full account page (profile + password + avatar + danger)
6 * POST /profile -> update bio
7 * POST /password -> change password (verify current, hash new)
8 * POST /avatar -> upload avatar image (multer)
9 * POST /avatar/remove -> clear avatar_url
10 *
11 * Each form has its own POST handler. After success, redirects back to
12 * /account?success=... so the page picks it up via query string.
13 */
14
15import express from 'express';
16import path from 'path';
17import fs from 'fs';
18import { fileURLToPath } from 'url';
19import bcrypt from 'bcryptjs';
20import multer from 'multer';
21import { v4 as uuid } from 'uuid';
22import db from '../config/database.js';
23import { renderPage } from '../middleware/render.js';
24import { requireAuth } from '../middleware/auth.js';
25
26const __dirname = path.dirname(fileURLToPath(import.meta.url));
27const AVATAR_DIR = path.resolve(
28 process.env.AVATAR_PATH || path.join(__dirname, '..', '..', 'storage', 'media', 'avatars')
29);
30fs.mkdirSync(AVATAR_DIR, { recursive: true });
31
32const ALLOWED_AVATAR_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']);
33const MAX_AVATAR_BYTES = 5 * 1024 * 1024;
34
35const avatarStorage = multer.diskStorage({
36 destination: (req, file, cb) => cb(null, AVATAR_DIR),
37 filename: (req, file, cb) => {
38 const ext = path.extname(file.originalname).toLowerCase();
39 cb(null, `${uuid()}${ext}`);
40 },
41});
42const avatarUpload = multer({
43 storage: avatarStorage,
44 limits: { fileSize: MAX_AVATAR_BYTES },
45 fileFilter: (req, file, cb) => {
46 const ext = path.extname(file.originalname).toLowerCase();
47 if (!ALLOWED_AVATAR_EXT.has(ext)) {
48 return cb(new Error('Avatar must be jpg/png/webp/gif'));
49 }
50 cb(null, true);
51 },
52});
53
54const router = express.Router();
55
56// ==================== GET account page ====================
57router.get('/', requireAuth, (req, res) => {
58 const account = db.prepare(`
59 SELECT id, username, email, role, bio, avatar_url, created_at
60 FROM users WHERE id = ?
61 `).get(req.session.user.id);
62
63 renderPage(req, res, 'pages/account', {
64 pageTitle: 'Account',
65 bodyClass: 'on-special',
66 account,
67 success: req.query.success || null,
68 error: req.query.error || null,
69 });
70});
71
72// ==================== UPDATE BIO ====================
73router.post('/profile', requireAuth, (req, res) => {
74 const bio = (req.body.bio || '').toString().slice(0, 500).trim();
75 db.prepare('UPDATE users SET bio = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
76 .run(bio || null, req.session.user.id);
77 res.redirect('/account?success=' + encodeURIComponent('Profile updated'));
78});
79
80// P57 — /preferences route removed. Per-user theme/palette was a multi-tenant
81// holdover that conflicts with the site-default model: visitors should see
82// the site's appearance, not whatever a user once picked. The users.theme and
83// users.palette columns stay in the schema (no migration needed) but are no
84// longer read or written.
85
86// ==================== CHANGE PASSWORD ====================
87router.post('/password', requireAuth, (req, res) => {
88 const { current, new_password, confirm } = req.body;
89 if (!current || !new_password || !confirm) {
90 return res.redirect('/account?error=' + encodeURIComponent('All password fields required'));
91 }
92 if (new_password.length < 8) {
93 return res.redirect('/account?error=' + encodeURIComponent('New password must be at least 8 characters'));
94 }
95 if (new_password !== confirm) {
96 return res.redirect('/account?error=' + encodeURIComponent('New passwords do not match'));
97 }
98
99 const row = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(req.session.user.id);
100 if (!row || !bcrypt.compareSync(current, row.password_hash)) {
101 return res.redirect('/account?error=' + encodeURIComponent('Current password is incorrect'));
102 }
103
104 const newHash = bcrypt.hashSync(new_password, 10);
105 db.prepare('UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
106 .run(newHash, req.session.user.id);
107
108 res.redirect('/account?success=' + encodeURIComponent('Password changed'));
109});
110
111// ==================== UPLOAD AVATAR ====================
112router.post('/avatar', requireAuth, (req, res) => {
113 avatarUpload.single('avatar')(req, res, (err) => {
114 if (err) {
115 return res.redirect('/account?error=' + encodeURIComponent(err.message));
116 }
117 if (!req.file) {
118 return res.redirect('/account?error=' + encodeURIComponent('No file uploaded'));
119 }
120
121 const url = `/media/avatars/${req.file.filename}`;
122
123 // Remove the old avatar file (if it lives in our avatar dir)
124 const old = db.prepare('SELECT avatar_url FROM users WHERE id = ?').get(req.session.user.id)?.avatar_url;
125 if (old && old.startsWith('/media/avatars/')) {
126 const oldPath = path.join(AVATAR_DIR, path.basename(old));
127 try { fs.unlinkSync(oldPath); } catch {}
128 }
129
130 db.prepare('UPDATE users SET avatar_url = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
131 .run(url, req.session.user.id);
132 req.session.user.avatar_url = url;
133
134 res.redirect('/account?success=' + encodeURIComponent('Avatar updated'));
135 });
136});
137
138// ==================== REMOVE AVATAR ====================
139router.post('/avatar/remove', requireAuth, (req, res) => {
140 const old = db.prepare('SELECT avatar_url FROM users WHERE id = ?').get(req.session.user.id)?.avatar_url;
141 if (old && old.startsWith('/media/avatars/')) {
142 const oldPath = path.join(AVATAR_DIR, path.basename(old));
143 try { fs.unlinkSync(oldPath); } catch {}
144 }
145 db.prepare('UPDATE users SET avatar_url = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
146 .run(req.session.user.id);
147 req.session.user.avatar_url = null;
148 res.redirect('/account?success=' + encodeURIComponent('Avatar removed'));
149});
150
151export default router;
Note: See TracBrowser for help on using the repository browser.