// server/app.js import express from 'express'; import cors from 'cors'; import multer from 'multer'; import path from 'path'; import fs from 'fs'; import jwt from "jsonwebtoken"; import bcrypt from "bcryptjs"; import db from './db.js'; import AuthHandler from './middleware/AuthHandler.js'; /**@typedef {import("../client/src/DBRecord.ts").DBRecord} DBRecord */ console.log("Starting Server..."); // test db connection try{ await db.query("SELECT 1") console.log("DB connection successful") const scan = await db.query("SELECT * FROM public.scans WHERE id = 1"); console.debug(scan[0]); console.log("Scans table exists"); } catch(err){ console.error(err); process.exit(1); } const JWT_SECRET = process.env.JWT_SECRET || "super-secret-key"; const app = express(); app.use(cors()); app.use(express.json()); app.use("/", express.static('../client/dist')); app.use("/search", express.static('../client/dist')); app.use("/add", express.static('../client/dist')); app.use("/edit/:id", express.static('../client/dist')); app.use("/login", express.static('../client/dist')); app.use("/register", express.static('../client/dist')); const UPLOAD_FOLDER = path.join(process.cwd(), 'Scans'); const storage = multer.diskStorage({ destination: (req, file, cb) => { cb(null, UPLOAD_FOLDER); }, filename: (req, file, cb) => { cb(null, file.originalname); } }); const upload = multer({ storage }); // Add a new music scan app.post('/api/music-scans/add', upload.single('file'), async (req, res) => { try { const { name, composer, arranger, words, year, genre, jewish, choral, liturgical, language, instrument } = req.body; const safeName = name.replace(/[^a-zA-Z0-9_\- ]/g, '').toLowerCase() + '.pdf'; if (!safeName) res.status(400).json({ message: 'Invalid file name!' }); else { const locs = [ jewish === 'true' ? 'Jewish' : 'Non-Jewish', liturgical === 'true' ? 'Liturgical' : 'Non-Liturgical', choral === 'true' ? 'Choral' : 'Non-Choral', (genre || 'unknown').toLowerCase(), (composer || 'unknown').toLowerCase() ]; const link = path.join(...locs, safeName); const filePath = path.join(UPLOAD_FOLDER, link); fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.renameSync(req.file.path, filePath); /** @type DBRecord[] */ const newScan = await db.query( `INSERT INTO public.scans (name, composer, arranger, words, link, year, genre, jewish, choral, liturgical, language, instrument) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *`, [ name.toLowerCase(), (composer || 'unknown').toLowerCase(), (arranger || 'unknown').toLowerCase(), (words || 'unknown').toLowerCase(), link, year, genre.toLowerCase(), jewish === 'true', choral === 'true', liturgical === 'true', language.toLowerCase(), instrument.toLowerCase() ] ); if (!newScan || newScan.length === 0) res.status(500).json({ message: 'Failed to save music scan!' }); else res.json({ message: 'Music scan added successfully!', saved_at: filePath }); } } catch (err) { res.status(500).json({ message: err.message }); } }); // Get a music scan by id app.get('/api/music-scans/scan/:id', async (req, res) => { /** @type DBRecord[] */ const queryResult = await db.query('SELECT * FROM public.scans WHERE id = $1', [req.params.id]); if (!queryResult || queryResult.length === 0) res.status(404).json({ message: 'Not found' }); else if (queryResult.length > 1) res.status(500).json({ message: 'Multiple scans found with the same ID' }); else res.json(queryResult[0]); }); // Download a music scan file app.get('/api/music-scans/download/:id', AuthHandler, async (req, res) => { /** @type DBRecord[] */ const queryResult = await db.query('SELECT * FROM public.scans WHERE id = $1', [req.params.id]); if (!queryResult || queryResult.length === 0) res.status(404).json({ message: 'Not found' }); else if (queryResult.length > 1) res.status(500).json({ message: 'Multiple scans found with the same ID' }); else { const scan = queryResult[0]; const filePath = path.join(UPLOAD_FOLDER, scan.link); res.download(filePath, scan.name + '.pdf'); } }); // Update a music scan app.patch('/api/music-scans/scan/:id', AuthHandler, async (req, res) => { const queryResult = await db.query('SELECT * FROM public.scans WHERE id = $1', [req.params.id]); if (!queryResult || queryResult.length === 0) res.status(404).json({ message: 'Not found' }); else if (queryResult.length > 1) res.status(500).json({ message: 'Multiple scans found with the same ID' }); else { const data = req.body; if (!data.name || !data.link) res.status(400).json({ message: 'Name and link are required!' }); else if (data.link && !data.link.endsWith('.pdf')) res.status(400).json({ message: 'Link must end with .pdf' }); else { await db.query( `UPDATE public.scans SET name = $1, composer = $2, arranger = $3, words = $4, link = $5, year = $6, genre = $7, jewish = $8, choral = $9, liturgical = $10, language = $11, instrument = $12 WHERE id = $13`, [ data.name, data.composer || 'Unknown', data.arranger || 'Unknown', data.words || 'Unknown', data.link, data.year, data.genre, data.jewish === 'true', data.choral === 'true', data.liturgical === 'true', data.language, data.instrument, req.params.id ] ); res.json({ message: 'Music scan updated successfully!' }); } } }); // Delete a music scan app.delete('/api/music-scans/delete/:id', AuthHandler, async (req, res) => { /** @type DBRecord[] */ const queryResult = await db.query('SELECT * FROM public.scans WHERE id = $1', [req.params.id]); if (!queryResult || queryResult.length === 0) res.status(404).json({ message: 'Not found' }); else if (queryResult.length > 1) res.status(500).json({ message: 'Multiple scans found with the same ID' }); else { const scan = queryResult[0]; const filePath = path.join(UPLOAD_FOLDER, scan.link); if (fs.existsSync(filePath)) fs.unlinkSync(filePath); await db.query('DELETE FROM public.scans WHERE id = $1', [req.params.id]); res.json({ message: 'Music scan deleted successfully!' }); } }); // Search music scans app.get('/api/music-scans/search', async (req, res) => { const whereClauses = []; const values = []; let valIndex = 1; for (const key in req.query) { if (req.query[key] !== 'undefined' && req.query[key] !== '') { if (['jewish', 'choral', 'liturgical'].includes(key)) { whereClauses.push(`${key} = $${valIndex++}`); values.push(req.query[key] === 'true'); } else { whereClauses.push(`${key} ILIKE $${valIndex++}`); values.push(`%${req.query[key]}%`); } } } const whereSQL = whereClauses.length ? `WHERE ${whereClauses.join(' AND ')}` : ''; const results = await db.query(`SELECT * FROM public.scans ${whereSQL}`, values); res.json(results); }); // Autocomplete all app.get('/api/music-scans/all-autocomplete', async (req, res) => { const results = await db.query( `SELECT name, composer, arranger, words, genre, language, instrument, year FROM public.scans` ); res.json(results); }); // Autocomplete with search app.get('/api/music-scans/autocomplete', async (req, res) => { // Similar to search, but return unique values for each field const whereClauses = []; const values = []; let valIndex = 1; /** @type string[] */ const validKeys = [] for (const key in req.query) { if (req.query[key] !== 'undefined' && req.query[key] !== '') { validKeys.push(key); if (['jewish', 'choral', 'liturgical'].includes(key)) { whereClauses.push(`${key} = $${valIndex++}`); values.push(req.query[key] === 'true'); } else { whereClauses.push(`${key} ILIKE $${valIndex++}`); values.push(`%${req.query[key]}%`); } } } if (validKeys.length === 0) { return; } const whereSQL = whereClauses.length ? `WHERE ${whereClauses.join(' AND ')}` : ''; const text = `SELECT DISTINCT (${validKeys.join(', ')}) FROM public.scans ${whereSQL ? whereSQL : ''}` /** @type {string[][] | string[]} */ const results = await db.query(text, values).then( res => { console.debug(res); const sectionArray = []; if(validKeys.length > 1) { for (let row in res) sectionArray.push(res[row]['row'].replaceAll(/[()"]/g, '').split(',').map(item => item.trim())); } else { for (let row in res) sectionArray.push(res[row][validKeys[0]].replaceAll(/[()"]/g,'').trim()); } return sectionArray; }); const data = {}; if (validKeys.length === 1) data[validKeys[0]] = results else { for (let i = 0; i < validKeys.length; i++) { const column = [] for (const row of results) { column.push(row[i]) } console.debug(column); data[validKeys[i]] = [...new Set(column)].filter(item => item !== null && item !== undefined && item !== ''); } } res.json(data); }); app.post("/api/music-scans/register", async (req, res) => { const { username, password } = req.body const users = await db.query('SELECT * FROM public.users') let user = users.find(u => u.username === username) if (user) { res.status(401).json({ data: null, error: 'User already exists' }) return } const hashedPassword = await bcrypt.hash(password, 10) const insertedUsers = await db.query('INSERT INTO public.users (username, password, role) VALUES ($1, $2, $3) RETURNING *', [username, hashedPassword, 'user']) if (insertedUsers.length === 0) { res.status(500).json({ data: null, error: 'Failed to create user: User already exists' }) return } user = insertedUsers[0] const token = jwt.sign({ id: user.id, role: user.role }, JWT_SECRET, { expiresIn: '1d' }) res.json({ data: { id: user.id, username: user.username, password: null, // Don't send the password back token: token } }) }) app.post("/api/music-scans/login", async (req, res) => { const { username, password } = req.body const users = await db.query('SELECT * FROM public.users') const user = users.find(u => u.username === username) if (!user || !bcrypt.compareSync(password, user.password)) { res.status(401).json({ data: null, error: 'Invalid credentials' }) return } const token = jwt.sign({ id: user.id, role: user.role }, JWT_SECRET, { expiresIn: '1d' }) res.json({ data: { id: user.id, username: user.username, role: user.role, token: token } }) }) // 404 handler app.use((req, res) => { res.status(404).json({ message: 'Route not found' }); }); const PORT = process.env.PORT || 8000; app.listen(PORT, () => { console.log(`Express server running on port ${PORT}`); });