Files
TicketSupport/index.js
T

518 lines
21 KiB
JavaScript
Raw Normal View History

2026-05-01 19:31:44 +02:00
require('dotenv').config({ quiet: true });
const path = require('path');
const express = require('express');
const session = require('express-session');
const methodOverride = require('method-override');
const morgan = require('morgan');
const passport = require('passport');
const LocalStrategy = require('passport-local').Strategy;
const GoogleStrategy = require('passport-google-oauth20').Strategy;
const bcrypt = require('bcryptjs');
const crypto = require('crypto');
const { db, initDb } = require('./server/db');
const expressLayouts = require('express-ejs-layouts');
const { sendVerificationEmail } = require('./server/mailer');
const app = express();
const PORT = process.env.PORT || 3000;
const SESSION_SECRET = process.env.SESSION_SECRET || 'dev_secret_change_me';
const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID || '';
const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET || '';
const GOOGLE_CALLBACK_URL = process.env.GOOGLE_CALLBACK_URL || `http://localhost:${PORT}/auth/google/callback`;
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
app.use('/public', express.static(path.join(__dirname, 'public')));
app.use(expressLayouts);
app.set('layout', 'layout');
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.use(methodOverride('_method'));
app.use(morgan('dev'));
app.use(
session({
secret: SESSION_SECRET,
resave: false,
saveUninitialized: false,
})
);
app.use(passport.initialize());
app.use(passport.session());
passport.serializeUser((user, done) => {
done(null, { id: user.id, email: user.email, role: user.role, name: user.name });
});
passport.deserializeUser((obj, done) => done(null, obj));
passport.use(
new LocalStrategy(
{ usernameField: 'email', passwordField: 'password' },
(email, password, done) => {
const normEmail = email.toLowerCase();
db.get('SELECT * FROM users WHERE email = ?', [normEmail], async (err, user) => {
if (err) return done(err);
if (!user || !user.password_hash) return done(null, false, { message: 'Invalid credentials' });
if (user.banned) return done(null, false, { message: 'Account is banned' });
const ok = await bcrypt.compare(password, user.password_hash);
if (!ok) return done(null, false, { message: 'Invalid credentials' });
if (!user.email_verified) return done(null, false, { reason: 'unverified', email: normEmail });
return done(null, user);
});
}
)
);
if (GOOGLE_CLIENT_ID && GOOGLE_CLIENT_SECRET) {
passport.use(
new GoogleStrategy(
{
clientID: GOOGLE_CLIENT_ID,
clientSecret: GOOGLE_CLIENT_SECRET,
callbackURL: GOOGLE_CALLBACK_URL,
},
(accessToken, refreshToken, profile, done) => {
try {
const googleId = profile.id;
const rawEmail = (profile.emails && profile.emails[0] && profile.emails[0].value) || null;
const email = rawEmail ? rawEmail.toLowerCase() : null;
const name = profile.displayName || (profile.name ? `${profile.name.givenName || ''} ${profile.name.familyName || ''}`.trim() : (email || 'Google User'));
db.get('SELECT * FROM users WHERE google_id = ? OR (email IS NOT NULL AND email = ?)', [googleId, email], (err, user) => {
if (err) return done(err);
if (user) {
if (user.banned) return done(null, false, { message: 'Account is banned' });
if (!user.google_id) {
db.run('UPDATE users SET google_id = ?, email_verified = 1 WHERE id = ?', [googleId, user.id], (uErr) => done(uErr, { ...user, google_id: googleId, email_verified: 1 }));
} else {
if (!user.email_verified) {
db.run('UPDATE users SET email_verified = 1 WHERE id = ?', [user.id], (uErr) => done(uErr, { ...user, email_verified: 1 }));
} else {
return done(null, user);
}
}
} else {
db.run(
'INSERT INTO users (email, name, google_id, role, email_verified, created_at) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)',
[email, name, googleId, 'user', 1],
function (insErr) {
if (insErr) return done(insErr);
db.get('SELECT * FROM users WHERE id = ?', [this.lastID], (selErr, newUser) => done(selErr, newUser));
}
);
}
});
} catch (e) {
return done(e);
}
}
)
);
}
function ensureAuth(req, res, next) {
if (req.isAuthenticated()) {
db.get('SELECT banned FROM users WHERE id = ?', [req.user.id], (e, row) => {
if (e || !row) return res.redirect('/logout');
if (row.banned) {
req.logout(() => {
req.session.message = 'Your account has been banned.';
return res.redirect('/login');
});
} else {
return next();
}
});
return;
}
res.redirect('/login');
}
function ensureAdmin(req, res, next) {
if (!req.isAuthenticated()) return res.status(403).send('Forbidden');
db.get('SELECT role, banned FROM users WHERE id = ?', [req.user.id], (e, row) => {
if (e || !row) return res.status(403).send('Forbidden');
if (row.banned) {
req.logout(() => {
req.session.message = 'Your account has been banned.';
return res.redirect('/login');
});
return;
}
if (row.role === 'admin') return next();
return res.status(403).send('Forbidden');
});
}
app.use((req, res, next) => {
res.locals.currentUser = req.user || null;
res.locals.googleEnabled = Boolean(GOOGLE_CLIENT_ID && GOOGLE_CLIENT_SECRET);
res.locals.message = req.session.message || null;
delete req.session.message;
next();
});
app.get('/', (req, res) => {
if (req.isAuthenticated()) return res.redirect('/dashboard');
res.redirect('/login');
});
app.get('/login', (req, res) => res.render('login', { query: req.query }));
app.post('/login', (req, res, next) => {
passport.authenticate('local', (err, user, info) => {
if (err) return next(err);
if (!user) {
if (info && info.reason === 'unverified' && info.email) {
return res.redirect(`/login?unverified=1&email=${encodeURIComponent(info.email)}`);
}
return res.redirect('/login?error=1');
}
req.logIn(user, (e) => {
if (e) return next(e);
return res.redirect('/dashboard');
});
})(req, res, next);
});
app.get('/register', (req, res) => res.render('register'));
app.post('/register', async (req, res) => {
const { name, email, password } = req.body;
if (!email || !password) {
req.session.message = 'Email and password are required';
return res.redirect('/register');
}
const hash = await bcrypt.hash(password, 10);
const normEmail = email.toLowerCase();
const token = crypto.randomBytes(32).toString('hex');
const expires = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
db.run(
'INSERT INTO users (name, email, password_hash, role, email_verified, verify_token, verify_token_expires, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)',
[name || '', normEmail, hash, 'user', 0, token, expires],
async function (err) {
if (err) {
req.session.message = 'Registration failed. Email may be already in use.';
return res.redirect('/register');
}
try { await sendVerificationEmail(normEmail, token, name || ''); } catch (e) { console.error('Failed to send verification email:', e.message); }
req.session.message = 'Registration successful. Please check your email to verify your account.';
res.redirect('/login');
}
);
});
app.post('/logout', (req, res, next) => {
req.logout(err => {
if (err) return next(err);
res.redirect('/login');
});
});
if (GOOGLE_CLIENT_ID && GOOGLE_CLIENT_SECRET) {
app.get('/auth/google', passport.authenticate('google', { scope: ['profile', 'email'] }));
app.get('/auth/google/callback', passport.authenticate('google', { failureRedirect: '/login?oauth_error=1' }), (req, res) => {
res.redirect('/dashboard');
});
}
app.get('/dashboard', ensureAuth, (req, res) => {
db.all(
'SELECT * FROM tickets WHERE user_id = ? ORDER BY updated_at DESC, created_at DESC',
[req.user.id],
(err, rows) => {
if (err) rows = [];
res.render('dashboard', { tickets: rows });
}
);
});
app.get('/tickets/new', ensureAuth, (req, res) => res.render('ticket_new'));
app.post('/tickets', ensureAuth, (req, res) => {
const { subject, description } = req.body;
if (!subject || !description) {
req.session.message = 'Subject and description are required.';
return res.redirect('/tickets/new');
}
db.run(
'INSERT INTO tickets (user_id, subject, description, status, created_at, updated_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)',
[req.user.id, subject, description, 'open'],
function (err) {
if (err) {
req.session.message = 'Failed to create ticket.';
return res.redirect('/tickets/new');
}
res.redirect(`/tickets/${this.lastID}`);
}
);
});
app.get('/tickets/:id', ensureAuth, (req, res) => {
const id = req.params.id;
db.get('SELECT t.*, u.name as user_name, u.email as user_email FROM tickets t JOIN users u ON u.id = t.user_id WHERE t.id = ?', [id], (err, ticket) => {
if (err || !ticket) return res.status(404).send('Not found');
if (req.user.role !== 'admin' && ticket.user_id !== req.user.id) return res.status(403).send('Forbidden');
db.all('SELECT r.*, u.name, u.email FROM ticket_responses r JOIN users u ON u.id = r.user_id WHERE r.ticket_id = ? ORDER BY r.created_at ASC', [id], (rErr, responses) => {
if (rErr) responses = [];
res.render('ticket_view', { ticket, responses });
});
});
});
app.post('/tickets/:id/respond', ensureAuth, (req, res) => {
const id = req.params.id;
const { message } = req.body;
if (!message) return res.redirect(`/tickets/${id}`);
db.get('SELECT * FROM tickets WHERE id = ?', [id], (err, ticket) => {
if (err || !ticket) return res.status(404).send('Not found');
if (req.user.role !== 'admin' && ticket.user_id !== req.user.id) return res.status(403).send('Forbidden');
db.run(
'INSERT INTO ticket_responses (ticket_id, user_id, message, is_admin_response, created_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)',
[id, req.user.id, message, req.user.role === 'admin' ? 1 : 0],
function (rErr) {
if (rErr) return res.status(500).send('Failed to add response');
db.run('UPDATE tickets SET updated_at = CURRENT_TIMESTAMP, status = ? WHERE id = ?', [req.user.role === 'admin' ? 'answered' : 'open', id], () => {
res.redirect(`/tickets/${id}`);
});
}
);
});
});
app.get('/admin', ensureAdmin, (req, res) => {
const status = req.query.status;
const params = [];
let sql = 'SELECT t.*, u.email as user_email, u.name as user_name FROM tickets t JOIN users u ON u.id = t.user_id';
if (status) {
sql += ' WHERE t.status = ?';
params.push(status);
}
sql += ' ORDER BY updated_at DESC, created_at DESC';
db.all(sql, params, (err, rows) => {
if (err) rows = [];
res.render('admin', { tickets: rows, status: status || '' });
});
});
app.post('/admin/tickets/:id/status', ensureAdmin, (req, res) => {
const { status } = req.body;
const id = req.params.id;
db.run('UPDATE tickets SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [status, id], (err) => {
if (err) req.session.message = 'Failed to update status';
res.redirect('/admin');
});
});
app.post('/admin/tickets/:id/delete', ensureAdmin, (req, res) => {
const id = req.params.id;
db.run('DELETE FROM tickets WHERE id = ?', [id], (err) => {
req.session.message = err ? 'Failed to delete ticket.' : 'Ticket deleted.';
res.redirect('/admin');
});
});
app.get('/admin/users', ensureAdmin, (req, res) => {
db.all(
`SELECT
id, name, email, role, created_at, banned, email_verified,
CASE WHEN google_id IS NOT NULL AND TRIM(google_id) <> '' THEN 1 ELSE 0 END AS has_google,
CASE WHEN password_hash IS NOT NULL AND TRIM(password_hash) <> '' THEN 1 ELSE 0 END AS has_password
FROM users
ORDER BY created_at DESC, id DESC`,
[],
(err, users) => {
if (err) users = [];
res.render('admin_users', { users });
}
);
});
app.post('/admin/users/:id/make-admin', ensureAdmin, (req, res) => {
const id = req.params.id;
db.run('UPDATE users SET role = ? WHERE id = ?', ['admin', id], (err) => {
req.session.message = err ? 'Failed to grant admin rights.' : 'Admin rights granted.';
res.redirect('/admin/users');
});
});
app.post('/admin/users/:id/unadmin', ensureAdmin, (req, res) => {
const id = parseInt(req.params.id, 10);
if (Number.isNaN(id)) {
req.session.message = 'Invalid user id.';
return res.redirect('/admin/users');
}
if (req.user && req.user.id === id) {
req.session.message = 'You cannot remove your own admin role.';
return res.redirect('/admin/users');
}
db.get('SELECT COUNT(*) AS cnt FROM users WHERE role = ?', ['admin'], (err, row) => {
if (err) {
req.session.message = 'Database error.';
return res.redirect('/admin/users');
}
const adminCount = row ? row.cnt : 0;
db.get('SELECT id, email, role FROM users WHERE id = ?', [id], (e2, user) => {
if (e2 || !user) {
req.session.message = 'User not found.';
return res.redirect('/admin/users');
}
if (user.role !== 'admin') {
req.session.message = 'User is not an admin.';
return res.redirect('/admin/users');
}
if (adminCount <= 1) {
req.session.message = 'Cannot remove the last admin.';
return res.redirect('/admin/users');
}
db.run('UPDATE users SET role = ? WHERE id = ?', ['user', id], (uErr) => {
req.session.message = uErr ? 'Failed to remove admin rights.' : `Admin rights removed from ${user.email || 'user'}.`;
return res.redirect('/admin/users');
});
});
});
});
app.post('/admin/users/grant-admin', ensureAdmin, (req, res) => {
let { email, name } = req.body;
email = (email || '').trim().toLowerCase();
name = (name || '').trim();
if (!email) {
req.session.message = 'Email is required.';
return res.redirect('/admin/users');
}
db.get('SELECT * FROM users WHERE email = ?', [email], (err, user) => {
if (err) {
req.session.message = 'Database error.';
return res.redirect('/admin/users');
}
if (user) {
db.run('UPDATE users SET role = ? WHERE id = ?', ['admin', user.id], (uErr) => {
req.session.message = uErr ? 'Failed to grant admin rights.' : `Admin rights granted to ${email}.`;
return res.redirect('/admin/users');
});
} else {
db.run(
'INSERT INTO users (name, email, role, created_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)',
[name, email, 'admin'],
(iErr) => {
req.session.message = iErr ? 'Failed to create admin user.' : `Admin user created for ${email}.`;
return res.redirect('/admin/users');
}
);
}
});
});
app.post('/admin/users/:id/ban', ensureAdmin, (req, res) => {
const id = parseInt(req.params.id, 10);
if (Number.isNaN(id)) { req.session.message = 'Invalid user id.'; return res.redirect('/admin/users'); }
if (req.user && req.user.id === id) { req.session.message = 'You cannot ban yourself.'; return res.redirect('/admin/users'); }
db.get('SELECT id, email, role FROM users WHERE id = ?', [id], (e, user) => {
if (e || !user) { req.session.message = 'User not found.'; return res.redirect('/admin/users'); }
if (user.role === 'admin') {
db.get('SELECT COUNT(*) AS cnt FROM users WHERE role = ?', ['admin'], (err, row) => {
const adminCount = row ? row.cnt : 0;
if (adminCount <= 1) { req.session.message = 'Cannot ban the last admin.'; return res.redirect('/admin/users'); }
db.run('UPDATE users SET banned = 1 WHERE id = ?', [id], (uErr) => {
req.session.message = uErr ? 'Failed to ban user.' : `User ${user.email || ''} banned.`;
return res.redirect('/admin/users');
});
});
} else {
db.run('UPDATE users SET banned = 1 WHERE id = ?', [id], (uErr) => {
req.session.message = uErr ? 'Failed to ban user.' : `User ${user.email || ''} banned.`;
return res.redirect('/admin/users');
});
}
});
});
app.post('/admin/users/:id/unban', ensureAdmin, (req, res) => {
const id = parseInt(req.params.id, 10);
if (Number.isNaN(id)) { req.session.message = 'Invalid user id.'; return res.redirect('/admin/users'); }
db.run('UPDATE users SET banned = 0 WHERE id = ?', [id], (uErr) => {
req.session.message = uErr ? 'Failed to unban user.' : 'User unbanned.';
return res.redirect('/admin/users');
});
});
app.post('/admin/users/:id/delete', ensureAdmin, (req, res) => {
const id = parseInt(req.params.id, 10);
if (Number.isNaN(id)) { req.session.message = 'Invalid user id.'; return res.redirect('/admin/users'); }
if (req.user && req.user.id === id) { req.session.message = 'You cannot delete yourself.'; return res.redirect('/admin/users'); }
db.get('SELECT id, email, role FROM users WHERE id = ?', [id], (e, user) => {
if (e || !user) { req.session.message = 'User not found.'; return res.redirect('/admin/users'); }
if (user.role === 'admin') {
db.get('SELECT COUNT(*) AS cnt FROM users WHERE role = ?', ['admin'], (err, row) => {
const adminCount = row ? row.cnt : 0;
if (adminCount <= 1) { req.session.message = 'Cannot delete the last admin.'; return res.redirect('/admin/users'); }
db.run('DELETE FROM users WHERE id = ?', [id], (dErr) => {
req.session.message = dErr ? 'Failed to delete user.' : `User ${user.email || ''} deleted.`;
return res.redirect('/admin/users');
});
});
} else {
db.run('DELETE FROM users WHERE id = ?', [id], (dErr) => {
req.session.message = dErr ? 'Failed to delete user.' : `User ${user.email || ''} deleted.`;
return res.redirect('/admin/users');
});
}
});
});
initDb(() => {
const adminEmail = process.env.ADMIN_EMAIL || 'admin@example.com';
const adminPass = process.env.ADMIN_PASSWORD || 'admin123';
db.get('SELECT * FROM users WHERE email = ?', [adminEmail], async (err, user) => {
if (!user) {
const hash = await bcrypt.hash(adminPass, 10);
db.run(
'INSERT INTO users (name, email, password_hash, role, email_verified, created_at) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)',
['Administrator', adminEmail, hash, 'admin', 1]
);
console.log(`Seeded admin user: ${adminEmail}`);
} else if (!user.email_verified) {
db.run('UPDATE users SET email_verified = 1 WHERE id = ?', [user.id]);
}
app.listen(PORT, () => console.log(`TicketSupport server running on http://localhost:${PORT}`));
});
});
app.get('/verify/:token', (req, res) => {
const { token } = req.params;
if (!token) { req.session.message = 'Invalid verification link.'; return res.redirect('/login'); }
db.get('SELECT * FROM users WHERE verify_token = ?', [token], (err, user) => {
if (err || !user) { req.session.message = 'Invalid or expired verification link.'; return res.redirect('/login'); }
if (user.email_verified) { req.session.message = 'Your email is already verified. Please log in.'; return res.redirect('/login'); }
const now = new Date();
const exp = user.verify_token_expires ? new Date(user.verify_token_expires) : null;
if (!exp || exp < now) { req.session.message = 'Verification link has expired. Please request a new one.'; return res.redirect(`/verify/resend?email=${encodeURIComponent(user.email || '')}`); }
db.run('UPDATE users SET email_verified = 1, verify_token = NULL, verify_token_expires = NULL WHERE id = ?', [user.id], (uErr) => {
req.session.message = uErr ? 'Failed to verify email. Try again.' : 'Your email has been verified. You can now log in.';
return res.redirect('/login');
});
});
});
app.get('/verify/resend', (req, res) => {
const email = (req.query && req.query.email) ? String(req.query.email) : '';
res.render('verify_resend', { email });
});
app.post('/verify/resend', (req, res) => {
let { email } = req.body;
email = (email || '').trim().toLowerCase();
if (!email) { req.session.message = 'Please enter your email.'; return res.redirect('/verify/resend'); }
db.get('SELECT * FROM users WHERE email = ?', [email], (err, user) => {
if (err) { req.session.message = 'Something went wrong.'; return res.redirect(`/verify/resend?email=${encodeURIComponent(email)}`); }
if (!user) { req.session.message = 'If an account exists, a verification email has been sent.'; return res.redirect('/login'); }
if (user.email_verified) { req.session.message = 'Your email is already verified. Please log in.'; return res.redirect('/login'); }
const token = crypto.randomBytes(32).toString('hex');
const expires = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
db.run('UPDATE users SET verify_token = ?, verify_token_expires = ? WHERE id = ?', [token, expires, user.id], async (uErr) => {
if (uErr) { req.session.message = 'Could not generate verification link. Try again later.'; return res.redirect(`/verify/resend?email=${encodeURIComponent(email)}`); }
try { await sendVerificationEmail(email, token, user.name || ''); } catch (e) { console.error('Failed to send verification email:', e.message); }
req.session.message = 'Verification email sent. Please check your inbox.'; return res.redirect('/login');
});
});
});