const express = require('express'); const jwt = require('jsonwebtoken'); const cors = require('cors'); const path = require('path'); require('dotenv').config({ path: path.join(__dirname, '.env') }); const nasVaultStore = require('./nasVaultStore'); const fetch = (...args) => import('node-fetch').then(({ default: nodeFetch }) => nodeFetch(...args)); const app = express(); app.use(express.json({ limit: process.env.JSON_BODY_LIMIT || '150mb' })); app.use(express.urlencoded({ extended: true, limit: process.env.JSON_BODY_LIMIT || '150mb' })); app.use(cors()); const SECRET_KEY = process.env.JWT_SECRET || 'your-secret-key-change-in-production'; const connections = new Map(); app.use((req, res, next) => { console.log(`[${new Date().toISOString()}] ${req.method} ${req.path}`); next(); }); function verifyNasSession(req, res, next) { const authHeader = req.headers.authorization; const token = authHeader?.split(' ')[1]; if (!token) return res.status(401).json({ error: 'No token provided' }); try { const decoded = jwt.verify(token, SECRET_KEY); const conn = connections.get(decoded.connectionId); if (!conn) return res.status(401).json({ error: 'Session expired or not found' }); req.connectionId = decoded.connectionId; req.connection = conn; req.vaultKey = conn.vaultKey; next(); } catch (err) { res.status(401).json({ error: 'Invalid token: ' + err.message }); } } app.get('/api/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString(), activeSessions: connections.size, nasMode: 'rest-crudl', }); }); /** * Open a NAS client session (REST vault). Credentials scope the vault; media is stored * server-side as base64 with preserved metadata (no SFTP / exifr pipeline). */ app.post('/api/nas/sessions', (req, res) => { const { host, port = 22, username, password } = req.body; if (!host || !username || !password) { return res.status(400).json({ error: 'Missing required fields: host, username, password' }); } const vaultKey = nasVaultStore.vaultKeyFromCredentials(host, username); const connectionId = `nas_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`; connections.set(connectionId, { host, port: Number(port) || 22, username, password, vaultKey, createdAt: new Date(), }); const token = jwt.sign({ connectionId }, SECRET_KEY, { expiresIn: '24h' }); console.log(`NAS session: vault=${vaultKey.slice(0, 12)}… (${connectionId})`); res.json({ success: true, connectionToken: token, message: 'NAS REST session established', }); }); /** List — GET /api/nas/media?offset=&limit=&include=metadata|full */ app.get('/api/nas/media', verifyNasSession, (req, res) => { try { const offset = Math.max(0, parseInt(req.query.offset, 10) || 0); const limit = Math.min(500, Math.max(1, parseInt(req.query.limit, 10) || 100)); const include = req.query.include === 'full' ? 'full' : 'metadata'; const { items, total } = nasVaultStore.list(req.vaultKey, { offset, limit, include }); res.json({ items, total, offset, limit, include }); } catch (err) { console.error('NAS list error:', err.message); res.status(500).json({ error: err.message || 'List failed' }); } }); /** Read — GET /api/nas/media/:id?include=metadata|full */ app.get('/api/nas/media/:id', verifyNasSession, (req, res) => { const row = nasVaultStore.getById(req.vaultKey, req.params.id); if (!row) return res.status(404).json({ error: 'Media not found' }); const include = req.query.include === 'full' ? 'full' : 'metadata'; res.json(nasVaultStore.shapeRow(row, include)); }); /** Create — POST /api/nas/media */ app.post('/api/nas/media', verifyNasSession, (req, res) => { try { const { filename, mimeType, base64, metadata } = req.body || {}; if (!base64) return res.status(400).json({ error: 'Missing base64 in body' }); const created = nasVaultStore.create(req.vaultKey, { filename, mimeType, base64, metadata }); res.status(201).json(created); } catch (err) { console.error('NAS create error:', err.message); res.status(400).json({ error: err.message || 'Create failed' }); } }); /** Update — PUT /api/nas/media/:id */ app.put('/api/nas/media/:id', verifyNasSession, (req, res) => { try { const updated = nasVaultStore.update(req.vaultKey, req.params.id, req.body || {}); if (!updated) return res.status(404).json({ error: 'Media not found' }); res.json(updated); } catch (err) { console.error('NAS update error:', err.message); res.status(400).json({ error: err.message || 'Update failed' }); } }); /** Delete — DELETE /api/nas/media/:id */ app.delete('/api/nas/media/:id', verifyNasSession, (req, res) => { const ok = nasVaultStore.remove(req.vaultKey, req.params.id); if (!ok) return res.status(404).json({ error: 'Media not found' }); res.status(204).send(); }); /** End session (does not delete vault data) */ app.delete('/api/nas/sessions/current', verifyNasSession, (req, res) => { connections.delete(req.connectionId); res.json({ success: true, message: 'Session closed' }); }); app.post('/api/google-photos/fetch', verifyNasSession, async (req, res) => { const { googleAccessToken, pageSize = 50, pageToken } = req.body; if (!googleAccessToken) { return res.status(400).json({ error: 'Google access token is required' }); } try { const params = new URLSearchParams({ pageSize: String(pageSize) }); if (pageToken) params.append('pageToken', pageToken); const response = await fetch( `https://photoslibrary.googleapis.com/v1/mediaItems?${params.toString()}`, { headers: { Authorization: `Bearer ${googleAccessToken}` } } ); if (!response.ok) { const errText = await response.text(); return res.status(response.status).json({ error: 'Google API error', details: errText }); } const data = await response.json(); const mediaItems = (data.mediaItems || []).map((item) => ({ id: item.id, filename: item.filename, mimeType: item.mimeType, baseUrl: item.baseUrl, productUrl: item.productUrl, creationTime: item.mediaMetadata?.creationTime, width: item.mediaMetadata?.width, height: item.mediaMetadata?.height, })); console.log(`Fetched ${mediaItems.length} Google Photos items via NAS proxy`); res.json({ success: true, mediaItems, nextPageToken: data.nextPageToken || null, count: mediaItems.length }); } catch (err) { console.error('Google Photos fetch error:', err.message); res.status(500).json({ error: 'Failed to fetch Google Photos: ' + err.message }); } }); /** Saves a Google image into the NAS vault as base64 with metadata (CRUD Create). */ app.post('/api/google-photos/download-to-nas', verifyNasSession, async (req, res) => { const { googleAccessToken, baseUrl, filename, metadata = {} } = req.body; if (!googleAccessToken || !baseUrl) { return res.status(400).json({ error: 'googleAccessToken and baseUrl are required' }); } try { const imageResponse = await fetch(`${baseUrl}=d`, { headers: { Authorization: `Bearer ${googleAccessToken}` }, }); if (!imageResponse.ok) { return res.status(imageResponse.status).json({ error: 'Failed to download from Google Photos' }); } const arrayBuffer = await imageResponse.arrayBuffer(); const base64 = Buffer.from(arrayBuffer).toString('base64'); const safeName = filename || `gphoto_${Date.now()}.jpg`; const mimeType = imageResponse.headers.get('content-type') || 'image/jpeg'; const created = nasVaultStore.create(req.vaultKey, { filename: safeName, mimeType, base64, metadata: { ...metadata, source: 'google_photos_offload', width: metadata.width, height: metadata.height, dateTaken: metadata.creationTime || metadata.dateTaken, }, }); console.log(`Saved Google Photo to NAS vault: ${created.id}`); res.json({ success: true, media: created, message: 'Photo stored in NAS vault' }); } catch (err) { console.error('Google → NAS error:', err.message); res.status(500).json({ error: 'Failed to save photo to NAS vault: ' + err.message }); } }); app.post('/api/google-photos/sync-status', verifyNasSession, (req, res) => { res.json({ success: true, status: 'idle', message: 'No active sync in progress', lastSync: null }); }); process.on('SIGINT', () => { console.log('\nShutting down...'); connections.clear(); process.exit(0); }); app.use((err, req, res, _next) => { console.error('Server error:', err.message); res.status(500).json({ error: 'Internal server error', message: process.env.NODE_ENV === 'development' ? err.message : 'Unknown error', }); }); app.use((req, res) => { res.status(404).json({ error: 'Not found', path: req.path }); }); const PORT = process.env.PORT || 3001; app.listen(PORT, () => { console.log(`\n NAS REST backend (CRUDL + base64 vault)`); console.log(` Port: ${PORT}`); console.log(` URL: http://localhost:${PORT}`); console.log(` Status: Ready\n`); }); module.exports = app;