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

+4
View File
@@ -0,0 +1,4 @@
node_modules/
.config/
*.log
+342
View File
@@ -0,0 +1,342 @@
const readline = require('readline');
const os = require('os');
const fs = require('fs');
const path = require('path');
const config = require('./config');
const passwordManager = require('./password_manager');
const { execSync } = require('child_process');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
function ask(question) {
return new Promise(resolve => rl.question(question, resolve));
}
function clearScreen() {
process.stdout.write(process.platform === 'win32' ? '\x1Bc' : '\033[2J\033[H');
}
let detectedPlatform = null;
function printBanner() {
const user = os.userInfo().username;
const photosPath = config.getPhotosPath() || 'NOT SET';
const shortPath = photosPath.length > 25 ? '...' + photosPath.slice(-22) : photosPath;
console.log('');
console.log('╔══════════════════════════════════════════════╗');
console.log('║ NAS Photo Server - CLI Client ║');
console.log('╠══════════════════════════════════════════════╣');
console.log(`║ User: ${user.padEnd(32)}║`);
if (passwordManager.hasStoredPassword()) {
console.log('║ Password: [STORED - Press P to view] ║');
} else {
console.log('║ Password: [NOT SET] ║');
}
console.log(`║ Platform: ${(detectedPlatform || 'Unknown').padEnd(32)}║`);
console.log(`║ Photos: ${shortPath.padEnd(32)}║`);
console.log(`║ Port: ${String(config.DEFAULT_PORT).padEnd(32)}║`);
console.log('╚══════════════════════════════════════════════╝');
console.log('');
}
async function detectOrAskPlatform() {
const plat = process.platform;
if (plat === 'win32') {
detectedPlatform = 'Windows';
console.log('[*] Detected platform: Windows');
return 'windows';
} else if (plat === 'linux') {
detectedPlatform = 'Linux (Ubuntu)';
console.log('[*] Detected platform: Linux (assuming Ubuntu)');
return 'linux';
} else {
console.log('');
console.log('What operating system are you running?');
console.log(' [1] Windows');
console.log(' [2] Linux (Ubuntu)');
const choice = await ask('Select (1/2): ');
if (choice.trim() === '1') {
detectedPlatform = 'Windows';
return 'windows';
} else {
detectedPlatform = 'Linux (Ubuntu)';
return 'linux';
}
}
}
function checkDependencies() {
console.log('[*] Checking dependencies...');
try {
execSync('node --version', { stdio: 'ignore' });
console.log(' [OK] Node.js found');
} catch {
console.log(' [!!] Node.js not found. Please install Node.js.');
return false;
}
const nodeModules = path.join(__dirname, 'node_modules');
if (!fs.existsSync(nodeModules)) {
console.log('[*] Installing dependencies...');
try {
execSync('npm install', { cwd: __dirname, stdio: 'inherit' });
console.log(' [OK] Dependencies installed');
} catch {
console.log(' [!!] Failed to install dependencies. Run "npm install" manually in ServerFiles/');
return false;
}
} else {
console.log(' [OK] Dependencies already installed');
}
return true;
}
async function setupPhotosPath(platform) {
const current = config.getPhotosPath();
if (current) {
console.log(`[*] Current photos path: ${current}`);
const change = await ask('Change photos path? (Y/N): ');
if (change.trim().toUpperCase() !== 'Y') return;
}
let defaultPath;
if (platform === 'windows') {
defaultPath = path.join(os.homedir(), 'NASPhotos');
} else {
defaultPath = path.join(os.homedir(), 'nas-photos');
}
console.log(`Default path: ${defaultPath}`);
const inputPath = await ask('Enter photos storage path (or press Enter for default): ');
const finalPath = inputPath.trim() || defaultPath;
if (!fs.existsSync(finalPath)) {
console.log(`[*] Creating directory: ${finalPath}`);
fs.mkdirSync(finalPath, { recursive: true });
}
config.setPhotosPath(finalPath);
console.log(`[OK] Photos path set to: ${finalPath}`);
}
async function handlePassword() {
console.log('');
console.log('╔══════════════════════════════════════════════╗');
console.log('║ Password Management ║');
console.log('╠══════════════════════════════════════════════╣');
if (passwordManager.hasStoredPassword()) {
console.log('║ A password is currently stored. ║');
console.log('║ [V] View password ║');
console.log('║ [C] Change password ║');
console.log('║ [D] Delete password ║');
console.log('║ [B] Back ║');
console.log('╚══════════════════════════════════════════════╝');
const choice = await ask('Select option: ');
switch (choice.trim().toUpperCase()) {
case 'V': {
const pw = passwordManager.retrievePassword();
if (pw) {
console.log('');
console.log(` User: ${os.userInfo().username}`);
console.log(` Password: ${pw}`);
console.log('');
} else {
console.log('[!!] Could not decrypt password. It may have been created on another machine.');
}
await ask('Press Enter to continue...');
break;
}
case 'C': {
const newPw = await ask('Enter new password: ');
if (newPw.trim().length > 0) {
passwordManager.storePassword(newPw.trim());
console.log('[OK] Password updated.');
console.log(` New password: ${newPw.trim()}`);
} else {
console.log('[!] Empty password not saved.');
}
await ask('Press Enter to continue...');
break;
}
case 'D': {
const confirm = await ask('Are you sure? (Y/N): ');
if (confirm.trim().toUpperCase() === 'Y') {
passwordManager.deletePassword();
console.log('[OK] Password deleted.');
}
await ask('Press Enter to continue...');
break;
}
default:
break;
}
} else {
console.log('║ No password is stored. ║');
console.log('╚══════════════════════════════════════════════╝');
const store = await ask('Do you want to store a password? (Y/N): ');
if (store.trim().toUpperCase() === 'Y') {
const pw = await ask('Enter password: ');
if (pw.trim().length > 0) {
passwordManager.storePassword(pw.trim());
console.log(`[OK] Password stored successfully.`);
console.log(` Your password: ${pw.trim()}`);
} else {
console.log('[!] Empty password not saved.');
}
await ask('Press Enter to continue...');
}
}
}
function showConnectionInfo() {
const interfaces = os.networkInterfaces();
const addresses = [];
for (const [name, nets] of Object.entries(interfaces)) {
for (const net of nets) {
if (net.family === 'IPv4' && !net.internal) {
addresses.push({ name, address: net.address });
}
}
}
console.log('');
console.log('╔══════════════════════════════════════════════╗');
console.log('║ Connection Information ║');
console.log('╠══════════════════════════════════════════════╣');
console.log(`║ User: ${os.userInfo().username.padEnd(35)}║`);
if (passwordManager.hasStoredPassword()) {
console.log('║ Pass: [STORED] ║');
} else {
console.log('║ Pass: [NOT SET] ║');
}
console.log(`║ Port: ${String(config.DEFAULT_PORT).padEnd(35)}║`);
console.log('║ ║');
if (addresses.length > 0) {
console.log('║ Network Addresses: ║');
for (const addr of addresses) {
const line = ` ${addr.name}: http://${addr.address}:${config.DEFAULT_PORT}`;
console.log(`║${line.padEnd(46)}║`);
}
} else {
console.log('║ No network interfaces found ║');
}
console.log('╚══════════════════════════════════════════════╝');
}
async function startServerInteractive() {
const photosPath = config.getPhotosPath();
if (!photosPath) {
console.log('[!!] Photos path not set. Please configure it first.');
await ask('Press Enter to continue...');
return false;
}
const portInput = await ask(`Enter port (default ${config.DEFAULT_PORT}): `);
const port = parseInt(portInput.trim()) || config.DEFAULT_PORT;
console.log(`[*] Starting NAS server on port ${port}...`);
console.log(`[*] Photos path: ${photosPath}`);
console.log('[*] Press Ctrl+C to stop the server.');
console.log('');
const { startServer } = require('./server');
startServer(port);
return true; // Server takes over the process
}
async function mainMenu(platform) {
while (true) {
clearScreen();
printBanner();
console.log(' [1] Start NAS Server');
console.log(' [2] Configure Photos Path');
console.log(' [3] Password Management (P)');
console.log(' [4] Show Connection Info');
console.log(' [5] Exit');
console.log('');
const choice = await ask('Select option: ');
switch (choice.trim().toUpperCase()) {
case '1': {
const started = await startServerInteractive();
if (started) return; // Server is now running
break;
}
case '2':
await setupPhotosPath(platform);
break;
case '3':
case 'P':
await handlePassword();
break;
case '4':
showConnectionInfo();
await ask('\nPress Enter to continue...');
break;
case '5':
console.log('Goodbye!');
rl.close();
process.exit(0);
default:
break;
}
}
}
async function main() {
clearScreen();
console.log('Welcome to NAS Photo Server Setup');
console.log('');
const platform = await detectOrAskPlatform();
console.log('');
if (!checkDependencies()) {
rl.close();
process.exit(1);
}
console.log('');
// First-time setup: photos path
if (!config.getPhotosPath()) {
console.log('[!] No photos path configured. Let\'s set one up.');
await setupPhotosPath(platform);
console.log('');
}
// Ask about password on first run
if (!passwordManager.hasStoredPassword()) {
const storePw = await ask('Do you want to store a password? (Y/N): ');
if (storePw.trim().toUpperCase() === 'Y') {
const pw = await ask('Enter password: ');
if (pw.trim().length > 0) {
passwordManager.storePassword(pw.trim());
console.log(`[OK] Password stored. Your password: ${pw.trim()}`);
}
}
console.log('');
}
await mainMenu(platform);
}
main().catch(err => {
console.error('Fatal error:', err);
process.exit(1);
});
+56
View File
@@ -0,0 +1,56 @@
const path = require('path');
const fs = require('fs');
const CONFIG_DIR = path.join(__dirname, '.config');
const PHOTOS_PATH_FILE = path.join(__dirname, 'nas_config.txt');
const DB_PATH = path.join(CONFIG_DIR, 'nas.db');
const PASSWORD_FILE = path.join(CONFIG_DIR, '.credentials');
const DEFAULT_PORT = 9500;
function ensureConfigDir() {
if (!fs.existsSync(CONFIG_DIR)) {
fs.mkdirSync(CONFIG_DIR, { recursive: true });
}
}
function getPhotosPath() {
if (!fs.existsSync(PHOTOS_PATH_FILE)) return null;
let raw = fs.readFileSync(PHOTOS_PATH_FILE, 'utf-8');
// Strip UTF-8 BOM
raw = raw.replace(/^\uFEFF/, '');
// Strip ALL control characters (CR, LF, null, tabs, zero-width spaces, etc.)
// except forward-slash and backslash which are path separators
raw = raw.replace(/[\x00-\x09\x0B\x0C\x0E-\x1F\x7F\r\n]/g, '');
// Strip zero-width / non-breaking unicode junk that can sneak in from copy-paste
raw = raw.replace(/[\u200B\u200C\u200D\uFEFF\u00A0]/g, '');
raw = raw.trim();
// Take only the first line in case there are multiple lines
raw = raw.split('\n')[0].trim();
if (!raw) return null;
// Return the path exactly as written — do NOT use path.resolve()
// because it mangles Linux paths on Windows and vice versa
return raw;
}
function setPhotosPath(p) {
// Clean and write as plain UTF-8 with unix line ending, no BOM
const clean = p.replace(/[\r\n\x00]/g, '').trim();
fs.writeFileSync(PHOTOS_PATH_FILE, clean, 'utf-8');
}
module.exports = {
CONFIG_DIR,
PHOTOS_PATH_FILE,
DB_PATH,
PASSWORD_FILE,
DEFAULT_PORT,
ensureConfigDir,
getPhotosPath,
setPhotosPath,
};
+320
View File
@@ -0,0 +1,320 @@
const Database = require('better-sqlite3');
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const config = require('./config');
let db = null;
function getDb() {
if (db) return db;
config.ensureConfigDir();
db = new Database(config.DB_PATH);
db.pragma('journal_mode = WAL');
initSchema();
return db;
}
function initSchema() {
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS photos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
filename TEXT NOT NULL,
filepath TEXT NOT NULL,
sha256_hash TEXT NOT NULL,
file_size INTEGER NOT NULL,
mime_type TEXT NOT NULL,
indexed_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id),
UNIQUE(user_id, sha256_hash)
);
CREATE TABLE IF NOT EXISTS albums (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
name TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id),
UNIQUE(user_id, name)
);
CREATE TABLE IF NOT EXISTS album_photos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
album_id INTEGER NOT NULL,
photo_id INTEGER NOT NULL,
added_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (album_id) REFERENCES albums(id) ON DELETE CASCADE,
FOREIGN KEY (photo_id) REFERENCES photos(id) ON DELETE CASCADE,
UNIQUE(album_id, photo_id)
);
CREATE INDEX IF NOT EXISTS idx_photos_user ON photos(user_id);
CREATE INDEX IF NOT EXISTS idx_photos_hash ON photos(sha256_hash);
CREATE INDEX IF NOT EXISTS idx_albums_user ON albums(user_id);
CREATE INDEX IF NOT EXISTS idx_album_photos_album ON album_photos(album_id);
`);
}
function ensureUser(username) {
const d = getDb();
const existing = d.prepare('SELECT id FROM users WHERE username = ?').get(username);
if (existing) return existing.id;
const result = d.prepare('INSERT INTO users (username) VALUES (?)').run(username);
return Number(result.lastInsertRowid);
}
function hashFile(filePath) {
const buffer = fs.readFileSync(filePath);
return crypto.createHash('sha256').update(buffer).digest('hex');
}
function getMimeType(ext) {
const map = {
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
'.gif': 'image/gif', '.bmp': 'image/bmp', '.webp': 'image/webp',
'.heic': 'image/heic', '.heif': 'image/heif',
'.tiff': 'image/tiff', '.tif': 'image/tiff', '.svg': 'image/svg+xml',
};
return map[ext.toLowerCase()] || 'application/octet-stream';
}
const SUPPORTED_EXTS = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.heic', '.heif', '.tiff', '.tif', '.svg'];
function indexPhotosForUser(username, photosBasePath) {
const d = getDb();
const userId = ensureUser(username);
const userDir = path.join(photosBasePath, username);
if (!fs.existsSync(userDir)) {
fs.mkdirSync(userDir, { recursive: true });
return { indexed: 0, skipped: 0, errors: 0, total: 0 };
}
const files = getAllMediaFiles(userDir);
let indexed = 0, skipped = 0, errors = 0;
const insertStmt = d.prepare(`
INSERT OR IGNORE INTO photos (user_id, filename, filepath, sha256_hash, file_size, mime_type)
VALUES (?, ?, ?, ?, ?, ?)
`);
for (const fullPath of files) {
try {
const stat = fs.statSync(fullPath);
const hash = hashFile(fullPath);
const ext = path.extname(fullPath);
const mime = getMimeType(ext);
const filename = path.basename(fullPath);
const result = insertStmt.run(userId, filename, fullPath, hash, stat.size, mime);
if (result.changes > 0) {
indexed++;
} else {
skipped++;
}
} catch (err) {
errors++;
}
}
return { indexed, skipped, errors, total: files.length };
}
// Recursively find all media files in a directory
function getAllMediaFiles(dir) {
let results = [];
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
results = results.concat(getAllMediaFiles(fullPath));
} else {
const ext = path.extname(entry.name).toLowerCase();
if (SUPPORTED_EXTS.includes(ext)) {
results.push(fullPath);
}
}
}
return results;
}
function getPhotoIndex(username) {
const d = getDb();
const user = d.prepare('SELECT id FROM users WHERE username = ?').get(username);
if (!user) return [];
return d.prepare(`
SELECT id, filename, sha256_hash, file_size, mime_type, indexed_at
FROM photos WHERE user_id = ? ORDER BY indexed_at DESC
`).all(user.id);
}
function getPhotoById(photoId) {
const d = getDb();
return d.prepare(`
SELECT p.*, u.username FROM photos p
JOIN users u ON p.user_id = u.id
WHERE p.id = ?
`).get(photoId);
}
function getPhotoByHash(hash) {
const d = getDb();
return d.prepare(`
SELECT p.*, u.username FROM photos p
JOIN users u ON p.user_id = u.id
WHERE p.sha256_hash = ?
`).get(hash);
}
function getAllUsers() {
const d = getDb();
return d.prepare('SELECT * FROM users ORDER BY username').all();
}
function removeOrphanedEntries(username, photosBasePath) {
const d = getDb();
const user = d.prepare('SELECT id FROM users WHERE username = ?').get(username);
if (!user) return 0;
const photos = d.prepare('SELECT id, filepath FROM photos WHERE user_id = ?').all(user.id);
let removed = 0;
const deleteStmt = d.prepare('DELETE FROM photos WHERE id = ?');
for (const photo of photos) {
if (!fs.existsSync(photo.filepath)) {
deleteStmt.run(photo.id);
removed++;
}
}
return removed;
}
function deletePhoto(photoId) {
const d = getDb();
const photo = getPhotoById(photoId);
if (!photo) return { success: false, error: 'Photo not found' };
// Remove from disk
if (fs.existsSync(photo.filepath)) {
fs.unlinkSync(photo.filepath);
}
// Remove from album_photos first (cascade might not work with all builds)
d.prepare('DELETE FROM album_photos WHERE photo_id = ?').run(photoId);
d.prepare('DELETE FROM photos WHERE id = ?').run(photoId);
return { success: true };
}
// ─── Album operations ───
function createAlbum(username, albumName) {
const d = getDb();
const userId = ensureUser(username);
try {
const result = d.prepare('INSERT INTO albums (user_id, name) VALUES (?, ?)').run(userId, albumName);
return { success: true, albumId: Number(result.lastInsertRowid) };
} catch (err) {
if (err.message.includes('UNIQUE')) {
return { success: false, error: 'Album already exists' };
}
throw err;
}
}
function getAlbums(username) {
const d = getDb();
const user = d.prepare('SELECT id FROM users WHERE username = ?').get(username);
if (!user) return [];
const albums = d.prepare('SELECT * FROM albums WHERE user_id = ? ORDER BY created_at DESC').all(user.id);
return albums.map(album => {
const count = d.prepare('SELECT COUNT(*) as cnt FROM album_photos WHERE album_id = ?').get(album.id);
const cover = d.prepare(`
SELECT p.id, p.filename, p.sha256_hash FROM album_photos ap
JOIN photos p ON ap.photo_id = p.id
WHERE ap.album_id = ? ORDER BY ap.added_at DESC LIMIT 1
`).get(album.id);
return { ...album, photoCount: count.cnt, coverPhoto: cover || null };
});
}
function getAlbumPhotos(albumId) {
const d = getDb();
return d.prepare(`
SELECT p.id, p.filename, p.sha256_hash, p.file_size, p.mime_type, p.indexed_at
FROM album_photos ap
JOIN photos p ON ap.photo_id = p.id
WHERE ap.album_id = ?
ORDER BY ap.added_at DESC
`).all(albumId);
}
function addPhotoToAlbum(albumId, photoId) {
const d = getDb();
try {
d.prepare('INSERT OR IGNORE INTO album_photos (album_id, photo_id) VALUES (?, ?)').run(albumId, photoId);
return { success: true };
} catch (err) {
return { success: false, error: err.message };
}
}
function removePhotoFromAlbum(albumId, photoId) {
const d = getDb();
d.prepare('DELETE FROM album_photos WHERE album_id = ? AND photo_id = ?').run(albumId, photoId);
return { success: true };
}
function deleteAlbum(albumId) {
const d = getDb();
d.prepare('DELETE FROM album_photos WHERE album_id = ?').run(albumId);
d.prepare('DELETE FROM albums WHERE id = ?').run(albumId);
return { success: true };
}
function renameAlbum(albumId, newName) {
const d = getDb();
try {
d.prepare('UPDATE albums SET name = ? WHERE id = ?').run(newName, albumId);
return { success: true };
} catch (err) {
return { success: false, error: err.message };
}
}
function closeDb() {
if (db) {
db.close();
db = null;
}
}
module.exports = {
getDb,
ensureUser,
hashFile,
indexPhotosForUser,
getPhotoIndex,
getPhotoById,
getPhotoByHash,
getAllUsers,
removeOrphanedEntries,
deletePhoto,
createAlbum,
getAlbums,
getAlbumPhotos,
addPhotoToAlbum,
removePhotoFromAlbum,
deleteAlbum,
renameAlbum,
closeDb,
};
+1 -2
View File
@@ -1,2 +1 @@
/home/user/photos
/home/bob/photos
+10 -17
View File
@@ -1,31 +1,24 @@
{
"name": "media-app-sftp-backend",
"name": "nas-photo-server",
"version": "1.0.0",
"description": "SFTP Backend API for Media Aggregation App",
"description": "Self-contained NAS Photo Server with REST API and CLI",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js",
"test": "echo \"Error: no test specified\" && exit 1"
"cli": "node cli.js",
"dev": "node cli.js"
},
"keywords": [
"sftp",
"backend",
"api",
"express"
"nas",
"photo-server",
"rest-api",
"base64"
],
"author": "",
"license": "MIT",
"dependencies": {
"body-parser": "^2.2.2",
"better-sqlite3": "^11.7.0",
"cors": "^2.8.6",
"dotenv": "^16.6.1",
"express": "^4.22.1",
"express-fileupload": "^1.4.0",
"jsonwebtoken": "^9.0.2",
"node-fetch": "^2.7.0"
},
"devDependencies": {
"nodemon": "^3.0.1"
"express": "^4.22.1"
}
}
+101
View File
@@ -0,0 +1,101 @@
const crypto = require('crypto');
const fs = require('fs');
const config = require('./config');
const ALGORITHM = 'aes-256-gcm';
const KEY_LENGTH = 32;
const IV_LENGTH = 16;
const SALT_LENGTH = 32;
function deriveKey(passphrase, salt) {
return crypto.pbkdf2Sync(passphrase, salt, 100000, KEY_LENGTH, 'sha512');
}
function getMachineId() {
const os = require('os');
const raw = `${os.hostname()}-${os.platform()}-${os.arch()}-${os.userInfo().username}`;
return crypto.createHash('sha256').update(raw).digest('hex');
}
function encrypt(plaintext) {
const machineKey = getMachineId();
const salt = crypto.randomBytes(SALT_LENGTH);
const key = deriveKey(machineKey, salt);
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
let encrypted = cipher.update(plaintext, 'utf-8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
return {
salt: salt.toString('hex'),
iv: iv.toString('hex'),
authTag: authTag.toString('hex'),
data: encrypted,
};
}
function decrypt(encObj) {
const machineKey = getMachineId();
const salt = Buffer.from(encObj.salt, 'hex');
const key = deriveKey(machineKey, salt);
const iv = Buffer.from(encObj.iv, 'hex');
const authTag = Buffer.from(encObj.authTag, 'hex');
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encObj.data, 'hex', 'utf-8');
decrypted += decipher.final('utf-8');
return decrypted;
}
function storePassword(password) {
config.ensureConfigDir();
const encrypted = encrypt(password);
const content = JSON.stringify(encrypted);
fs.writeFileSync(config.PASSWORD_FILE, content, { encoding: 'utf-8', mode: 0o600 });
// On Windows, hide the file with attrib
if (process.platform === 'win32') {
try {
const { execSync } = require('child_process');
execSync(`attrib +h "${config.PASSWORD_FILE}"`, { stdio: 'ignore' });
} catch (_) {}
}
return true;
}
function retrievePassword() {
if (!fs.existsSync(config.PASSWORD_FILE)) return null;
try {
const content = fs.readFileSync(config.PASSWORD_FILE, 'utf-8');
const encObj = JSON.parse(content);
return decrypt(encObj);
} catch (err) {
return null;
}
}
function hasStoredPassword() {
return fs.existsSync(config.PASSWORD_FILE);
}
function deletePassword() {
if (fs.existsSync(config.PASSWORD_FILE)) {
fs.unlinkSync(config.PASSWORD_FILE);
return true;
}
return false;
}
module.exports = {
storePassword,
retrievePassword,
hasStoredPassword,
deletePassword,
};
+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 };