const path = require('path'); const fs = require('fs/promises'); const crypto = require('crypto'); const express = require('express'); const session = require('express-session'); const bcrypt = require('bcrypt'); const multer = require('multer'); const sharp = require('sharp'); require('dotenv').config(); const { query } = require('./db'); const app = express(); const PORT = process.env.PORT || 3000; const uploadDir = path.join(__dirname, 'uploads'); const allowedMimeTypes = new Set([ 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/heic', 'image/heif', 'image/heic-sequence', 'image/heif-sequence', ]); const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 }, fileFilter: (req, file, cb) => { if (!allowedMimeTypes.has(file.mimetype)) { return cb(new Error('Unsupported file type.')); } return cb(null, true); }, }); app.set('view engine', 'ejs'); app.set('views', path.join(__dirname, 'views')); app.use(express.urlencoded({ extended: true })); app.use(express.static(path.join(__dirname, 'public'))); app.use('/uploads', express.static(uploadDir)); app.use('/android', express.static(path.join(__dirname, 'android'))); app.use('/ios', express.static(path.join(__dirname, 'ios'))); app.use( session({ secret: process.env.SESSION_SECRET || 'dev-secret', resave: false, saveUninitialized: false, cookie: { maxAge: 1000 * 60 * 60 * 24 * 365 }, }) ); app.use((req, res, next) => { res.locals.currentUser = req.session.user || null; res.locals.flash = req.session.flash || null; delete req.session.flash; next(); }); function requireAuth(req, res, next) { if (!req.session.user) { return res.redirect('/login'); } return next(); } function requireApproved(req, res, next) { if (!req.session.user) { return res.redirect('/login'); } if (!req.session.user.is_approved) { return res.redirect('/pending'); } return next(); } function requireAdmin(req, res, next) { if (!req.session.user) { return res.redirect('/login'); } if (!req.session.user.is_admin) { return res.status(403).render('error', { title: 'Not authorized', message: 'You do not have access to this page.', }); } return next(); } app.get('/', async (req, res, next) => { try { let pads = []; if (req.session.user) { const { rows } = await query( `SELECT p.id, p.name, p.brand, p.description, r.fit, r.comfort, r.absorbency FROM pads p LEFT JOIN reviews r ON r.pad_id = p.id AND r.user_id = p.user_id WHERE p.user_id = $1 ORDER BY p.name`, [req.session.user.id] ); pads = rows; } res.render('index', { title: 'Pedal', pads }); } catch (err) { next(err); } }); app.get('/pads/new', requireApproved, (req, res) => { res.render('new-pad', { title: 'Add a pad' }); }); app.post('/pads', requireApproved, async (req, res, next) => { const name = req.body.name?.trim(); const brand = req.body.brand?.trim() || null; const description = req.body.description?.trim() || null; if (!name) { req.session.flash = { type: 'danger', message: 'Pad name is required.' }; return res.redirect('/pads/new'); } try { const { rows } = await query( `INSERT INTO pads (name, brand, description, user_id) VALUES ($1, $2, $3, $4) RETURNING id`, [name, brand, description, req.session.user.id] ); req.session.flash = { type: 'success', message: 'Pad added.' }; return res.redirect(`/pads/${rows[0].id}`); } catch (err) { if (err.code === '23505') { req.session.flash = { type: 'danger', message: 'That pad already exists.' }; return res.redirect('/pads/new'); } return next(err); } }); app.get('/register', (req, res) => { res.render('register', { title: 'Create account' }); }); app.post('/register', async (req, res, next) => { const { username, password } = req.body; if (!username || !password) { req.session.flash = { type: 'danger', message: 'Username and password required.' }; return res.redirect('/register'); } try { const existing = await query('SELECT id FROM users WHERE username = $1', [ username, ]); if (existing.rows.length > 0) { req.session.flash = { type: 'danger', message: 'Username already taken.' }; return res.redirect('/register'); } const passwordHash = await bcrypt.hash(password, 12); const { rows } = await query( `INSERT INTO users (username, password_hash) VALUES ($1, $2) RETURNING id, username, is_admin, is_approved`, [username, passwordHash] ); req.session.user = rows[0]; req.session.flash = { type: 'info', message: 'Account created. Awaiting admin approval.', }; return res.redirect('/pending'); } catch (err) { return next(err); } }); app.get('/login', (req, res) => { res.render('login', { title: 'Sign in' }); }); app.post('/login', async (req, res, next) => { const { username, password } = req.body; try { const { rows } = await query( 'SELECT id, username, password_hash, is_admin, is_approved FROM users WHERE username = $1', [username] ); if (rows.length === 0) { req.session.flash = { type: 'danger', message: 'Invalid credentials.' }; return res.redirect('/login'); } const user = rows[0]; const valid = await bcrypt.compare(password, user.password_hash); if (!valid) { req.session.flash = { type: 'danger', message: 'Invalid credentials.' }; return res.redirect('/login'); } req.session.user = { id: user.id, username: user.username, is_admin: user.is_admin, is_approved: user.is_approved, }; if (!user.is_approved) { req.session.flash = { type: 'warning', message: 'Your account is pending admin approval.', }; return res.redirect('/pending'); } return res.redirect('/'); } catch (err) { return next(err); } }); app.post('/logout', (req, res) => { req.session.destroy(() => { res.redirect('/'); }); }); app.get('/pending', requireAuth, (req, res) => { res.render('pending', { title: 'Approval pending' }); }); app.get('/pads/:id', requireAuth, async (req, res, next) => { try { const padId = Number.parseInt(req.params.id, 10); if (Number.isNaN(padId)) { return res.status(404).render('error', { title: 'Not found', message: 'Pad not found.', }); } const { rows: padRows } = await query( 'SELECT id, name, brand, description FROM pads WHERE id = $1 AND user_id = $2', [padId, req.session.user.id] ); if (padRows.length === 0) { return res.status(404).render('error', { title: 'Not found', message: 'Pad not found.', }); } const { rows: reviewRows } = await query( `SELECT id, fit, comfort, absorbency, notes, created_at, updated_at FROM reviews WHERE pad_id = $1 AND user_id = $2`, [padId, req.session.user.id] ); const { rows: photos } = await query( `SELECT id, filename, original_name, created_at FROM photos WHERE pad_id = $1 AND user_id = $2 ORDER BY created_at DESC`, [padId, req.session.user.id] ); return res.render('pad', { title: padRows[0].name, pad: padRows[0], review: reviewRows[0] || null, photos, }); } catch (err) { return next(err); } }); app.post('/pads/:id/delete', requireApproved, async (req, res, next) => { try { const padId = Number.parseInt(req.params.id, 10); if (Number.isNaN(padId)) { return res.status(404).render('error', { title: 'Not found', message: 'Pad not found.', }); } const { rows: padRows } = await query( 'SELECT id FROM pads WHERE id = $1 AND user_id = $2', [padId, req.session.user.id] ); if (padRows.length === 0) { return res.status(404).render('error', { title: 'Not found', message: 'Pad not found.', }); } const { rows: photoRows } = await query( 'SELECT filename FROM photos WHERE pad_id = $1 AND user_id = $2', [padId, req.session.user.id] ); await query('DELETE FROM pads WHERE id = $1 AND user_id = $2', [ padId, req.session.user.id, ]); for (const photo of photoRows) { try { await fs.unlink(path.join(uploadDir, photo.filename)); } catch (err) { if (err.code !== 'ENOENT') { throw err; } } } req.session.flash = { type: 'success', message: 'Pad deleted.' }; return res.redirect('/'); } catch (err) { return next(err); } }); app.post('/pads/:id/reviews', requireApproved, async (req, res, next) => { const padId = Number.parseInt(req.params.id, 10); const fit = Number.parseInt(req.body.fit, 10); const comfort = Number.parseInt(req.body.comfort, 10); const absorbency = Number.parseInt(req.body.absorbency, 10); const notes = req.body.notes?.trim() || null; if ([fit, comfort, absorbency].some((value) => Number.isNaN(value))) { req.session.flash = { type: 'danger', message: 'Please score all rating fields.' }; return res.redirect(`/pads/${padId}`); } if ([fit, comfort, absorbency].some((value) => value < 1 || value > 5)) { req.session.flash = { type: 'danger', message: 'Ratings must be between 1 and 5.' }; return res.redirect(`/pads/${padId}`); } try { const { rows: padRows } = await query( 'SELECT id FROM pads WHERE id = $1 AND user_id = $2', [padId, req.session.user.id] ); if (padRows.length === 0) { return res.status(404).render('error', { title: 'Not found', message: 'Pad not found.', }); } await query( `INSERT INTO reviews (user_id, pad_id, fit, comfort, absorbency, notes) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (user_id, pad_id) DO UPDATE SET fit = EXCLUDED.fit, comfort = EXCLUDED.comfort, absorbency = EXCLUDED.absorbency, notes = EXCLUDED.notes, updated_at = NOW()`, [req.session.user.id, padId, fit, comfort, absorbency, notes] ); req.session.flash = { type: 'success', message: 'Review saved.' }; return res.redirect(`/pads/${padId}`); } catch (err) { return next(err); } }); app.post( '/pads/:id/photos', requireApproved, (req, res, next) => { upload.single('photo')(req, res, (err) => { if (err) { req.session.flash = { type: 'danger', message: err.message }; return res.redirect(`/pads/${req.params.id}`); } return next(); }); }, async (req, res, next) => { const padId = Number.parseInt(req.params.id, 10); if (!req.file) { req.session.flash = { type: 'danger', message: 'Please select a photo to upload.' }; return res.redirect(`/pads/${padId}`); } try { const { rows: padRows } = await query( 'SELECT id FROM pads WHERE id = $1 AND user_id = $2', [padId, req.session.user.id] ); if (padRows.length === 0) { return res.status(404).render('error', { title: 'Not found', message: 'Pad not found.', }); } const filename = `${crypto.randomUUID()}.webp`; const outputPath = path.join(uploadDir, filename); await sharp(req.file.buffer) .rotate() .resize(1600, 1600, { fit: 'inside', withoutEnlargement: true }) .webp({ quality: 80 }) .toFile(outputPath); await query( `INSERT INTO photos (user_id, pad_id, filename, original_name) VALUES ($1, $2, $3, $4)`, [req.session.user.id, padId, filename, req.file.originalname] ); req.session.flash = { type: 'success', message: 'Photo uploaded.' }; return res.redirect(`/pads/${padId}`); } catch (err) { return next(err); } } ); app.post( '/pads/:padId/photos/:photoId/delete', requireApproved, async (req, res, next) => { try { const padId = Number.parseInt(req.params.padId, 10); const photoId = Number.parseInt(req.params.photoId, 10); if (Number.isNaN(padId) || Number.isNaN(photoId)) { return res.status(404).render('error', { title: 'Not found', message: 'Photo not found.', }); } const { rows } = await query( 'SELECT filename FROM photos WHERE id = $1 AND pad_id = $2 AND user_id = $3', [photoId, padId, req.session.user.id] ); if (rows.length === 0) { return res.status(404).render('error', { title: 'Not found', message: 'Photo not found.', }); } await query('DELETE FROM photos WHERE id = $1 AND pad_id = $2 AND user_id = $3', [ photoId, padId, req.session.user.id, ]); try { await fs.unlink(path.join(uploadDir, rows[0].filename)); } catch (err) { if (err.code !== 'ENOENT') { throw err; } } req.session.flash = { type: 'success', message: 'Photo deleted.' }; return res.redirect(`/pads/${padId}`); } catch (err) { return next(err); } } ); app.get('/admin', requireAdmin, async (req, res, next) => { try { const { rows: pendingUsers } = await query( `SELECT id, username, created_at FROM users WHERE is_approved = FALSE ORDER BY created_at ASC` ); res.render('admin', { title: 'Admin approvals', pendingUsers }); } catch (err) { next(err); } }); app.post('/admin/users/:id/approve', requireAdmin, async (req, res, next) => { try { await query('UPDATE users SET is_approved = TRUE WHERE id = $1', [ req.params.id, ]); req.session.flash = { type: 'success', message: 'User approved.' }; res.redirect('/admin'); } catch (err) { next(err); } }); app.use((err, req, res, next) => { console.error(err); res.status(500).render('error', { title: 'Something went wrong', message: 'Please try again or check the logs.', }); }); async function ensureAdmin() { const adminUsername = process.env.ADMIN_USERNAME; const adminPassword = process.env.ADMIN_PASSWORD; if (!adminUsername || !adminPassword) { return; } const { rows } = await query('SELECT id FROM users WHERE username = $1', [ adminUsername, ]); if (rows.length > 0) { return; } const passwordHash = await bcrypt.hash(adminPassword, 12); await query( `INSERT INTO users (username, password_hash, is_admin, is_approved) VALUES ($1, $2, TRUE, TRUE)`, [adminUsername, passwordHash] ); } async function waitForDatabase(retries = 12) { const delayMs = 1000; for (let attempt = 1; attempt <= retries; attempt += 1) { try { await query('SELECT 1'); return; } catch (err) { if (attempt === retries) { throw err; } await new Promise((resolve) => setTimeout(resolve, delayMs)); } } } async function start() { await fs.mkdir(uploadDir, { recursive: true }); await waitForDatabase(); await ensureAdmin(); app.listen(PORT, () => { console.log(`Pedal running on http://localhost:${PORT}`); }); } start().catch((err) => { console.error(err); process.exit(1); });