596 lines
16 KiB
JavaScript
596 lines
16 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`
|
|
);
|
|
const { rows: users } = await query(
|
|
`SELECT id, username, is_admin, is_approved, created_at
|
|
FROM users
|
|
ORDER BY created_at DESC`
|
|
);
|
|
res.render('admin', { title: 'Admin', pendingUsers, users });
|
|
} 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.post('/admin/users/:id/reset-password', requireAdmin, async (req, res, next) => {
|
|
const newPassword = req.body.password?.trim();
|
|
if (!newPassword) {
|
|
req.session.flash = { type: 'danger', message: 'Password cannot be empty.' };
|
|
return res.redirect('/admin');
|
|
}
|
|
|
|
try {
|
|
const passwordHash = await bcrypt.hash(newPassword, 12);
|
|
await query('UPDATE users SET password_hash = $1 WHERE id = $2', [
|
|
passwordHash,
|
|
req.params.id,
|
|
]);
|
|
req.session.flash = { type: 'success', message: 'Password reset.' };
|
|
return res.redirect('/admin');
|
|
} catch (err) {
|
|
return 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);
|
|
});
|