From 5e4503178991042d98e752afe3961db0bd4a71d1 Mon Sep 17 00:00:00 2001 From: Jason Stedwell Date: Mon, 6 Jul 2026 14:47:41 -0500 Subject: [PATCH] Harden dog endpoints: parent validation, 404s, safe photo handling, transactions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - safeParsePhotos(): corrupted photo_urls JSON no longer crashes every request that loads the dog - DELETE /photos/:idx rejects NaN/out-of-range indexes with 400 (was silent no-op) - PUT /:id returns 404 for missing dogs (was silent success) - POST/PUT validate sire/dam exist, have the right sex, and aren't the dog itself - Dog create (dog+parents), update (dog+parent swap), and cascade delete now run in transactions — no more partial writes Co-Authored-By: Claude Fable 5 --- server/routes/dogs.js | 186 ++++++++++++++++++++++++++---------------- 1 file changed, 117 insertions(+), 69 deletions(-) diff --git a/server/routes/dogs.js b/server/routes/dogs.js index b86b4e4..3728547 100644 --- a/server/routes/dogs.js +++ b/server/routes/dogs.js @@ -31,6 +31,32 @@ const upload = multer({ const emptyToNull = (v) => (v === '' || v === undefined) ? null : v; +// photo_urls is stored as a JSON array string; never let one corrupted row +// crash every request that loads the dog. +function safeParsePhotos(raw) { + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch (_) { + return []; + } +} + +// Sire/dam references must exist and have the right sex — bad parent links +// corrupt the pedigree graph and COI math. Returns an error string or null. +function validateParents(db, sire_id, dam_id, dogId = null) { + const checks = [[sire_id, 'male', 'Sire'], [dam_id, 'female', 'Dam']]; + for (const [pid, wantSex, label] of checks) { + if (!pid || pid === '') continue; + if (dogId !== null && String(pid) === String(dogId)) return `${label} cannot be the dog itself`; + const parent = db.prepare('SELECT sex FROM dogs WHERE id = ?').get(pid); + if (!parent) return `${label} does not exist (id ${pid})`; + if (parent.sex !== wantSex) return `${label} must be a ${wantSex} dog`; + } + return null; +} + // ── Shared SELECT columns ──────────────────────────────────────────────── const DOG_COLS = ` id, name, registration_number, breed, sex, birth_date, @@ -47,7 +73,7 @@ function attachParents(db, dogs) { WHERE p.dog_id = ? `); dogs.forEach(dog => { - dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : []; + dog.photo_urls = safeParsePhotos(dog.photo_urls); const parents = parentStmt.all(dog.id); dog.sire = parents.find(p => p.parent_type === 'sire') || null; dog.dam = parents.find(p => p.parent_type === 'dam') || null; @@ -171,7 +197,7 @@ router.get('/:id', (req, res) => { if (!dog) return res.status(404).json({ error: 'Dog not found' }); - dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : []; + dog.photo_urls = safeParsePhotos(dog.photo_urls); const parents = db.prepare(` SELECT p.parent_type, d.id, d.name, d.is_champion, d.is_external @@ -208,32 +234,40 @@ router.post('/', (req, res) => { } const db = getDatabase(); - const result = db.prepare(` - INSERT INTO dogs (name, registration_number, breed, sex, birth_date, color, - microchip, notes, litter_id, photo_urls, is_champion, is_external) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run( - name, - emptyToNull(registration_number), - breed, sex, - emptyToNull(birth_date), - emptyToNull(color), - emptyToNull(microchip), - emptyToNull(notes), - emptyToNull(litter_id), - '[]', - is_champion ? 1 : 0, - is_external ? 1 : 0 - ); - const dogId = result.lastInsertRowid; + const parentError = validateParents(db, sire_id, dam_id); + if (parentError) return res.status(400).json({ error: parentError }); - if (sire_id && sire_id !== '' && sire_id !== null) { - db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').run(dogId, sire_id, 'sire'); - } - if (dam_id && dam_id !== '' && dam_id !== null) { - db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').run(dogId, dam_id, 'dam'); - } + const createDog = db.transaction(() => { + const result = db.prepare(` + INSERT INTO dogs (name, registration_number, breed, sex, birth_date, color, + microchip, notes, litter_id, photo_urls, is_champion, is_external) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + name, + emptyToNull(registration_number), + breed, sex, + emptyToNull(birth_date), + emptyToNull(color), + emptyToNull(microchip), + emptyToNull(notes), + emptyToNull(litter_id), + '[]', + is_champion ? 1 : 0, + is_external ? 1 : 0 + ); + + const id = result.lastInsertRowid; + if (sire_id && sire_id !== '' && sire_id !== null) { + db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').run(id, sire_id, 'sire'); + } + if (dam_id && dam_id !== '' && dam_id !== null) { + db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').run(id, dam_id, 'dam'); + } + return id; + }); + + const dogId = createDog(); const dog = db.prepare(`SELECT ${DOG_COLS} FROM dogs WHERE id = ?`).get(dogId); dog.photo_urls = []; @@ -253,36 +287,46 @@ router.put('/:id', (req, res) => { microchip, notes, sire_id, dam_id, litter_id, is_champion, is_external } = req.body; const db = getDatabase(); - db.prepare(` - UPDATE dogs - SET name = ?, registration_number = ?, breed = ?, sex = ?, - birth_date = ?, color = ?, microchip = ?, notes = ?, - litter_id = ?, is_champion = ?, is_external = ?, updated_at = datetime('now') - WHERE id = ? - `).run( - name, - emptyToNull(registration_number), - breed, sex, - emptyToNull(birth_date), - emptyToNull(color), - emptyToNull(microchip), - emptyToNull(notes), - emptyToNull(litter_id), - is_champion ? 1 : 0, - is_external ? 1 : 0, - req.params.id - ); - db.prepare('DELETE FROM parents WHERE dog_id = ?').run(req.params.id); - if (sire_id && sire_id !== '' && sire_id !== null) { - db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').run(req.params.id, sire_id, 'sire'); - } - if (dam_id && dam_id !== '' && dam_id !== null) { - db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').run(req.params.id, dam_id, 'dam'); - } + const existing = db.prepare('SELECT id FROM dogs WHERE id = ?').get(req.params.id); + if (!existing) return res.status(404).json({ error: 'Dog not found' }); + + const parentError = validateParents(db, sire_id, dam_id, req.params.id); + if (parentError) return res.status(400).json({ error: parentError }); + + const updateDog = db.transaction(() => { + db.prepare(` + UPDATE dogs + SET name = ?, registration_number = ?, breed = ?, sex = ?, + birth_date = ?, color = ?, microchip = ?, notes = ?, + litter_id = ?, is_champion = ?, is_external = ?, updated_at = datetime('now') + WHERE id = ? + `).run( + name, + emptyToNull(registration_number), + breed, sex, + emptyToNull(birth_date), + emptyToNull(color), + emptyToNull(microchip), + emptyToNull(notes), + emptyToNull(litter_id), + is_champion ? 1 : 0, + is_external ? 1 : 0, + req.params.id + ); + + db.prepare('DELETE FROM parents WHERE dog_id = ?').run(req.params.id); + if (sire_id && sire_id !== '' && sire_id !== null) { + db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').run(req.params.id, sire_id, 'sire'); + } + if (dam_id && dam_id !== '' && dam_id !== null) { + db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').run(req.params.id, dam_id, 'dam'); + } + }); + updateDog(); const dog = db.prepare(`SELECT ${DOG_COLS} FROM dogs WHERE id = ?`).get(req.params.id); - dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : []; + dog.photo_urls = safeParsePhotos(dog.photo_urls); console.log(`✔ Dog updated: ${dog.name} (ID: ${req.params.id})`); res.json(dog); @@ -300,11 +344,13 @@ router.delete('/:id', (req, res) => { if (!existing) return res.status(404).json({ error: 'Dog not found' }); const id = req.params.id; - db.prepare('DELETE FROM parents WHERE parent_id = ?').run(id); - db.prepare('DELETE FROM parents WHERE dog_id = ?').run(id); - db.prepare('DELETE FROM health_records WHERE dog_id = ?').run(id); - db.prepare('DELETE FROM heat_cycles WHERE dog_id = ?').run(id); - db.prepare('DELETE FROM dogs WHERE id = ?').run(id); + db.transaction(() => { + db.prepare('DELETE FROM parents WHERE parent_id = ?').run(id); + db.prepare('DELETE FROM parents WHERE dog_id = ?').run(id); + db.prepare('DELETE FROM health_records WHERE dog_id = ?').run(id); + db.prepare('DELETE FROM heat_cycles WHERE dog_id = ?').run(id); + db.prepare('DELETE FROM dogs WHERE id = ?').run(id); + })(); console.log(`✔ Dog #${id} (${existing.name}) permanently deleted`); res.json({ success: true, message: `${existing.name} has been deleted` }); @@ -323,7 +369,7 @@ router.post('/:id/photos', upload.single('photo'), (req, res) => { const dog = db.prepare('SELECT photo_urls FROM dogs WHERE id = ?').get(req.params.id); if (!dog) return res.status(404).json({ error: 'Dog not found' }); - const photoUrls = dog.photo_urls ? JSON.parse(dog.photo_urls) : []; + const photoUrls = safeParsePhotos(dog.photo_urls); photoUrls.push(`/uploads/${req.file.filename}`); db.prepare('UPDATE dogs SET photo_urls = ? WHERE id = ?').run(JSON.stringify(photoUrls), req.params.id); @@ -341,19 +387,21 @@ router.delete('/:id/photos/:photoIndex', (req, res) => { const dog = db.prepare('SELECT photo_urls FROM dogs WHERE id = ?').get(req.params.id); if (!dog) return res.status(404).json({ error: 'Dog not found' }); - const photoUrls = dog.photo_urls ? JSON.parse(dog.photo_urls) : []; - const photoIndex = parseInt(req.params.photoIndex); + const photoUrls = safeParsePhotos(dog.photo_urls); + const photoIndex = parseInt(req.params.photoIndex, 10); - if (photoIndex >= 0 && photoIndex < photoUrls.length) { - const photoPath = path.join( - process.env.UPLOAD_PATH || path.join(__dirname, '../../uploads'), - path.basename(photoUrls[photoIndex]) - ); - if (fs.existsSync(photoPath)) fs.unlinkSync(photoPath); - photoUrls.splice(photoIndex, 1); - db.prepare('UPDATE dogs SET photo_urls = ? WHERE id = ?').run(JSON.stringify(photoUrls), req.params.id); + if (!Number.isInteger(photoIndex) || photoIndex < 0 || photoIndex >= photoUrls.length) { + return res.status(400).json({ error: 'Invalid photo index' }); } + const photoPath = path.join( + process.env.UPLOAD_PATH || path.join(__dirname, '../../uploads'), + path.basename(photoUrls[photoIndex]) + ); + if (fs.existsSync(photoPath)) fs.unlinkSync(photoPath); + photoUrls.splice(photoIndex, 1); + db.prepare('UPDATE dogs SET photo_urls = ? WHERE id = ?').run(JSON.stringify(photoUrls), req.params.id); + res.json({ photos: photoUrls }); } catch (error) { console.error('Error deleting photo:', error);