const express = require('express'); const jwt = require('jsonwebtoken'); const cors = require('cors'); const path = require('path'); const os = require('os'); const fs = require('fs'); const util = require('util'); const { execFile } = require('child_process'); const fileUpload = require('express-fileupload'); require('dotenv').config({ path: path.join(__dirname, '.env') }); const fetch = require('node-fetch'); const execFileAsync = util.promisify(execFile); const SSH_OPTIONS = ['-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null']; const NAS_CONFIG_PATH = path.join(__dirname, 'nas_config.txt'); let DEFAULT_PHOTO_DIR = '/home/user/photos'; try { const raw = fs.readFileSync(NAS_CONFIG_PATH, 'utf8').trim(); if (raw) DEFAULT_PHOTO_DIR = raw.split('\n')[0].trim(); } catch (_) {} function runCommand(cmd, args, options = {}) { return execFileAsync(cmd, args, { timeout: 30000, maxBuffer: 10 * 1024 * 1024, ...options, }); } function buildScpArgs(conn, source, dest) { const args = ['-P', String(conn.port || 22), ...SSH_OPTIONS]; if (conn.privateKeyPath) args.push('-i', conn.privateKeyPath); args.push(source, dest); return args; } function buildSftpArgs(conn, batchFilePath) { const args = ['-P', String(conn.port || 22), ...SSH_OPTIONS, '-b', batchFilePath]; if (conn.privateKeyPath) args.push('-i', conn.privateKeyPath); args.push(`${conn.username}@${conn.host}`); return args; } async function runSftpBatch(conn, batchCommands) { const batchFilePath = path.join( os.tmpdir(), `sftp_batch_${Date.now()}_${Math.random().toString(36).slice(2)}.txt` ); fs.writeFileSync(batchFilePath, batchCommands.join('\n'), 'utf8'); try { const args = buildSftpArgs(conn, batchFilePath); if (conn.password) { const sshpass = process.env.SSHPASS_PATH || 'sshpass'; return await runCommand(sshpass, ['-p', conn.password, 'sftp', ...args]); } return await runCommand('sftp', args); } finally { fs.unlinkSync(batchFilePath); } } const app = express(); app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use(fileUpload()); 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 verifyToken(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); req.connectionId = decoded.connectionId; req.connection = connections.get(decoded.connectionId); if (!req.connection) return res.status(401).json({ error: 'Connection expired or not found' }); 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(), activeConnections: connections.size, defaultPhotoDir: DEFAULT_PHOTO_DIR, }); }); app.get('/api/config/photo-dir', (req, res) => { res.json({ photoDir: DEFAULT_PHOTO_DIR }); }); app.post('/api/sftp/connect', (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 connectionId = `conn_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; connections.set(connectionId, { host, port, username, password, createdAt: new Date() }); const token = jwt.sign({ connectionId }, SECRET_KEY, { expiresIn: '24h' }); console.log(`Connection prepared: ${host}:${port} (${connectionId})`); res.json({ success: true, connectionToken: token, message: `Connection prepared for ${host}:${port}`, defaultPhotoDir: DEFAULT_PHOTO_DIR, }); }); app.post('/api/sftp/list', verifyToken, async (req, res) => { const { path: dirPath = DEFAULT_PHOTO_DIR } = req.body; const conn = req.connection; try { const result = await runSftpBatch(conn, [`ls -l ${dirPath}`]); const files = result.stdout .split('\n') .filter(Boolean) .map((line) => { const parts = line.split(/\s+/); return { name: parts.pop(), isDirectory: parts[0]?.charAt(0) === 'd', size: parseInt(parts[4], 10), modifyTime: new Date(`${parts[5]} ${parts[6]} ${parts[7]} ${parts[8]}`), permissions: parts[0], }; }); res.json({ path: dirPath, files, count: files.length }); } catch (err) { console.error('List directory error:', err.message); res.status(500).json({ error: 'Failed to list directory: ' + err.message }); } }); app.get('/api/sftp/download', verifyToken, async (req, res) => { const { path: filePath } = req.query; if (!filePath) return res.status(400).json({ error: 'Missing path parameter' }); const conn = req.connection; const filename = path.basename(filePath); const tempFilePath = path.join(os.tmpdir(), filename); try { await runCommand('scp', buildScpArgs(conn, `${conn.username}@${conn.host}:${filePath}`, tempFilePath)); res.download(tempFilePath, filename, () => { try { fs.unlinkSync(tempFilePath); } catch (_) {} }); } catch (err) { console.error('Download error:', err.message); res.status(500).json({ error: 'Download failed: ' + err.message }); } }); app.post('/api/sftp/upload', verifyToken, async (req, res) => { const { remotePath } = req.body; if (!remotePath || !req.files?.file) { return res.status(400).json({ error: 'Missing remotePath or file' }); } const file = req.files.file; const conn = req.connection; const tempFilePath = path.join(os.tmpdir(), file.name); try { await file.mv(tempFilePath); await runCommand( 'scp', buildScpArgs(conn, tempFilePath, `${conn.username}@${conn.host}:${remotePath}`) ); fs.unlinkSync(tempFilePath); console.log(`Uploaded: ${remotePath}`); res.json({ success: true, remotePath, message: 'File uploaded' }); } catch (err) { console.error('Upload error:', err.message); res.status(500).json({ error: 'Upload failed: ' + err.message }); } }); app.post('/api/sftp/mkdir', verifyToken, async (req, res) => { const { path: dirPath } = req.body; if (!dirPath) return res.status(400).json({ error: 'Missing path parameter' }); try { await runSftpBatch(req.connection, [`mkdir ${dirPath}`]); console.log(`Created directory: ${dirPath}`); res.json({ success: true, path: dirPath, message: 'Directory created' }); } catch (err) { console.error('Mkdir error:', err.message); res.status(500).json({ error: 'Failed to create directory: ' + err.message }); } }); app.post('/api/sftp/delete', verifyToken, async (req, res) => { const { path: filePath } = req.body; if (!filePath) return res.status(400).json({ error: 'Missing path parameter' }); try { await runSftpBatch(req.connection, [`rm ${filePath}`]); console.log(`Deleted: ${filePath}`); res.json({ success: true, path: filePath, message: 'File deleted' }); } catch (err) { console.error('Delete error:', err.message); res.status(500).json({ error: 'Failed to delete file: ' + err.message }); } }); app.post('/api/sftp/disconnect', verifyToken, (req, res) => { connections.delete(req.connectionId); console.log(`Disconnected: ${req.connectionId}`); res.json({ success: true, message: 'Disconnected from SFTP server' }); }); app.post('/api/google-photos/fetch', verifyToken, 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 }); } }); app.post('/api/google-photos/download-to-nas', verifyToken, async (req, res) => { const { googleAccessToken, baseUrl, filename, remotePath } = req.body; const conn = req.connection; if (!googleAccessToken || !baseUrl || !remotePath) { return res.status(400).json({ error: 'googleAccessToken, baseUrl, and remotePath 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 buffer = await imageResponse.buffer(); const safeName = filename || `gphoto_${Date.now()}.jpg`; const tempFilePath = path.join(os.tmpdir(), safeName); fs.writeFileSync(tempFilePath, buffer); const fullRemotePath = `${conn.username}@${conn.host}:${remotePath}/${safeName}`; await runCommand('scp', buildScpArgs(conn, tempFilePath, fullRemotePath)); fs.unlinkSync(tempFilePath); console.log(`Saved Google Photo to NAS: ${remotePath}/${safeName}`); res.json({ success: true, message: `Photo saved to NAS at ${remotePath}/${safeName}` }); } catch (err) { console.error('Download-to-NAS error:', err.message); res.status(500).json({ error: 'Failed to save photo to NAS: ' + err.message }); } }); app.post('/api/google-photos/sync-status', verifyToken, (req, res) => { res.json({ success: true, status: 'idle', message: 'No active sync in progress', lastSync: null }); }); process.on('SIGINT', () => { console.log('\nShutting down...'); connections.forEach((_, id) => connections.delete(id)); 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 SFTP Backend Server`); console.log(` Port: ${PORT}`); console.log(` URL: http://localhost:${PORT}`); console.log(` Photo Dir: ${DEFAULT_PHOTO_DIR}`); console.log(` Status: Ready\n`); }); module.exports = app;