mirror of
https://github.com/mudabbir-ahmad/Media-Aggregation-and-Sorting-System.git
synced 2026-10-07 19:40:20 +00:00
feat: update app structure and improve user experience; refactor media handling and enhance NAS integration
This commit is contained in:
1 parent
4006cd93f1
commit
ac9e4aee43
23 files changed
+1229
-643
No files matched your search
+98
-182
@@ -2,69 +2,15 @@ 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 nasVaultStore = require('./nasVaultStore');
|
||||
|
||||
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 fetch = (...args) => import('node-fetch').then(({ default: nodeFetch }) => nodeFetch(...args));
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(fileUpload());
|
||||
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';
|
||||
@@ -75,16 +21,18 @@ app.use((req, res, next) => {
|
||||
next();
|
||||
});
|
||||
|
||||
function verifyToken(req, res, 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 = connections.get(decoded.connectionId);
|
||||
if (!req.connection) return res.status(401).json({ error: 'Connection expired or not found' });
|
||||
req.connection = conn;
|
||||
req.vaultKey = conn.vaultKey;
|
||||
next();
|
||||
} catch (err) {
|
||||
res.status(401).json({ error: 'Invalid token: ' + err.message });
|
||||
@@ -95,141 +43,103 @@ app.get('/api/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
activeConnections: connections.size,
|
||||
defaultPhotoDir: DEFAULT_PHOTO_DIR,
|
||||
activeSessions: connections.size,
|
||||
nasMode: 'rest-crudl',
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/config/photo-dir', (req, res) => {
|
||||
res.json({ photoDir: DEFAULT_PHOTO_DIR });
|
||||
});
|
||||
|
||||
app.post('/api/sftp/connect', (req, res) => {
|
||||
/**
|
||||
* 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 connectionId = `conn_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
connections.set(connectionId, { host, port, username, password, createdAt: new Date() });
|
||||
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(`Connection prepared: ${host}:${port} (${connectionId})`);
|
||||
console.log(`NAS session: vault=${vaultKey.slice(0, 12)}… (${connectionId})`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
connectionToken: token,
|
||||
message: `Connection prepared for ${host}:${port}`,
|
||||
defaultPhotoDir: DEFAULT_PHOTO_DIR,
|
||||
message: 'NAS REST session established',
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/sftp/list', verifyToken, async (req, res) => {
|
||||
const { path: dirPath = DEFAULT_PHOTO_DIR } = req.body;
|
||||
const conn = req.connection;
|
||||
|
||||
/** List — GET /api/nas/media?offset=&limit=&include=metadata|full */
|
||||
app.get('/api/nas/media', verifyNasSession, (req, res) => {
|
||||
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 });
|
||||
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('List directory error:', err.message);
|
||||
res.status(500).json({ error: 'Failed to list directory: ' + err.message });
|
||||
console.error('NAS list error:', err.message);
|
||||
res.status(500).json({ error: err.message || 'List failed' });
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
/** 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 {
|
||||
await runCommand('scp', buildScpArgs(conn, `${conn.username}@${conn.host}:${filePath}`, tempFilePath));
|
||||
res.download(tempFilePath, filename, () => {
|
||||
try { fs.unlinkSync(tempFilePath); } catch (_) {}
|
||||
});
|
||||
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('Download error:', err.message);
|
||||
res.status(500).json({ error: 'Download failed: ' + err.message });
|
||||
console.error('NAS create error:', err.message);
|
||||
res.status(400).json({ error: err.message || 'Create failed' });
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
/** Update — PUT /api/nas/media/:id */
|
||||
app.put('/api/nas/media/:id', verifyNasSession, (req, res) => {
|
||||
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' });
|
||||
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('Upload error:', err.message);
|
||||
res.status(500).json({ error: 'Upload failed: ' + err.message });
|
||||
console.error('NAS update error:', err.message);
|
||||
res.status(400).json({ error: err.message || 'Update failed' });
|
||||
}
|
||||
});
|
||||
|
||||
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 });
|
||||
}
|
||||
/** 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();
|
||||
});
|
||||
|
||||
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) => {
|
||||
/** End session (does not delete vault data) */
|
||||
app.delete('/api/nas/sessions/current', verifyNasSession, (req, res) => {
|
||||
connections.delete(req.connectionId);
|
||||
console.log(`Disconnected: ${req.connectionId}`);
|
||||
res.json({ success: true, message: 'Disconnected from SFTP server' });
|
||||
res.json({ success: true, message: 'Session closed' });
|
||||
});
|
||||
|
||||
app.post('/api/google-photos/fetch', verifyToken, async (req, res) => {
|
||||
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' });
|
||||
@@ -269,12 +179,11 @@ app.post('/api/google-photos/fetch', verifyToken, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
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' });
|
||||
/** 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 {
|
||||
@@ -285,30 +194,39 @@ app.post('/api/google-photos/download-to-nas', verifyToken, async (req, res) =>
|
||||
return res.status(imageResponse.status).json({ error: 'Failed to download from Google Photos' });
|
||||
}
|
||||
|
||||
const buffer = await imageResponse.buffer();
|
||||
const arrayBuffer = await imageResponse.arrayBuffer();
|
||||
const base64 = Buffer.from(arrayBuffer).toString('base64');
|
||||
const safeName = filename || `gphoto_${Date.now()}.jpg`;
|
||||
const tempFilePath = path.join(os.tmpdir(), safeName);
|
||||
fs.writeFileSync(tempFilePath, buffer);
|
||||
const mimeType = imageResponse.headers.get('content-type') || 'image/jpeg';
|
||||
|
||||
const fullRemotePath = `${conn.username}@${conn.host}:${remotePath}/${safeName}`;
|
||||
await runCommand('scp', buildScpArgs(conn, tempFilePath, fullRemotePath));
|
||||
fs.unlinkSync(tempFilePath);
|
||||
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: ${remotePath}/${safeName}`);
|
||||
res.json({ success: true, message: `Photo saved to NAS at ${remotePath}/${safeName}` });
|
||||
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('Download-to-NAS error:', err.message);
|
||||
res.status(500).json({ error: 'Failed to save photo to NAS: ' + err.message });
|
||||
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', verifyToken, (req, res) => {
|
||||
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.forEach((_, id) => connections.delete(id));
|
||||
connections.clear();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
@@ -326,12 +244,10 @@ app.use((req, res) => {
|
||||
|
||||
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`);
|
||||
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;
|
||||
|
||||
Reference in new issue
Block a user