pedal/server.js
2026-01-03 15:11:11 -05:00

571 lines
15 KiB
JavaScript

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);
});