From 6167418074155c2d9b40fcf5eeeaab0f21edd85a Mon Sep 17 00:00:00 2001 From: mudabbir-ahmad Date: Wed, 4 Mar 2026 21:52:52 +0000 Subject: [PATCH] feat: update NAS configuration and enhance UI components for better user experience --- ServerFiles/.gitignore | 4 + ServerFiles/cli.js | 342 +++++++++++++ ServerFiles/config.js | 56 +++ ServerFiles/db.js | 320 ++++++++++++ ServerFiles/nas_config.txt | 3 +- ServerFiles/package.json | 27 +- ServerFiles/password_manager.js | 101 ++++ ServerFiles/server.js | 644 +++++++++++++------------ src/API/NASAPI.js | 265 ++++++---- src/AUTH/NASAUTH.js | 95 ++-- src/UI/EmptyState.js | 35 ++ src/UI/FilterChips.js | 52 ++ src/UI/ScreenHeader.js | 45 ++ src/UI/ServiceHelpers.js | 13 + src/components/MediaViewer.js | 68 ++- src/components/NASSetupInstructions.js | 51 +- src/components/PhotoActionSheet.js | 78 +++ src/components/PhotoGrid.js | 5 +- src/screens/AlbumsScreen.js | 336 +++++++++++-- src/screens/HomeScreen.js | 230 +++++---- src/screens/OnboardingScreen.js | 29 +- src/screens/SettingsScreen.js | 39 +- src/services/DatabaseService.js | 11 + 23 files changed, 2147 insertions(+), 702 deletions(-) create mode 100644 ServerFiles/.gitignore create mode 100644 ServerFiles/cli.js create mode 100644 ServerFiles/config.js create mode 100644 ServerFiles/db.js create mode 100644 ServerFiles/password_manager.js create mode 100644 src/UI/EmptyState.js create mode 100644 src/UI/FilterChips.js create mode 100644 src/UI/ScreenHeader.js create mode 100644 src/UI/ServiceHelpers.js create mode 100644 src/components/PhotoActionSheet.js diff --git a/ServerFiles/.gitignore b/ServerFiles/.gitignore new file mode 100644 index 0000000..1cb632c --- /dev/null +++ b/ServerFiles/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.config/ +*.log + diff --git a/ServerFiles/cli.js b/ServerFiles/cli.js new file mode 100644 index 0000000..20b465d --- /dev/null +++ b/ServerFiles/cli.js @@ -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); +}); + diff --git a/ServerFiles/config.js b/ServerFiles/config.js new file mode 100644 index 0000000..30bc184 --- /dev/null +++ b/ServerFiles/config.js @@ -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, +}; + diff --git a/ServerFiles/db.js b/ServerFiles/db.js new file mode 100644 index 0000000..e6559e6 --- /dev/null +++ b/ServerFiles/db.js @@ -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, +}; + diff --git a/ServerFiles/nas_config.txt b/ServerFiles/nas_config.txt index 5acf427..8ad8882 100644 --- a/ServerFiles/nas_config.txt +++ b/ServerFiles/nas_config.txt @@ -1,2 +1 @@ -/home/user/photos - +/home/bob/photos \ No newline at end of file diff --git a/ServerFiles/package.json b/ServerFiles/package.json index a061fb1..821cd09 100644 --- a/ServerFiles/package.json +++ b/ServerFiles/package.json @@ -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" } } diff --git a/ServerFiles/password_manager.js b/ServerFiles/password_manager.js new file mode 100644 index 0000000..3ca7ff9 --- /dev/null +++ b/ServerFiles/password_manager.js @@ -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, +}; + diff --git a/ServerFiles/server.js b/ServerFiles/server.js index 9bc3045..bd91602 100644 --- a/ServerFiles/server.js +++ b/ServerFiles/server.js @@ -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 }; diff --git a/src/API/NASAPI.js b/src/API/NASAPI.js index 8707648..ebdf5d8 100644 --- a/src/API/NASAPI.js +++ b/src/API/NASAPI.js @@ -1,135 +1,198 @@ import axios from 'axios'; -// HTTP client that communicates with the SFTP backend (ServerFiles/server.js) -const DEFAULT_BACKEND_URL = 'http://192.168.1.100:3001'; +// REST client that communicates with the NAS Photo Server (ServerFiles/server.js) +const DEFAULT_PORT = '9500'; class NASAPI { - constructor(backendUrl = DEFAULT_BACKEND_URL) { - this.backendUrl = backendUrl; - this.connectionToken = null; + constructor() { + this.baseUrl = null; + this.username = null; this.isConnected = false; - this.credentials = null; } - // Establish a connection via the backend; falls back to local-only config if the backend is unreachable - async connect(host, port, username, password, photoFolder = '/Photos') { - if (!host || !username || !password) throw new Error('Host, username, and password are required'); + configure(host, port, username) { + this.baseUrl = `http://${host}:${port || DEFAULT_PORT}`; + this.username = username; + } - this.credentials = { host, port: port || 22, username, photoFolder }; + async connect(host, port, username) { + if (!host || !username) throw new Error('Host and username are required'); + this.configure(host, port, username); + + // Register user on NAS — NAS creates user folder and DB entry + const response = await axios.post( + `${this.baseUrl}/api/register`, + { username: this.username }, + { timeout: 10000 }, + ); + + if (response.data?.success) { + this.isConnected = true; + return { + success: true, + message: `Connected to NAS at ${host}`, + userId: response.data.userId, + username: response.data.username, + }; + } + throw new Error('Failed to register with NAS'); + } + + async healthCheck() { + if (!this.baseUrl) return false; try { - const response = await axios.post( - `${this.backendUrl}/api/sftp/connect`, - { host, port: port || 22, username, password }, - { timeout: 10000 } - ); - if (response.data?.success) { - this.connectionToken = response.data.connectionToken; - this.isConnected = true; - return { success: true, message: `Connected to ${host}` }; - } - } catch (_) { - console.log('Backend not available, storing NAS credentials locally'); - } - - this.isConnected = true; - return { success: true, message: `NAS configured: ${host} (backend connection pending)`, connectionData: this.credentials }; - } - - getHeaders() { - if (!this.connectionToken) return {}; - return { Authorization: `Bearer ${this.connectionToken}` }; - } - - async listDirectory(path = '/') { - if (!this.connectionToken) return { path, files: [], message: 'Backend not connected' }; - const response = await axios.post( - `${this.backendUrl}/api/sftp/list`, { path }, - { headers: this.getHeaders(), timeout: 15000 } - ); - return response.data; - } - - // Filter directory listing for supported photo/video files - async getPhotos(folder = null) { - const targetFolder = folder || this.credentials?.photoFolder || '/Photos'; - if (!this.connectionToken) return { mediaItems: [], folder: targetFolder, message: 'Backend not connected' }; - - try { - const listing = await this.listDirectory(targetFolder); - const photoExts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.heic']; - const videoExts = ['.mp4', '.mov', '.avi', '.mkv', '.webm']; - - const mediaItems = (listing.files || []) - .filter((file) => { - const ext = '.' + (file.name || '').toLowerCase().split('.').pop(); - return photoExts.includes(ext) || videoExts.includes(ext); - }) - .map((file) => { - const ext = '.' + (file.name || '').toLowerCase().split('.').pop(); - return { - source_service: 'NAS', - source_id: `nas_${file.name}`, - title: file.name, - remote_path: `${targetFolder}/${file.name}`, - media_type: videoExts.includes(ext) ? 'video' : 'photo', - created_date: file.modifyTime, - size: file.size, - }; - }); - - return { mediaItems, folder: targetFolder }; - } catch (error) { - console.error('Get photos error:', error.message); - return { mediaItems: [], folder: targetFolder }; + const response = await axios.get(`${this.baseUrl}/api/health`, { timeout: 5000 }); + return response.data?.status === 'ok'; + } catch { + return false; } } - async uploadFile(localPath, remotePath) { - if (!this.connectionToken) throw new Error('Backend not connected — cannot upload'); - const formData = new FormData(); - formData.append('remotePath', remotePath); - formData.append('file', { uri: localPath, name: remotePath.split('/').pop(), type: 'application/octet-stream' }); - + // Full refresh cycle: POST to re-index + fetch updated photo list + async refreshPhotos() { + if (!this.username) throw new Error('Not connected'); const response = await axios.post( - `${this.backendUrl}/api/sftp/upload`, formData, - { headers: { ...this.getHeaders(), 'Content-Type': 'multipart/form-data' }, timeout: 60000 } + `${this.baseUrl}/api/refresh`, + { username: this.username }, + { timeout: 30000 }, ); return response.data; } - async createDirectory(remotePath) { - if (!this.connectionToken) throw new Error('Backend not connected'); + // Fetch photo index only (no re-index) + async getPhotos() { + if (!this.username) throw new Error('Not connected'); const response = await axios.post( - `${this.backendUrl}/api/sftp/mkdir`, { path: remotePath }, - { headers: this.getHeaders(), timeout: 10000 } + `${this.baseUrl}/api/photos`, + { username: this.username }, + { timeout: 15000 }, ); return response.data; } - async deleteFile(remotePath) { - if (!this.connectionToken) throw new Error('Backend not connected'); + // Fetch a single photo as base64 with hash verification + async fetchPhoto(photoId) { const response = await axios.post( - `${this.backendUrl}/api/sftp/delete`, { path: remotePath }, - { headers: this.getHeaders(), timeout: 10000 } + `${this.baseUrl}/api/photo/fetch`, + { photoId }, + { timeout: 60000 }, ); - return response.data; - } - - async disconnect() { - if (this.connectionToken) { - try { - await axios.post(`${this.backendUrl}/api/sftp/disconnect`, {}, { headers: this.getHeaders(), timeout: 5000 }); - } catch (_) { /* ignore */ } + if (!response.data?.success || !response.data?.photo) { + throw new Error('Failed to fetch photo'); } - this.connectionToken = null; + return response.data.photo; + } + + // Upload a photo from device to NAS as base64 + async uploadPhoto(filename, base64Data) { + if (!this.username) throw new Error('Not connected'); + const response = await axios.post( + `${this.baseUrl}/api/photo/upload`, + { username: this.username, filename, base64: base64Data }, + { timeout: 120000 }, + ); + return response.data; + } + + // Delete a photo from NAS + async deletePhoto(photoId) { + const response = await axios.post( + `${this.baseUrl}/api/photo/delete`, + { photoId }, + { timeout: 10000 }, + ); + return response.data; + } + + // Re-index only + async reIndex() { + if (!this.username) throw new Error('Not connected'); + const response = await axios.post( + `${this.baseUrl}/api/index`, + { username: this.username }, + { timeout: 30000 }, + ); + return response.data; + } + + // ─── Album operations ─── + + async getAlbums() { + if (!this.username) throw new Error('Not connected'); + const response = await axios.post( + `${this.baseUrl}/api/albums`, + { username: this.username }, + { timeout: 10000 }, + ); + return response.data; + } + + async createAlbum(name) { + if (!this.username) throw new Error('Not connected'); + const response = await axios.post( + `${this.baseUrl}/api/album/create`, + { username: this.username, name }, + { timeout: 10000 }, + ); + return response.data; + } + + async getAlbumPhotos(albumId) { + const response = await axios.post( + `${this.baseUrl}/api/album/photos`, + { albumId }, + { timeout: 15000 }, + ); + return response.data; + } + + async addPhotoToAlbum(albumId, photoId) { + const response = await axios.post( + `${this.baseUrl}/api/album/add-photo`, + { albumId, photoId }, + { timeout: 10000 }, + ); + return response.data; + } + + async removePhotoFromAlbum(albumId, photoId) { + const response = await axios.post( + `${this.baseUrl}/api/album/remove-photo`, + { albumId, photoId }, + { timeout: 10000 }, + ); + return response.data; + } + + async deleteAlbum(albumId) { + const response = await axios.post( + `${this.baseUrl}/api/album/delete`, + { albumId }, + { timeout: 10000 }, + ); + return response.data; + } + + async renameAlbum(albumId, name) { + const response = await axios.post( + `${this.baseUrl}/api/album/rename`, + { albumId, name }, + { timeout: 10000 }, + ); + return response.data; + } + + disconnect() { + this.baseUrl = null; + this.username = null; this.isConnected = false; - this.credentials = null; return { success: true }; } getConnectionInfo() { - return this.credentials ? { ...this.credentials, isConnected: this.isConnected } : null; + if (!this.baseUrl) return null; + return { baseUrl: this.baseUrl, username: this.username, isConnected: this.isConnected }; } } diff --git a/src/AUTH/NASAUTH.js b/src/AUTH/NASAUTH.js index 6b50d31..ada779e 100644 --- a/src/AUTH/NASAUTH.js +++ b/src/AUTH/NASAUTH.js @@ -1,33 +1,34 @@ import { AuthServiceBase } from './AuthServiceBase'; import NASAPI from '../API/NASAPI'; -// NAS SFTP authentication — connects to the backend server via NASAPI +// NAS authentication — connects to the NAS REST server via NASAPI class NASAUTH extends AuthServiceBase { constructor() { super(); this.nasAPI = new NASAPI(); this.host = null; - this.port = 22; + this.port = null; this.username = null; - this.password = null; - this.photoFolder = null; this.isConnected = false; } - async authenticate(host, port = 22, username, password, photoFolder = '/Photos') { + async authenticate(host, port = 9500, username) { try { - if (!host || !username || !password) throw new Error('Host, username, and password are required'); + if (!host || !username) throw new Error('Host and username are required'); this.host = host; - this.port = port || 22; + this.port = port || 9500; this.username = username; - this.password = password; - this.photoFolder = photoFolder; - await this.nasAPI.connect(host, this.port, username, password, photoFolder); + await this.nasAPI.connect(host, this.port, username); this.isConnected = true; - return { success: true, message: `NAS configured: ${host}`, host: this.host, username: this.username }; + return { + success: true, + message: `NAS connected: ${host}`, + host: this.host, + username: this.username, + }; } catch (error) { console.error('NAS authentication error:', error.message); this.isConnected = false; @@ -41,23 +42,21 @@ class NASAUTH extends AuthServiceBase { } async refreshToken() { - if (this.host && this.username && this.password) { - return await this.authenticate(this.host, this.port, this.username, this.password, this.photoFolder); + if (this.host && this.username) { + return await this.authenticate(this.host, this.port, this.username); } throw new Error('No NAS credentials available for reconnection'); } async revokeAccess() { try { - await this.nasAPI.disconnect(); + this.nasAPI.disconnect(); } catch (error) { console.error('Disconnect error:', error.message); } this.isConnected = false; this.host = null; this.username = null; - this.password = null; - this.photoFolder = null; return { success: true, message: 'NAS connection closed' }; } @@ -67,35 +66,75 @@ class NASAUTH extends AuthServiceBase { host: this.host, username: this.username, port: this.port, - photoFolder: this.photoFolder, connectedAt: new Date().toISOString(), - serverType: 'NAS SFTP', + serverType: 'NAS REST', }; } - async listDirectory(remotePath = '/') { + // Full refresh: re-index on NAS + return photo list + async refreshPhotos() { if (!this.isConnected) throw new Error('Not connected to NAS'); - return await this.nasAPI.listDirectory(remotePath); + return await this.nasAPI.refreshPhotos(); } - async uploadFile(localPath, remotePath) { + // Fetch photo list only + async getPhotos() { if (!this.isConnected) throw new Error('Not connected to NAS'); - return await this.nasAPI.uploadFile(localPath, remotePath); + return await this.nasAPI.getPhotos(); } - async getPhotos(photoFolder = null) { + // Fetch single photo as base64 data URI + async fetchPhoto(photoId) { if (!this.isConnected) throw new Error('Not connected to NAS'); - return await this.nasAPI.getPhotos(photoFolder || this.photoFolder || '/Photos'); + return await this.nasAPI.fetchPhoto(photoId); } - async createDirectory(remotePath) { + // Upload photo + async uploadPhoto(filename, base64Data) { if (!this.isConnected) throw new Error('Not connected to NAS'); - return await this.nasAPI.createDirectory(remotePath); + return await this.nasAPI.uploadPhoto(filename, base64Data); } - async deleteFile(remotePath) { + // Delete photo from NAS + async deletePhoto(photoId) { if (!this.isConnected) throw new Error('Not connected to NAS'); - return await this.nasAPI.deleteFile(remotePath); + return await this.nasAPI.deletePhoto(photoId); + } + + // Health check + async healthCheck() { + return await this.nasAPI.healthCheck(); + } + + // Album operations + async getAlbums() { + if (!this.isConnected) throw new Error('Not connected to NAS'); + return await this.nasAPI.getAlbums(); + } + + async createAlbum(name) { + if (!this.isConnected) throw new Error('Not connected to NAS'); + return await this.nasAPI.createAlbum(name); + } + + async getAlbumPhotos(albumId) { + if (!this.isConnected) throw new Error('Not connected to NAS'); + return await this.nasAPI.getAlbumPhotos(albumId); + } + + async addPhotoToAlbum(albumId, photoId) { + if (!this.isConnected) throw new Error('Not connected to NAS'); + return await this.nasAPI.addPhotoToAlbum(albumId, photoId); + } + + async removePhotoFromAlbum(albumId, photoId) { + if (!this.isConnected) throw new Error('Not connected to NAS'); + return await this.nasAPI.removePhotoFromAlbum(albumId, photoId); + } + + async deleteAlbum(albumId) { + if (!this.isConnected) throw new Error('Not connected to NAS'); + return await this.nasAPI.deleteAlbum(albumId); } } diff --git a/src/UI/EmptyState.js b/src/UI/EmptyState.js new file mode 100644 index 0000000..815e3f0 --- /dev/null +++ b/src/UI/EmptyState.js @@ -0,0 +1,35 @@ +import React from 'react'; +import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'; +import { useTheme } from '../services/ThemeContext'; + +// Reusable empty-state placeholder shown when a list has no items +const EmptyState = ({ icon = '📭', title, message, actionLabel, onAction }) => { + const { theme } = useTheme(); + + return ( + + {icon} + {title} + {message ? ( + {message} + ) : null} + {actionLabel && onAction ? ( + + {actionLabel} + + ) : null} + + ); +}; + +const styles = StyleSheet.create({ + container: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 32, paddingTop: 60 }, + icon: { fontSize: 56, marginBottom: 16 }, + title: { fontSize: 18, fontWeight: '600', marginBottom: 6, textAlign: 'center' }, + message: { fontSize: 13, textAlign: 'center', lineHeight: 20, marginBottom: 20 }, + button: { paddingHorizontal: 24, paddingVertical: 10, borderRadius: 20 }, + buttonText: { color: '#fff', fontSize: 14, fontWeight: '600' }, +}); + +export default EmptyState; + diff --git a/src/UI/FilterChips.js b/src/UI/FilterChips.js new file mode 100644 index 0000000..38c346d --- /dev/null +++ b/src/UI/FilterChips.js @@ -0,0 +1,52 @@ +import React from 'react'; +import { ScrollView, TouchableOpacity, Text, StyleSheet } from 'react-native'; +import { useTheme } from '../services/ThemeContext'; + +// Reusable horizontal filter chip row — used in HomeScreen, AlbumsScreen, etc. +const FilterChips = ({ filters, selectedKey, onSelect }) => { + const { theme } = useTheme(); + + return ( + + onSelect('all')} + > + + All + + + {filters.map((f) => ( + onSelect(f.key)} + > + {f.icon ? {f.icon} : null} + + {f.name} + + + ))} + + ); +}; + +const styles = StyleSheet.create({ + container: { maxHeight: 40 }, + row: { paddingHorizontal: 12, paddingVertical: 6, gap: 6, alignItems: 'center' }, + chip: { + flexDirection: 'row', alignItems: 'center', paddingHorizontal: 10, paddingVertical: 4, + borderRadius: 16, gap: 4, height: 28, + }, + chipIcon: { fontSize: 11 }, + chipText: { fontSize: 12, fontWeight: '500', color: '#3c4043' }, +}); + +export default FilterChips; + diff --git a/src/UI/ScreenHeader.js b/src/UI/ScreenHeader.js new file mode 100644 index 0000000..3cc6afe --- /dev/null +++ b/src/UI/ScreenHeader.js @@ -0,0 +1,45 @@ +import React from 'react'; +import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'; +import { useTheme } from '../services/ThemeContext'; + +// Reusable inline header bar with back arrow and title +const ScreenHeader = ({ title, subtitle, onBack, rightAction }) => { + const { theme } = useTheme(); + + return ( + + {onBack ? ( + + ← + + ) : ( + + )} + + {title} + {subtitle ? {subtitle} : null} + + {rightAction || } + + ); +}; + +const styles = StyleSheet.create({ + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingTop: 52, + paddingBottom: 12, + borderBottomWidth: 1, + }, + backButton: { width: 32 }, + backArrow: { fontSize: 24 }, + titleWrap: { flex: 1, alignItems: 'center' }, + title: { fontSize: 18, fontWeight: '700' }, + subtitle: { fontSize: 12, marginTop: 2 }, +}); + +export default ScreenHeader; + diff --git a/src/UI/ServiceHelpers.js b/src/UI/ServiceHelpers.js new file mode 100644 index 0000000..a1d00a1 --- /dev/null +++ b/src/UI/ServiceHelpers.js @@ -0,0 +1,13 @@ +// Shared service helpers — avoids duplicating icon/name maps across components and screens + +export const getServiceIcon = (type) => + ({ DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱'); + +export const getServiceName = (type) => + ({ + DEVICE: 'On Device', + GOOGLE_PHOTOS: 'Google Photos', + ICLOUD_PHOTOS: 'iCloud Photos', + NAS: 'NAS Storage', + }[type] || type); + diff --git a/src/components/MediaViewer.js b/src/components/MediaViewer.js index 8575906..b081873 100644 --- a/src/components/MediaViewer.js +++ b/src/components/MediaViewer.js @@ -4,26 +4,25 @@ import { Dimensions, FlatList, } from 'react-native'; import { useTheme } from '../services/ThemeContext'; +import { getServiceIcon, getServiceName } from '../UI/ServiceHelpers'; const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window'); const THUMB_SIZE = 60; const THUMB_GAP = 4; -const getServiceIcon = (type) => - ({ DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱'); -const getServiceName = (type) => - ({ DEVICE: 'On Device', GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud', NAS: 'NAS' }[type] || type); - /** - * Fullscreen media viewer with thumbnail carousel. + * Fullscreen media viewer with thumbnail carousel and action toolbar. * * Props: - * visible – boolean to show/hide the modal - * media – currently selected media item - * allMedia – (optional) full array of media items for carousel navigation - * onClose – callback when user dismisses the viewer + * visible – boolean to show/hide the modal + * media – currently selected media item + * allMedia – (optional) full array of media items for carousel navigation + * onClose – callback when user dismisses the viewer + * onDelete – (optional) callback(item) to delete the current item + * onAddToAlbum – (optional) callback(item) to add current item to an album + * onShare – (optional) callback(item) to share current item */ -const MediaViewer = ({ visible, media, allMedia, onClose }) => { +const MediaViewer = ({ visible, media, allMedia, onClose, onDelete, onAddToAlbum, onShare }) => { const { theme } = useTheme(); const mainListRef = useRef(null); const thumbListRef = useRef(null); @@ -185,6 +184,28 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => { {/* Info panel */} + {/* Action toolbar */} + + {onShare && ( + onShare(displayMedia)}> + 📤 + Share + + )} + {onAddToAlbum && ( + onAddToAlbum(displayMedia)}> + 📁 + Album + + )} + {onDelete && ( + onDelete(displayMedia)}> + 🗑️ + Delete + + )} + + {displayMedia.title || 'Photo'} Source @@ -211,6 +232,24 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => { )} + {displayMedia.sha256Hash && ( + + SHA-256 + + {displayMedia.sha256Hash.substring(0, 24)}… + + + )} + {displayMedia.fileSize > 0 && ( + + Size + + {displayMedia.fileSize > 1048576 + ? (displayMedia.fileSize / 1048576).toFixed(1) + ' MB' + : (displayMedia.fileSize / 1024).toFixed(1) + ' KB'} + + + )} @@ -255,6 +294,13 @@ const styles = StyleSheet.create({ padding: 16, paddingBottom: 32, borderTopLeftRadius: 16, borderTopRightRadius: 16, }, + actionBar: { + flexDirection: 'row', justifyContent: 'space-around', marginBottom: 12, + paddingBottom: 12, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: 'rgba(255,255,255,0.15)', + }, + actionBtn: { alignItems: 'center', paddingHorizontal: 16 }, + actionEmoji: { fontSize: 20, marginBottom: 4 }, + actionText: { color: '#e8eaed', fontSize: 11, fontWeight: '500' }, title: { fontSize: 16, fontWeight: '600', color: '#fff', marginBottom: 8 }, row: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 4 }, label: { fontSize: 12, color: '#9aa0a6' }, diff --git a/src/components/NASSetupInstructions.js b/src/components/NASSetupInstructions.js index f962779..43eeecd 100644 --- a/src/components/NASSetupInstructions.js +++ b/src/components/NASSetupInstructions.js @@ -25,51 +25,41 @@ const NASSetupInstructions = () => { Prerequisites - • An Ubuntu server (20.04 LTS or later) with SSH access enabled{'\n'} - • A user account with sudo access on the server{'\n'} - • The server's IP address and SSH port (default: 22) + • A Windows or Ubuntu machine to run the NAS server{'\n'} + • Node.js installed on the NAS machine{'\n'} + • The NAS machine's IP address on your local network - Step 1: Enable SSH + Step 1: Copy the ServerFiles folder - If SSH is not already enabled on your Ubuntu server, open a terminal on the server and run:{'\n\n'} - sudo apt update && sudo apt install openssh-server{'\n'} - sudo systemctl enable ssh && sudo systemctl start ssh + The ServerFiles folder is self-contained. Copy it to any machine you want to use as a NAS. - Step 2: Create a Photos Directory + Step 2: Run the CLI client - Create a dedicated folder for your photo storage:{'\n\n'} - mkdir -p ~/photos{'\n'} - chmod 755 ~/photos + Open a terminal in the ServerFiles folder and run:{'\n\n'} + node cli.js{'\n\n'} + The CLI will ask whether you are on Windows or Linux (Ubuntu), set a photos storage path, and optionally store a password. - Step 3: Configure Firewall (if needed) + Step 3: Start the server - If your server uses UFW, allow SSH connections:{'\n\n'} - sudo ufw allow ssh{'\n'} - sudo ufw enable + From the CLI menu, select "Start NAS Server". The server starts on port 9500 by default.{'\n\n'} + Alternatively run: node server.js - Step 4: Find Your Server IP + Step 4: Connect from the App - Run this on your server to find its local IP:{'\n\n'} - hostname -I{'\n\n'} - Use that IP address in the "Server Address" field above. + Enter the NAS machine IP, port (default 9500), and your username.{'\n\n'} + No password or photo folder path is needed — the NAS manages storage location and user folders automatically. - Step 5: Connect from the App + How it works - Enter the server IP, SSH port (22), your username, password, and the photos folder path (e.g. /home/youruser/photos).{'\n\n'} - The app's backend server will handle the SFTP connection securely. - - - Step 6: Run the Backend Server - - The NAS connection uses a backend server. Navigate to the ServerFiles folder and run:{'\n\n'} - npm install{'\n'} - node server.js{'\n\n'} - Ensure the backend URL in the app settings matches the server address (e.g. http://192.168.1.100:3001). + • Your username is sent to the NAS, which creates a folder for you{'\n'} + • The NAS indexes all photos in your folder and stores SHA-256 hashes{'\n'} + • Photos are sent to the app as base64-encoded data with integrity hashes{'\n'} + • Pull-to-refresh triggers a full re-index on the NAS )} @@ -98,4 +88,3 @@ const styles = StyleSheet.create({ }); export default NASSetupInstructions; - diff --git a/src/components/PhotoActionSheet.js b/src/components/PhotoActionSheet.js new file mode 100644 index 0000000..f2458eb --- /dev/null +++ b/src/components/PhotoActionSheet.js @@ -0,0 +1,78 @@ +import React from 'react'; +import { Modal, View, Text, TouchableOpacity, StyleSheet } from 'react-native'; +import { useTheme } from '../services/ThemeContext'; + +/** + * Action sheet overlay for photo operations (delete, add to album, info, etc.) + * + * Props: + * visible – boolean + * onClose – dismiss callback + * actions – array of { label, icon, onPress, destructive? } + */ +const PhotoActionSheet = ({ visible, onClose, actions = [] }) => { + const { theme } = useTheme(); + + return ( + + + + + {actions.map((action, i) => ( + { onClose(); action.onPress?.(); }} + activeOpacity={0.6} + > + {action.icon ? {action.icon} : null} + + {action.label} + + + ))} + + Cancel + + + + + ); +}; + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + justifyContent: 'flex-end', + backgroundColor: 'rgba(0,0,0,0.4)', + }, + sheet: { + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + paddingBottom: 30, + paddingTop: 8, + }, + handle: { + width: 36, height: 4, borderRadius: 2, alignSelf: 'center', marginBottom: 8, + }, + actionRow: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 20, + paddingVertical: 16, + gap: 14, + }, + actionIcon: { fontSize: 20 }, + actionLabel: { fontSize: 16, fontWeight: '500' }, + cancelRow: { + alignItems: 'center', + paddingVertical: 14, + marginHorizontal: 16, + marginTop: 8, + borderRadius: 12, + }, + cancelText: { fontSize: 16, fontWeight: '600' }, +}); + +export default PhotoActionSheet; + diff --git a/src/components/PhotoGrid.js b/src/components/PhotoGrid.js index c7b252e..4f535ee 100644 --- a/src/components/PhotoGrid.js +++ b/src/components/PhotoGrid.js @@ -3,6 +3,7 @@ import { View, Text, FlatList, TouchableOpacity, Image, Dimensions, StyleSheet, } from 'react-native'; import { useTheme } from '../services/ThemeContext'; +import { getServiceIcon } from '../UI/ServiceHelpers'; const { width } = Dimensions.get('window'); const GRID_GAP = 2; @@ -10,6 +11,7 @@ const GRID_GAP = 2; const PhotoGrid = ({ data, onItemPress, + onItemLongPress, columns = 3, refreshControl, ListHeaderComponent, @@ -20,8 +22,6 @@ const PhotoGrid = ({ const { theme } = useTheme(); const itemSize = (width - GRID_GAP * (columns + 1)) / columns; - const getServiceIcon = (type) => - ({ DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱'); const renderSourceBadges = (item) => { const sources = item.sources && item.sources.length > 0 @@ -51,6 +51,7 @@ const PhotoGrid = ({ onItemPress?.(item)} + onLongPress={() => onItemLongPress?.(item)} activeOpacity={0.8} > {item.local_path ? ( diff --git a/src/screens/AlbumsScreen.js b/src/screens/AlbumsScreen.js index 811b1fa..47f2c6a 100644 --- a/src/screens/AlbumsScreen.js +++ b/src/screens/AlbumsScreen.js @@ -1,12 +1,17 @@ import React, { useState, useCallback } from 'react'; import { StyleSheet, View, Text, SectionList, FlatList, TouchableOpacity, - Dimensions, Image, ActivityIndicator, RefreshControl, + Dimensions, Image, ActivityIndicator, RefreshControl, Alert, TextInput, Modal, } from 'react-native'; import { useFocusEffect } from '@react-navigation/native'; import { useTheme } from '../services/ThemeContext'; import DatabaseService from '../services/DatabaseService'; +import NASAUTH from '../AUTH/NASAUTH'; import MediaViewer from '../components/MediaViewer'; +import PhotoActionSheet from '../components/PhotoActionSheet'; +import ScreenHeader from '../UI/ScreenHeader'; +import EmptyState from '../UI/EmptyState'; +import { getServiceIcon, getServiceName } from '../UI/ServiceHelpers'; const { width } = Dimensions.get('window'); const ALBUM_COLUMNS = 2; @@ -24,14 +29,30 @@ const CollectionsScreen = ({ route }) => { const [selectedMedia, setSelectedMedia] = useState(null); const [viewerVisible, setViewerVisible] = useState(false); + // Create album modal + const [createModalVisible, setCreateModalVisible] = useState(false); + const [newAlbumName, setNewAlbumName] = useState(''); + + + // Action sheet + const [actionSheetVisible, setActionSheetVisible] = useState(false); + const [actionTarget, setActionTarget] = useState(null); + useFocusEffect( useCallback(() => { if (userId) loadCollections(); }, [userId]) ); - const getServiceIcon = (type) => ({ DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱'); - const getServiceName = (type) => ({ DEVICE: 'On Device', GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud Photos', NAS: 'NAS Storage' }[type] || type); + const getNasAuth = async () => { + const accounts = await DatabaseService.getLinkedAccounts(userId); + const nasAccount = accounts.find((a) => a.service_type === 'NAS'); + if (!nasAccount) return null; + const auth = new NASAUTH(); + const d = nasAccount.account_data; + await auth.authenticate(d.host, d.port, d.username); + return auth; + }; const loadCollections = async () => { try { @@ -39,7 +60,7 @@ const CollectionsScreen = ({ route }) => { const allMedia = await DatabaseService.getMediaByUser(userId); const sectionList = []; - // ─── Utility albums (top) ─── + // ─── Utility albums ─── const utilityAlbums = [ { name: 'All Photos', icon: '📸', count: allMedia.length, media: allMedia, type: 'utility' }, { name: 'Hidden', icon: '🙈', count: 0, media: [], type: 'utility' }, @@ -68,6 +89,36 @@ const CollectionsScreen = ({ route }) => { }); } + // ─── NAS Albums (from NAS server) ─── + const nasAccount = accounts.find((a) => a.service_type === 'NAS'); + if (nasAccount) { + try { + const auth = await getNasAuth(); + if (auth) { + const result = await auth.getAlbums(); + if (result?.albums?.length) { + const nasAlbumEntries = result.albums.map((a) => ({ + name: a.name, + icon: '📁', + count: a.photoCount || 0, + coverImage: null, + media: [], + type: 'NAS_ALBUM', + nasAlbumId: a.id, + })); + sectionList.push({ + title: 'NAS Albums', + icon: '💾', + data: [{ albums: nasAlbumEntries }], + }); + } + await auth.revokeAccess(); + } + } catch (err) { + console.error('NAS album fetch error:', err); + } + } + setSections(sectionList); } catch (err) { console.error('Error loading collections:', err); @@ -77,27 +128,171 @@ const CollectionsScreen = ({ route }) => { } }; - // Album detail view + const openAlbum = async (album) => { + setSelectedAlbum(album); + + if (album.type === 'NAS_ALBUM' && album.nasAlbumId) { + // Fetch NAS album photos + try { + const auth = await getNasAuth(); + if (auth) { + const result = await auth.getAlbumPhotos(album.nasAlbumId); + const items = (result?.photos || []).map((p) => ({ + source_service: 'NAS', + source_id: `nas_${p.id}`, + title: p.filename, + media_type: 'photo', + sha256Hash: p.sha256Hash, + fileSize: p.fileSize, + nas_photo_id: p.id, + local_path: null, + })); + setAlbumMedia(items); + await auth.revokeAccess(); + } + } catch (err) { + console.error('NAS album photos error:', err); + setAlbumMedia([]); + } + } else { + setAlbumMedia(album.media || []); + } + }; + + // ─── Create NAS Album ─── + const handleCreateAlbum = async () => { + if (!newAlbumName.trim()) { + Alert.alert('Error', 'Please enter an album name'); + return; + } + try { + const auth = await getNasAuth(); + if (auth) { + const result = await auth.createAlbum(newAlbumName.trim()); + await auth.revokeAccess(); + if (result?.success) { + setCreateModalVisible(false); + setNewAlbumName(''); + loadCollections(); + Alert.alert('Created', `Album "${newAlbumName.trim()}" created on NAS`); + } else { + Alert.alert('Error', result?.error || 'Failed to create album'); + } + } + } catch (err) { + Alert.alert('Error', err.message); + } + }; + + // ─── Delete NAS Album ─── + const handleDeleteAlbum = (album) => { + if (!album.nasAlbumId) return; + Alert.alert('Delete Album', `Delete "${album.name}"? Photos will not be deleted.`, [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete', style: 'destructive', + onPress: async () => { + try { + const auth = await getNasAuth(); + if (auth) { + await auth.deleteAlbum(album.nasAlbumId); + await auth.revokeAccess(); + setSelectedAlbum(null); + loadCollections(); + } + } catch (err) { + Alert.alert('Error', err.message); + } + }, + }, + ]); + }; + + // ─── Remove photo from NAS album ─── + const handleRemoveFromAlbum = (item) => { + if (!selectedAlbum?.nasAlbumId || !item.nas_photo_id) return; + Alert.alert('Remove from Album', `Remove "${item.title}" from this album?`, [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Remove', style: 'destructive', + onPress: async () => { + try { + const auth = await getNasAuth(); + if (auth) { + await auth.removePhotoFromAlbum(selectedAlbum.nasAlbumId, item.nas_photo_id); + await auth.revokeAccess(); + setAlbumMedia((prev) => prev.filter((m) => m.nas_photo_id !== item.nas_photo_id)); + } + } catch (err) { + Alert.alert('Error', err.message); + } + }, + }, + ]); + }; + + // ─── Delete photo from NAS entirely ─── + const handleDeletePhoto = (item) => { + if (!item.nas_photo_id) return; + Alert.alert('Delete Photo', `Permanently delete "${item.title}" from NAS?`, [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete', style: 'destructive', + onPress: async () => { + try { + const auth = await getNasAuth(); + if (auth) { + await auth.nasAPI.deletePhoto(item.nas_photo_id); + await auth.revokeAccess(); + setAlbumMedia((prev) => prev.filter((m) => m.nas_photo_id !== item.nas_photo_id)); + setViewerVisible(false); + } + } catch (err) { + Alert.alert('Error', err.message); + } + }, + }, + ]); + }; + + const handlePhotoLongPress = (item) => { + setActionTarget(item); + setActionSheetVisible(true); + }; + + const getPhotoActions = () => { + if (!actionTarget) return []; + const acts = [ + { label: 'View', icon: '👁️', onPress: () => { setSelectedMedia(actionTarget); setViewerVisible(true); } }, + ]; + if (selectedAlbum?.nasAlbumId && actionTarget.nas_photo_id) { + acts.push({ label: 'Remove from Album', icon: '📤', onPress: () => handleRemoveFromAlbum(actionTarget) }); + } + if (actionTarget.nas_photo_id) { + acts.push({ label: 'Delete from NAS', icon: '🗑️', destructive: true, onPress: () => handleDeletePhoto(actionTarget) }); + } + return acts; + }; + + // ─── Album detail view ─── if (selectedAlbum) { const gridItemSize = (width - 6) / 3; + const isNasAlbum = !!selectedAlbum.nasAlbumId; + return ( - - { setSelectedAlbum(null); setAlbumMedia([]); }}> - ← - - - {selectedAlbum.name} - - {selectedAlbum.count} item{selectedAlbum.count !== 1 ? 's' : ''} - - - + { setSelectedAlbum(null); setAlbumMedia([]); }} + rightAction={isNasAlbum ? ( + handleDeleteAlbum(selectedAlbum)}> + Delete + + ) : undefined} + /> {albumMedia.length === 0 ? ( - - 📭 - No items in this collection - + ) : ( { { setSelectedMedia(item); setViewerVisible(true); }} + onLongPress={() => handlePhotoLongPress(item)} activeOpacity={0.8} > {item.local_path ? ( @@ -116,16 +312,28 @@ const CollectionsScreen = ({ route }) => { )} )} - keyExtractor={(item, i) => item.id?.toString() || i.toString()} + keyExtractor={(item, i) => item.id?.toString() || item.source_id || i.toString()} numColumns={3} contentContainerStyle={styles.gridContent} /> )} - setViewerVisible(false)} /> + setViewerVisible(false)} + onDelete={handleDeletePhoto} + /> + setActionSheetVisible(false)} + actions={getPhotoActions()} + /> ); } + // ─── Loading ─── if (loading) { return ( @@ -135,13 +343,15 @@ const CollectionsScreen = ({ route }) => { ); } + // ─── Album card renderer ─── const renderAlbumRow = ({ item }) => ( {item.albums.map((album, idx) => ( { setSelectedAlbum(album); setAlbumMedia(album.media); }} + onPress={() => openAlbum(album)} + onLongPress={() => album.nasAlbumId && handleDeleteAlbum(album)} activeOpacity={0.8} > @@ -172,20 +382,28 @@ const CollectionsScreen = ({ route }) => { ); }; + const hasNas = sections.some((s) => s.title === 'NAS Storage' || s.title === 'NAS Albums'); + return ( Collections + {hasNas && ( + setCreateModalVisible(true)} + > + + Album + + )} {sections.length === 0 || (sections.length === 1 && sections[0].data[0].albums[0].count === 0) ? ( - - 📁 - No Collections - - Collections from your connected services will appear here - - + ) : ( { } /> )} + + {/* Create NAS Album Modal */} + setCreateModalVisible(false)}> + setCreateModalVisible(false)}> + + Create NAS Album + + + setCreateModalVisible(false)}> + Cancel + + + Create + + + + + ); }; @@ -206,42 +449,35 @@ const CollectionsScreen = ({ route }) => { const styles = StyleSheet.create({ container: { flex: 1 }, centerContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 24 }, - header: { paddingHorizontal: 16, paddingTop: 52, paddingBottom: 12 }, + header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingHorizontal: 16, paddingTop: 52, paddingBottom: 12 }, headerTitle: { fontSize: 24, fontWeight: '700' }, + createBtn: { paddingHorizontal: 14, paddingVertical: 7, borderRadius: 16 }, + createBtnText: { color: '#fff', fontSize: 13, fontWeight: '600' }, listContent: { paddingHorizontal: ALBUM_GAP, paddingBottom: 20 }, - // Section headers - sectionHeader: { - flexDirection: 'row', alignItems: 'center', paddingVertical: 12, gap: 8, - }, + sectionHeader: { flexDirection: 'row', alignItems: 'center', paddingVertical: 12, gap: 8 }, sectionIcon: { fontSize: 20 }, sectionTitle: { fontSize: 16, fontWeight: '700' }, - // Album grid albumRow: { flexDirection: 'row', gap: ALBUM_GAP, marginBottom: ALBUM_GAP }, albumCard: {}, albumCover: { borderRadius: 12, overflow: 'hidden', marginBottom: 8 }, albumCoverImage: { width: '100%', height: '100%' }, - albumCoverPlaceholder: { - width: '100%', height: '100%', justifyContent: 'center', alignItems: 'center', - }, + albumCoverPlaceholder: { width: '100%', height: '100%', justifyContent: 'center', alignItems: 'center' }, albumName: { fontSize: 14, fontWeight: '600', paddingHorizontal: 2 }, albumCount: { fontSize: 12, paddingHorizontal: 2, marginTop: 2 }, // Album detail - albumHeader: { - flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, - paddingTop: 52, paddingBottom: 12, gap: 12, borderBottomWidth: 1, - }, - backArrow: { fontSize: 24 }, - albumHeaderInfo: { flex: 1 }, - albumHeaderTitle: { fontSize: 20, fontWeight: '700' }, - albumHeaderCount: { fontSize: 12 }, gridContent: { paddingBottom: 20 }, gridItem: { margin: 1 }, gridImage: { width: '100%', height: '100%' }, gridPlaceholder: { width: '100%', height: '100%', justifyContent: 'center', alignItems: 'center' }, - // Empty / Loading loadingText: { marginTop: 12, fontSize: 14 }, - emptyTitle: { fontSize: 18, fontWeight: '600', marginBottom: 6 }, - emptyText: { fontSize: 13, textAlign: 'center' }, + // Create album modal + modalOverlay: { flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: 'rgba(0,0,0,0.5)' }, + modalCard: { width: '80%', borderRadius: 16, padding: 20 }, + modalTitle: { fontSize: 18, fontWeight: '700', marginBottom: 16 }, + modalInput: { borderWidth: 1, borderRadius: 8, padding: 12, fontSize: 15, marginBottom: 16 }, + modalActions: { flexDirection: 'row', gap: 10 }, + modalBtn: { flex: 1, paddingVertical: 12, borderRadius: 8, alignItems: 'center', borderWidth: 1, borderColor: 'transparent' }, + modalBtnText: { fontSize: 14, fontWeight: '600' }, }); export default CollectionsScreen; diff --git a/src/screens/HomeScreen.js b/src/screens/HomeScreen.js index a615533..8d80951 100644 --- a/src/screens/HomeScreen.js +++ b/src/screens/HomeScreen.js @@ -1,6 +1,6 @@ import React, { useState, useEffect, useCallback } from 'react'; import { - StyleSheet, View, Text, ActivityIndicator, ScrollView, RefreshControl, + StyleSheet, View, Text, ActivityIndicator, RefreshControl, Alert, } from 'react-native'; import { useFocusEffect } from '@react-navigation/native'; import { useTheme } from '../services/ThemeContext'; @@ -12,6 +12,10 @@ import { File } from 'expo-file-system'; import TopBar from '../components/TopBar'; import PhotoGrid from '../components/PhotoGrid'; import MediaViewer from '../components/MediaViewer'; +import PhotoActionSheet from '../components/PhotoActionSheet'; +import FilterChips from '../UI/FilterChips'; +import EmptyState from '../UI/EmptyState'; +import { getServiceIcon, getServiceName } from '../UI/ServiceHelpers'; import { logError } from '../services/ErrorHandler'; import { TouchableOpacity } from 'react-native'; @@ -19,11 +23,8 @@ import { TouchableOpacity } from 'react-native'; * Merge media items from different services. * Items with the same filename (title) are merged into a single entry * with a `sources` array listing every service it appears in. - * The best local_path is kept (prefer file:// from DEVICE over a cached - * Picker download, since device URIs are always fresh). */ const mergeMediaAcrossServices = (items) => { - // First pass: group by normalised filename const byFilename = new Map(); const noFilename = []; @@ -36,15 +37,12 @@ const mergeMediaAcrossServices = (items) => { if (byFilename.has(key)) { const existing = byFilename.get(key); - // Add this service to the sources list if (!existing.sources.includes(item.source_service)) { existing.sources.push(item.source_service); } - // Prefer a local device path over a cached download if (item.source_service === 'DEVICE' && item.local_path) { existing.local_path = item.local_path; } - // Keep the best local_path if the existing one is missing if (!existing.local_path && item.local_path) { existing.local_path = item.local_path; } @@ -53,7 +51,6 @@ const mergeMediaAcrossServices = (items) => { } } - // Second pass: also deduplicate by source_id for items with different titles const bySourceId = new Map(); for (const item of [...byFilename.values(), ...noFilename]) { const key = item.source_id || item.id || item.local_path || Math.random().toString(); @@ -89,6 +86,9 @@ const HomeScreen = ({ navigation, route }) => { const [googleSessionId, setGoogleSessionId] = useState(null); const [loadingMore, setLoadingMore] = useState(false); const [importing, setImporting] = useState(false); + // Action sheet state + const [actionSheetVisible, setActionSheetVisible] = useState(false); + const [actionSheetTarget, setActionSheetTarget] = useState(null); useFocusEffect( useCallback(() => { @@ -118,7 +118,7 @@ const HomeScreen = ({ navigation, route }) => { try { const newMedia = []; - // ─── Device photos (local camera roll, screenshots, etc.) ─── + // ─── Device photos ─── try { const deviceResult = await DeviceMediaService.getPhotos(100); if (deviceResult.mediaItems.length) { @@ -130,18 +130,15 @@ const HomeScreen = ({ navigation, route }) => { console.error('Device media fetch error:', err); } - // ─── Previously imported Google Photos (stored in DB) ─── + // ─── Previously imported Google Photos ─── try { const googleMedia = await DatabaseService.getMediaByService(userId, 'GOOGLE_PHOTOS'); if (googleMedia?.length) { - // Validate cached files still exist — clear stale paths const validated = googleMedia.map((item) => { if (item.local_path) { try { const f = new File(item.local_path); - if (!f.exists || f.size === 0) { - return { ...item, local_path: null }; - } + if (!f.exists || f.size === 0) return { ...item, local_path: null }; } catch (_) { return { ...item, local_path: null }; } @@ -154,15 +151,29 @@ const HomeScreen = ({ navigation, route }) => { console.error('DB Google media fetch error:', err); } - // ─── NAS fetch ─── + // ─── NAS fetch (REST refresh cycle) ─── const nasAccount = linkedAccounts.find((a) => a.service_type === 'NAS'); if (nasAccount) { try { - const nasAuth = new NASAUTH(); const nasData = nasAccount.account_data; - await nasAuth.authenticate(nasData.host, nasData.port, nasData.username, nasData.password, nasData.photoFolder); - const nasPhotos = await nasAuth.getPhotos(nasData.photoFolder); - if (nasPhotos?.mediaItems) newMedia.push(...nasPhotos.mediaItems); + const nasAuth = new NASAUTH(); + await nasAuth.authenticate(nasData.host, nasData.port, nasData.username); + const result = await nasAuth.refreshPhotos(); + if (result?.photos?.length) { + const nasItems = result.photos.map((p) => ({ + source_service: 'NAS', + source_id: `nas_${p.id}`, + title: p.filename, + media_type: p.mimeType?.startsWith('image/') ? 'photo' : 'video', + created_date: p.indexedAt, + size: p.fileSize, + sha256Hash: p.sha256Hash, + nas_photo_id: p.id, + // No local_path — NAS photos are fetched on-demand as base64 + local_path: null, + })); + newMedia.push(...nasItems); + } await nasAuth.revokeAccess(); } catch (err) { console.error('NAS fetch error:', err); @@ -175,9 +186,6 @@ const HomeScreen = ({ navigation, route }) => { console.log('iCloud: placeholder — would fetch photos here'); } - // ─── Merge & cross-reference across services ─── - // Match items across sources by filename so we can show "this photo - // exists on Device AND Google Photos" etc. const merged = mergeMediaAcrossServices(newMedia); setAllMedia(merged); } catch (err) { @@ -188,7 +196,7 @@ const HomeScreen = ({ navigation, route }) => { } }; - // ─── Import from Google Photos (opens picker in browser) ─── + // ─── Import from Google Photos ─── const importGooglePhotos = async () => { const googleAccount = linkedAccounts.find((a) => a.service_type === 'GOOGLE_PHOTOS'); if (!googleAccount) { @@ -206,7 +214,6 @@ const HomeScreen = ({ navigation, route }) => { let result = await googleAuth.pickPhotos(); - // If 403 (stale scopes), auto re-auth and retry if (result?.error?.code === 403) { console.log('Stale token — re-authenticating…'); await googleAuth.revokeAccess(); @@ -223,21 +230,15 @@ const HomeScreen = ({ navigation, route }) => { if (result?.error) { logError('Import Google Photos', result.error.message); } else if (result?.mediaItems?.length) { - // Save imported items to DB so they persist across app restarts for (const item of result.mediaItems) { await DatabaseService.saveMediaItem(userId, item); } - setGoogleNextPage(result.nextPageToken || null); setGoogleSessionId(result.sessionId || null); - - // Merge into current view with cross-service matching setAllMedia((prev) => mergeMediaAcrossServices([...prev, ...result.mediaItems])); - console.log(`Imported ${result.mediaItems.length} photos from Google`); } - // Persist refreshed tokens if (googleAuth.accessToken && googleAuth.accessToken !== data.accessToken) { await DatabaseService.linkAccount(userId, 'GOOGLE_PHOTOS', { accessToken: googleAuth.accessToken, @@ -256,7 +257,6 @@ const HomeScreen = ({ navigation, route }) => { const filterMedia = () => { let filtered = allMedia; if (selectedFilter !== 'all') { - // Check both source_service and the sources array for cross-service items filtered = allMedia.filter((m) => m.source_service === selectedFilter || (m.sources && m.sources.includes(selectedFilter)) @@ -314,11 +314,93 @@ const HomeScreen = ({ navigation, route }) => { } }; - const getServiceIcon = (type) => ({ DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱'); - const getServiceName = (type) => ({ DEVICE: 'On Device', GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud', NAS: 'NAS' }[type] || type); + // ─── Photo actions ─── + + const handleDeletePhoto = (item) => { + const sources = item.sources || [item.source_service]; + const actions = []; + + if (sources.includes('DEVICE')) { + actions.push('your device'); + } + if (sources.includes('NAS') || item.nas_photo_id) { + actions.push('the NAS'); + } + if (sources.includes('GOOGLE_PHOTOS')) { + actions.push('Google Photos (DB record)'); + } + + Alert.alert( + 'Delete Photo', + `Delete "${item.title || 'this photo'}" from ${actions.join(' and ')}?`, + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete', + style: 'destructive', + onPress: async () => { + try { + // Delete from NAS if applicable + if (item.nas_photo_id) { + const nasAccount = linkedAccounts.find((a) => a.service_type === 'NAS'); + if (nasAccount) { + const nasAuth = new NASAUTH(); + const nasData = nasAccount.account_data; + await nasAuth.authenticate(nasData.host, nasData.port, nasData.username); + await nasAuth.deletePhoto(item.nas_photo_id); + await nasAuth.revokeAccess(); + } + } + // Remove from local DB + if (item.id && typeof item.id === 'number') { + await DatabaseService.deleteMediaItem?.(item.id); + } + // Remove from state + setAllMedia((prev) => prev.filter((m) => + (m.source_id || m.id) !== (item.source_id || item.id) + )); + setViewerVisible(false); + Alert.alert('Deleted', 'Photo has been removed.'); + } catch (err) { + Alert.alert('Error', 'Failed to delete: ' + err.message); + } + }, + }, + ] + ); + }; + + const handleLongPress = (item) => { + setActionSheetTarget(item); + setActionSheetVisible(true); + }; + + const getActionSheetActions = () => { + if (!actionSheetTarget) return []; + const actions = [ + { label: 'View Details', icon: 'ℹ️', onPress: () => { setSelectedMedia(actionSheetTarget); setViewerVisible(true); } }, + ]; + if (actionSheetTarget.nas_photo_id || actionSheetTarget.source_service === 'NAS') { + actions.push({ label: 'Delete from NAS', icon: '🗑️', destructive: true, onPress: () => handleDeletePhoto(actionSheetTarget) }); + } + if (actionSheetTarget.source_service === 'DEVICE') { + actions.push({ label: 'Delete from Device', icon: '🗑️', destructive: true, onPress: () => handleDeletePhoto(actionSheetTarget) }); + } + return actions; + }; const hasGoogleAccount = linkedAccounts.some((a) => a.service_type === 'GOOGLE_PHOTOS'); + // Build filter list + const sourceFilters = [ + { key: 'DEVICE', icon: getServiceIcon('DEVICE'), name: getServiceName('DEVICE') }, + ...linkedAccounts.map((acc) => ({ + key: acc.service_type, + icon: getServiceIcon(acc.service_type), + name: getServiceName(acc.service_type), + })), + ]; + if (loading && allMedia.length === 0) { return ( @@ -328,39 +410,6 @@ const HomeScreen = ({ navigation, route }) => { ); } - // Build the list of available source filters - const sourceFilters = [ - { key: 'DEVICE', icon: '📱', name: 'On Device' }, - ...linkedAccounts.map((acc) => ({ - key: acc.service_type, - icon: getServiceIcon(acc.service_type), - name: getServiceName(acc.service_type), - })), - ]; - - const FilterChips = () => ( - - setSelectedFilter('all')} - > - All - - {sourceFilters.map((f) => ( - setSelectedFilter(f.key)} - > - {f.icon} - - {f.name} - - - ))} - - ); - return ( { onMenuPress={() => navigation.navigate('Settings', { userId })} /> - + {/* Import from Google Photos button */} {hasGoogleAccount && ( @@ -392,25 +441,22 @@ const HomeScreen = ({ navigation, route }) => { )} {filteredMedia.length === 0 ? ( - - 📸 - No Photos Yet - - {hasGoogleAccount + - { setRefreshing(true); loadAllMedia(); }} - > - Refresh - - + : 'Grant media library permission or take some photos to get started' + } + actionLabel="Refresh" + onAction={() => { setRefreshing(true); loadAllMedia(); }} + /> ) : ( { setSelectedMedia(item); setViewerVisible(true); }} + onItemLongPress={handleLongPress} onEndReached={loadMorePhotos} refreshControl={ { setRefreshing(true); loadAllMedia(); }} /> @@ -428,6 +474,13 @@ const HomeScreen = ({ navigation, route }) => { media={selectedMedia} allMedia={filteredMedia} onClose={() => setViewerVisible(false)} + onDelete={handleDeletePhoto} + /> + + setActionSheetVisible(false)} + actions={getActionSheetActions()} /> ); @@ -437,16 +490,6 @@ const styles = StyleSheet.create({ container: { flex: 1 }, centerContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 24 }, loadingText: { marginTop: 12, fontSize: 14 }, - // Filter chips - filterContainer: { maxHeight: 40 }, - filterRow: { paddingHorizontal: 12, paddingVertical: 6, gap: 6, alignItems: 'center' }, - chip: { - flexDirection: 'row', alignItems: 'center', paddingHorizontal: 10, paddingVertical: 4, - borderRadius: 16, gap: 4, height: 28, - }, - chipIcon: { fontSize: 11 }, - chipText: { fontSize: 12, fontWeight: '500', color: '#3c4043' }, - // Import button importButton: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', marginHorizontal: 12, marginVertical: 6, paddingVertical: 10, paddingHorizontal: 16, @@ -454,11 +497,6 @@ const styles = StyleSheet.create({ }, importIcon: { fontSize: 16 }, importText: { color: '#fff', fontSize: 14, fontWeight: '600' }, - // Empty - emptyTitle: { fontSize: 18, fontWeight: '600', marginBottom: 6 }, - emptyText: { fontSize: 13, textAlign: 'center', marginBottom: 20, lineHeight: 20 }, - retryButton: { paddingHorizontal: 24, paddingVertical: 10, borderRadius: 20 }, - retryButtonText: { color: '#fff', fontSize: 14, fontWeight: '600' }, }); export default HomeScreen; diff --git a/src/screens/OnboardingScreen.js b/src/screens/OnboardingScreen.js index d34fde8..747fd4e 100644 --- a/src/screens/OnboardingScreen.js +++ b/src/screens/OnboardingScreen.js @@ -33,10 +33,8 @@ const OnboardingScreen = ({ navigation, route }) => { // NAS config const [nasHost, setNasHost] = useState(''); - const [nasPort, setNasPort] = useState('22'); + const [nasPort, setNasPort] = useState('9500'); const [nasUsername, setNasUsername] = useState(''); - const [nasPassword, setNasPassword] = useState(''); - const [nasPhotoFolder, setNasPhotoFolder] = useState('/home/user/photos'); // Auto-backup checklist (multi-select) const [autoBackupServices, setAutoBackupServices] = useState([]); @@ -102,8 +100,8 @@ const OnboardingScreen = ({ navigation, route }) => { }; const handleNasAuth = async () => { - if (!nasHost.trim() || !nasUsername.trim() || !nasPassword.trim()) { - Alert.alert('Error', 'Please fill in all NAS connection fields'); + if (!nasHost.trim() || !nasUsername.trim()) { + Alert.alert('Error', 'Please fill in the NAS host and username'); return; } try { @@ -111,14 +109,13 @@ const OnboardingScreen = ({ navigation, route }) => { setNasHealth('checking'); const nasAuth = new NASAUTH(); await nasAuth.authenticate( - nasHost.trim(), parseInt(nasPort) || 22, - nasUsername.trim(), nasPassword, nasPhotoFolder.trim() + nasHost.trim(), parseInt(nasPort) || 9500, + nasUsername.trim() ); await DatabaseService.linkAccount(userId, 'NAS', { host: nasHost.trim(), - port: parseInt(nasPort) || 22, + port: parseInt(nasPort) || 9500, username: nasUsername.trim(), - photoFolder: nasPhotoFolder.trim(), }); setNasConnected(true); setNasHealth('healthy'); @@ -189,7 +186,7 @@ const OnboardingScreen = ({ navigation, route }) => { description="Access your iCloud photo library (placeholder)" enabled={iosEnabled} onToggle={() => setIosEnabled(!iosEnabled)} /> setNasEnabled(!nasEnabled)} /> {/* NAS setup instructions dropdown */} @@ -254,19 +251,11 @@ const OnboardingScreen = ({ navigation, route }) => { Port + placeholder="9500" value={nasPort} onChangeText={setNasPort} editable={!loading} keyboardType="numeric" placeholderTextColor={theme.textTertiary} /> Username - - Password - - - Photos Folder - + placeholder="Your username" value={nasUsername} onChangeText={setNasUsername} editable={!loading} autoCapitalize="none" placeholderTextColor={theme.textTertiary} /> { const userId = route?.params?.userId; @@ -25,10 +26,8 @@ const SettingsScreen = ({ navigation, route }) => { // NAS edit fields const [editingNas, setEditingNas] = useState(false); const [nasHost, setNasHost] = useState(''); - const [nasPort, setNasPort] = useState('22'); + const [nasPort, setNasPort] = useState('9500'); const [nasUsername, setNasUsername] = useState(''); - const [nasPassword, setNasPassword] = useState(''); - const [nasPhotoFolder, setNasPhotoFolder] = useState('/home/user/photos'); useFocusEffect( useCallback(() => { @@ -46,9 +45,8 @@ const SettingsScreen = ({ navigation, route }) => { const nasAcc = allAccounts.find((a) => a.service_type === 'NAS'); if (nasAcc?.account_data) { setNasHost(nasAcc.account_data.host || ''); - setNasPort(String(nasAcc.account_data.port || 22)); + setNasPort(String(nasAcc.account_data.port || 9500)); setNasUsername(nasAcc.account_data.username || ''); - setNasPhotoFolder(nasAcc.account_data.photoFolder || '/home/user/photos'); } // Check health for each enabled account @@ -88,9 +86,10 @@ const SettingsScreen = ({ navigation, route }) => { if (acc.service_type === 'NAS') { const nasAuth = new NASAUTH(); const d = acc.account_data || {}; - await nasAuth.authenticate(d.host, d.port, d.username, d.password, d.photoFolder); + await nasAuth.authenticate(d.host, d.port, d.username); + const healthy = await nasAuth.healthCheck(); await nasAuth.revokeAccess(); - return 'healthy'; + return healthy ? 'healthy' : 'error'; } if (acc.service_type === 'ICLOUD_PHOTOS') { // Placeholder – always "healthy" since it's a stub @@ -191,22 +190,21 @@ const SettingsScreen = ({ navigation, route }) => { }; const saveNasConfig = async () => { - if (!nasHost.trim() || !nasUsername.trim() || !nasPassword.trim()) { - Alert.alert('Error', 'Please fill in all NAS fields'); + if (!nasHost.trim() || !nasUsername.trim()) { + Alert.alert('Error', 'Please fill in the NAS host and username'); return; } setLoading(true); try { const nasAuth = new NASAUTH(); await nasAuth.authenticate( - nasHost.trim(), parseInt(nasPort) || 22, - nasUsername.trim(), nasPassword, nasPhotoFolder.trim() + nasHost.trim(), parseInt(nasPort) || 9500, + nasUsername.trim() ); await DatabaseService.linkAccount(userId, 'NAS', { host: nasHost.trim(), - port: parseInt(nasPort) || 22, + port: parseInt(nasPort) || 9500, username: nasUsername.trim(), - photoFolder: nasPhotoFolder.trim(), }); setEditingNas(false); await loadData(); @@ -225,11 +223,6 @@ const SettingsScreen = ({ navigation, route }) => { ]); }; - const getServiceIcon = (type) => - ({ GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱'); - const getServiceName = (type) => - ({ GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud Photos', NAS: 'NAS Storage' }[type] || type); - const themeOptions = [ { key: 'light', label: 'Light', icon: '☀️', desc: 'Standard light mode' }, { key: 'dark', label: 'Dark', icon: '🌙', desc: 'Dark mode for low light' }, @@ -348,16 +341,10 @@ const SettingsScreen = ({ navigation, route }) => { value={nasHost} onChangeText={setNasHost} placeholder="192.168.1.100" placeholderTextColor={theme.textTertiary} /> Port + value={nasPort} onChangeText={setNasPort} keyboardType="numeric" placeholder="9500" placeholderTextColor={theme.textTertiary} /> Username - Password - - Photos Folder - + value={nasUsername} onChangeText={setNasUsername} autoCapitalize="none" placeholder="Your username" placeholderTextColor={theme.textTertiary} /> {loading ? : Save Configuration} diff --git a/src/services/DatabaseService.js b/src/services/DatabaseService.js index 8655ce0..16e47b2 100644 --- a/src/services/DatabaseService.js +++ b/src/services/DatabaseService.js @@ -384,6 +384,17 @@ const DatabaseService = { } }, + deleteMediaItem: async (mediaId) => { + try { + if (!(await DatabaseService.ensureDb())) return false; + await db.runAsync('DELETE FROM media WHERE id = ?', [mediaId]); + return true; + } catch (error) { + logError('Delete Media Item', error); + return false; + } + }, + // ─── Settings ────────────────────────────────────────────── getAutoBackupServices: async (userId) => {