const express = require('express'); const cors = require('cors'); const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const config = require('./config'); const db = require('./db'); const app = express(); app.use(cors()); app.use(express.json({ limit: '100mb' })); app.use((req, res, next) => { const start = Date.now(); res.on('finish', () => { const ms = Date.now() - start; const user = req.body?.username ? ` user=${req.body.username}` : ''; console.log(`[${new Date().toISOString()}] ${req.method} ${req.path} → ${res.statusCode} (${ms}ms)${user}`); }); next(); }); function getPhotosPath() { const p = config.getPhotosPath(); if (!p) throw new Error('Photos path not configured. Set it in nas_config.txt'); if (!fs.existsSync(p)) { fs.mkdirSync(p, { recursive: true }); } return p; } function sanitizeUsername(username) { return username.trim().replace(/[^a-zA-Z0-9_\-.]/g, '_'); } // ─── Health ────────────────────────────────────────────────── app.get('/api/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); // ─── User Registration ────────────────────────────────────── // Device sends its username; NAS registers and creates user folder app.post('/api/register', (req, res) => { try { const { username } = req.body; if (!username || typeof username !== 'string' || username.trim().length === 0) { return res.status(400).json({ error: 'Username is required' }); } const sanitized = sanitizeUsername(username); const userId = db.ensureUser(sanitized); const photosPath = getPhotosPath(); const userDir = path.join(photosPath, sanitized); if (!fs.existsSync(userDir)) { fs.mkdirSync(userDir, { recursive: true }); } res.json({ success: true, userId, username: sanitized }); } catch (err) { console.error('[/api/register] Error:', err.message, err.stack); res.status(500).json({ error: err.message }); } }); // ─── Index (NAS builds its database) ──────────────────────── app.post('/api/index', (req, res) => { try { const { username } = req.body; if (!username) return res.status(400).json({ error: 'Username is required' }); const photosPath = getPhotosPath(); const sanitized = sanitizeUsername(username); const removed = db.removeOrphanedEntries(sanitized, photosPath); const result = db.indexPhotosForUser(sanitized, photosPath); res.json({ success: true, username: sanitized, indexed: result.indexed, skipped: result.skipped, errors: result.errors, totalFiles: result.total, orphansRemoved: removed, }); } catch (err) { console.error('[/api/index] Error:', err.message, err.stack); res.status(500).json({ error: err.message }); } }); // ─── Photo List (POST fetch cycle) ───────────────────────── app.post('/api/photos', (req, res) => { try { const { username } = req.body; if (!username) return res.status(400).json({ error: 'Username is required' }); const sanitized = sanitizeUsername(username); const photos = db.getPhotoIndex(sanitized); res.json({ success: true, username: sanitized, count: photos.length, photos: photos.map(p => ({ id: p.id, filename: p.filename, sha256Hash: p.sha256_hash, fileSize: p.file_size, mimeType: p.mime_type, indexedAt: p.indexed_at, })), }); } catch (err) { console.error('[/api/photos] Error:', err.message, err.stack); res.status(500).json({ error: err.message }); } }); // ─── Fetch Single Photo as Base64 with hash ───────────────── app.post('/api/photo/fetch', (req, res) => { try { const { photoId, hash } = req.body; if (!photoId && !hash) { return res.status(400).json({ error: 'photoId or hash is required' }); } let photo; if (photoId) { photo = db.getPhotoById(photoId); } else { photo = db.getPhotoByHash(hash); } if (!photo) return res.status(404).json({ error: 'Photo not found' }); if (!fs.existsSync(photo.filepath)) { return res.status(404).json({ error: 'Photo file missing from disk' }); } const fileBuffer = fs.readFileSync(photo.filepath); const base64Data = fileBuffer.toString('base64'); const transportHash = crypto.createHash('sha256').update(base64Data).digest('hex'); res.json({ success: true, photo: { id: photo.id, filename: photo.filename, mimeType: photo.mime_type, fileSize: photo.file_size, sha256Hash: photo.sha256_hash, transportHash, base64: base64Data, }, }); } catch (err) { console.error('[/api/photo/fetch] Error:', err.message, err.stack); res.status(500).json({ error: err.message }); } }); // ─── Upload Photo from Device to NAS ──────────────────────── app.post('/api/photo/upload', (req, res) => { try { const { username, filename, base64, transportHash } = req.body; if (!username || !filename || !base64) { return res.status(400).json({ error: 'username, filename, and base64 are required' }); } if (transportHash) { const computed = crypto.createHash('sha256').update(base64).digest('hex'); if (computed !== transportHash) { return res.status(400).json({ error: 'Transport integrity check failed' }); } } const sanitized = sanitizeUsername(username); const photosPath = getPhotosPath(); const userDir = path.join(photosPath, sanitized); if (!fs.existsSync(userDir)) { fs.mkdirSync(userDir, { recursive: true }); } const safeFilename = filename.replace(/[^a-zA-Z0-9_\-.]/g, '_'); const destPath = path.join(userDir, safeFilename); const buffer = Buffer.from(base64, 'base64'); fs.writeFileSync(destPath, buffer); db.ensureUser(sanitized); const result = db.indexPhotosForUser(sanitized, photosPath); res.json({ success: true, filename: safeFilename, indexed: result.indexed }); } catch (err) { console.error('[/api/photo/upload] Error:', err.message, err.stack); res.status(500).json({ error: err.message }); } }); // ─── Delete Photo from NAS ────────────────────────────────── app.post('/api/photo/delete', (req, res) => { try { const { photoId } = req.body; if (!photoId) return res.status(400).json({ error: 'photoId is required' }); const result = db.deletePhoto(photoId); res.json(result); } catch (err) { console.error('[/api/photo/delete] Error:', err.message, err.stack); res.status(500).json({ error: err.message }); } }); // ─── Refresh (re-index + return list) ─────────────────────── app.post('/api/refresh', (req, res) => { try { const { username } = req.body; if (!username) return res.status(400).json({ error: 'Username is required' }); const sanitized = sanitizeUsername(username); const photosPath = getPhotosPath(); db.ensureUser(sanitized); db.removeOrphanedEntries(sanitized, photosPath); db.indexPhotosForUser(sanitized, photosPath); const photos = db.getPhotoIndex(sanitized); res.json({ success: true, username: sanitized, count: photos.length, photos: photos.map(p => ({ id: p.id, filename: p.filename, sha256Hash: p.sha256_hash, fileSize: p.file_size, mimeType: p.mime_type, indexedAt: p.indexed_at, })), }); } catch (err) { console.error('[/api/refresh] Error:', err.message, err.stack); res.status(500).json({ error: err.message }); } }); // ─── Album Endpoints ──────────────────────────────────────── app.post('/api/albums', (req, res) => { try { const { username } = req.body; if (!username) return res.status(400).json({ error: 'Username is required' }); const sanitized = sanitizeUsername(username); const albums = db.getAlbums(sanitized); res.json({ success: true, albums }); } catch (err) { console.error('[/api/albums] Error:', err.message, err.stack); res.status(500).json({ error: err.message }); } }); app.post('/api/album/create', (req, res) => { try { const { username, name } = req.body; if (!username || !name) return res.status(400).json({ error: 'username and name are required' }); const sanitized = sanitizeUsername(username); const result = db.createAlbum(sanitized, name.trim()); res.json(result); } catch (err) { console.error('[/api/album/create] Error:', err.message, err.stack); res.status(500).json({ error: err.message }); } }); app.post('/api/album/photos', (req, res) => { try { const { albumId } = req.body; if (!albumId) return res.status(400).json({ error: 'albumId is required' }); const photos = db.getAlbumPhotos(albumId); res.json({ success: true, photos: photos.map(p => ({ id: p.id, filename: p.filename, sha256Hash: p.sha256_hash, fileSize: p.file_size, mimeType: p.mime_type, indexedAt: p.indexed_at, })), }); } catch (err) { console.error('[/api/album/photos] Error:', err.message, err.stack); res.status(500).json({ error: err.message }); } }); app.post('/api/album/add-photo', (req, res) => { try { const { albumId, photoId } = req.body; if (!albumId || !photoId) return res.status(400).json({ error: 'albumId and photoId are required' }); const result = db.addPhotoToAlbum(albumId, photoId); res.json(result); } catch (err) { console.error('[/api/album/add-photo] Error:', err.message, err.stack); res.status(500).json({ error: err.message }); } }); app.post('/api/album/remove-photo', (req, res) => { try { const { albumId, photoId } = req.body; if (!albumId || !photoId) return res.status(400).json({ error: 'albumId and photoId are required' }); const result = db.removePhotoFromAlbum(albumId, photoId); res.json(result); } catch (err) { console.error('[/api/album/remove-photo] Error:', err.message, err.stack); res.status(500).json({ error: err.message }); } }); app.post('/api/album/delete', (req, res) => { try { const { albumId } = req.body; if (!albumId) return res.status(400).json({ error: 'albumId is required' }); const result = db.deleteAlbum(albumId); res.json(result); } catch (err) { console.error('[/api/album/delete] Error:', err.message, err.stack); res.status(500).json({ error: err.message }); } }); app.post('/api/album/rename', (req, res) => { try { const { albumId, name } = req.body; if (!albumId || !name) return res.status(400).json({ error: 'albumId and name are required' }); const result = db.renameAlbum(albumId, name.trim()); res.json(result); } catch (err) { console.error('[/api/album/rename] Error:', err.message, err.stack); res.status(500).json({ error: err.message }); } }); // ─── Users ────────────────────────────────────────────────── app.get('/api/users', (req, res) => { try { const users = db.getAllUsers(); res.json({ success: true, users }); } catch (err) { console.error('[/api/users] Error:', err.message, err.stack); res.status(500).json({ error: err.message }); } }); // ─── Error handling ───────────────────────────────────────── app.use((err, req, res, _next) => { console.error('Server error:', err.message); res.status(500).json({ error: 'Internal server error' }); }); app.use((req, res) => { res.status(404).json({ error: 'Not found', path: req.path }); }); // ─── Lifecycle ────────────────────────────────────────────── function startServer(port) { const p = port || config.DEFAULT_PORT; app.listen(p, '0.0.0.0', () => { console.log(`[NAS Server] Running on http://0.0.0.0:${p}`); console.log(`[NAS Server] Photos path: ${config.getPhotosPath() || 'NOT SET'}`); }); } if (require.main === module) { const photosPath = config.getPhotosPath(); if (!photosPath) { console.error('ERROR: Photos path not set. Write the path to your photos directory in nas_config.txt'); process.exit(1); } // Auto-create the photos directory if it doesn't exist yet if (!fs.existsSync(photosPath)) { fs.mkdirSync(photosPath, { recursive: true }); console.log(`[NAS Server] Created photos directory: ${photosPath}`); } startServer(); } process.on('SIGINT', () => { console.log('\nShutting down...'); db.closeDb(); process.exit(0); }); module.exports = { app, startServer };