feat: update NAS configuration and enhance UI components for better user experience

This commit is contained in:
bobbert committed 2026-03-04 21:52:52 +00:00
1 parent 8e6fa5270b
commit 6167418074
23 files changed
+2147 -702

No files matched your search

+326 -318
View File
@@ -1,376 +1,384 @@
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'];
// Read the default NAS photo directory from the config file
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 (_) {
// Config file missing — keep the hard-coded default
}
// SSH/SFTP shell helpers
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);
}
}
// Express setup
const path = require('path');
const crypto = require('crypto');
const config = require('./config');
const db = require('./db');
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(express.json({ limit: '100mb' }));
app.use((req, res, next) => {
console.log(`[${new Date().toISOString()}] ${req.method} ${req.path}`);
next();
});
// JWT verification middleware
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 });
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;
}
// ─── REST Endpoints ──────────────────────────────────────────────
function sanitizeUsername(username) {
return username.trim().replace(/[^a-zA-Z0-9_\-.]/g, '_');
}
// Health check
// ─── Health ──────────────────────────────────────────────────
app.get('/api/health', (req, res) => {
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
activeConnections: connections.size,
defaultPhotoDir: DEFAULT_PHOTO_DIR,
});
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Return the current NAS photo directory read from nas_config.txt
app.get('/api/config/photo-dir', (req, res) => {
res.json({ photoDir: DEFAULT_PHOTO_DIR });
});
// Establish an SFTP connection (stores credentials, returns a JWT)
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,
});
});
// List directory contents
app.post('/api/sftp/list', verifyToken, async (req, res) => {
const { path: dirPath = DEFAULT_PHOTO_DIR } = req.body;
const conn = req.connection;
// ─── User Registration ──────────────────────────────────────
// Device sends its username; NAS registers and creates user folder
app.post('/api/register', (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 { 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('List directory error:', err.message);
res.status(500).json({ error: 'Failed to list directory: ' + err.message });
res.status(500).json({ error: err.message });
}
});
// Download a file from the NAS
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);
// ─── Index (NAS builds its database) ────────────────────────
app.post('/api/index', (req, res) => {
try {
await runCommand('scp', buildScpArgs(conn, `${conn.username}@${conn.host}:${filePath}`, tempFilePath));
res.download(tempFilePath, filename, () => {
try { fs.unlinkSync(tempFilePath); } catch (_) { /* already cleaned */ }
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('Download error:', err.message);
res.status(500).json({ error: 'Download failed: ' + err.message });
res.status(500).json({ error: err.message });
}
});
// Upload a file to the NAS
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);
// ─── Photo List (POST fetch cycle) ─────────────────────────
app.post('/api/photos', (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' });
} catch (err) {
console.error('Upload error:', err.message);
res.status(500).json({ error: 'Upload failed: ' + err.message });
}
});
const { username } = req.body;
if (!username) return res.status(400).json({ error: 'Username is required' });
// Create a directory on the NAS
const sanitized = sanitizeUsername(username);
const photos = db.getPhotoIndex(sanitized);
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 a file on the NAS
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 });
}
});
// Disconnect from the NAS
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' });
});
// ─── Google Photos → NAS Proxy Endpoints ─────────────────────────
// Fetch media items from Google Photos on behalf of the mobile client
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 });
}
});
// Download a Google Photos item and save it directly to the NAS
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}` },
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,
})),
});
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 });
res.status(500).json({ error: err.message });
}
});
// Sync status (placeholder for future batch sync)
// ─── Fetch Single Photo as Base64 with hash ─────────────────
app.post('/api/google-photos/sync-status', verifyToken, (req, res) => {
res.json({ success: true, status: 'idle', message: 'No active sync in progress', lastSync: null });
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) {
res.status(500).json({ error: err.message });
}
});
// ─── Lifecycle ───────────────────────────────────────────────────
// ─── Upload Photo from Device to NAS ────────────────────────
process.on('SIGINT', () => {
console.log('\nShutting down...');
connections.forEach((_, id) => connections.delete(id));
process.exit(0);
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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) {
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',
message: process.env.NODE_ENV === 'development' ? err.message : 'Unknown error',
});
res.status(500).json({ error: 'Internal server 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`);
// ─── 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;
module.exports = { app, startServer };