mirror of
https://github.com/mudabbir-ahmad/Media-Aggregation-and-Sorting-System.git
synced 2026-10-07 19:40:20 +00:00
added NAS configuration file; updated MediaViewer to support thumbnail carousel; improved error handling in NASAPI and NASAUTH; refactored various comments for clarity
This commit is contained in:
1 parent
9519b32d68
commit
f8e11fc368
27 files changed
+1036
-558
No files matched your search
@@ -17,10 +17,7 @@ import SettingsScreen from './src/screens/SettingsScreen';
|
||||
const Stack = createNativeStackNavigator();
|
||||
const Tab = createBottomTabNavigator();
|
||||
|
||||
/**
|
||||
* MainTabs – Bottom tab navigator for logged-in users
|
||||
* Tabs: Library, Search, Collections
|
||||
*/
|
||||
// Bottom tab navigator shown after login
|
||||
function MainTabs({ route }) {
|
||||
const { userId } = route.params || {};
|
||||
const { theme } = useTheme();
|
||||
@@ -73,9 +70,7 @@ function MainTabs({ route }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* AppNavigator – Root stack navigator
|
||||
*/
|
||||
// Root stack navigator
|
||||
function AppNavigator() {
|
||||
return (
|
||||
<Stack.Navigator screenOptions={{ headerShown: false }}>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# SFTP Backend Server Configuration
|
||||
|
||||
# Server port
|
||||
PORT=3001
|
||||
|
||||
# JWT Secret (change this in production!)
|
||||
JWT_SECRET=web-token-key
|
||||
|
||||
# Node environment
|
||||
NODE_ENV=development
|
||||
|
||||
# Optional: Default SFTP settings (for testing)
|
||||
# DEFAULT_SFTP_HOST=192.168.1.100
|
||||
# DEFAULT_SFTP_PORT=22
|
||||
# DEFAULT_SFTP_USERNAME=user
|
||||
# DEFAULT_SFTP_PASSWORD=password
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/home/user/photos
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "media-app-sftp-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "SFTP Backend API for Media Aggregation App",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [
|
||||
"sftp",
|
||||
"backend",
|
||||
"api",
|
||||
"express"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"body-parser": "^2.2.2",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
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 app = express();
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(fileUpload());
|
||||
app.use(cors());
|
||||
|
||||
const SECRET_KEY = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
|
||||
const connections = new Map();
|
||||
|
||||
app.use((req, res, next) => {
|
||||
console.log(`[${new Date().toISOString()}] ${req.method} ${req.path}`);
|
||||
next();
|
||||
});
|
||||
|
||||
// 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 });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── REST Endpoints ──────────────────────────────────────────────
|
||||
|
||||
// Health check
|
||||
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
activeConnections: connections.size,
|
||||
defaultPhotoDir: DEFAULT_PHOTO_DIR,
|
||||
});
|
||||
});
|
||||
|
||||
// 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;
|
||||
|
||||
try {
|
||||
const result = await runSftpBatch(conn, [`ls -l ${dirPath}`]);
|
||||
const files = result.stdout
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const parts = line.split(/\s+/);
|
||||
return {
|
||||
name: parts.pop(),
|
||||
isDirectory: parts[0]?.charAt(0) === 'd',
|
||||
size: parseInt(parts[4], 10),
|
||||
modifyTime: new Date(`${parts[5]} ${parts[6]} ${parts[7]} ${parts[8]}`),
|
||||
permissions: parts[0],
|
||||
};
|
||||
});
|
||||
|
||||
res.json({ path: dirPath, files, count: files.length });
|
||||
} catch (err) {
|
||||
console.error('List directory error:', err.message);
|
||||
res.status(500).json({ error: 'Failed to list directory: ' + err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 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);
|
||||
|
||||
try {
|
||||
await runCommand('scp', buildScpArgs(conn, `${conn.username}@${conn.host}:${filePath}`, tempFilePath));
|
||||
res.download(tempFilePath, filename, () => {
|
||||
try { fs.unlinkSync(tempFilePath); } catch (_) { /* already cleaned */ }
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Download error:', err.message);
|
||||
res.status(500).json({ error: 'Download failed: ' + 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);
|
||||
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
// Create a directory on the NAS
|
||||
|
||||
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}` },
|
||||
});
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
// Sync status (placeholder for future batch sync)
|
||||
|
||||
app.post('/api/google-photos/sync-status', verifyToken, (req, res) => {
|
||||
res.json({ success: true, status: 'idle', message: 'No active sync in progress', lastSync: null });
|
||||
});
|
||||
|
||||
// ─── Lifecycle ───────────────────────────────────────────────────
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\nShutting down...');
|
||||
connections.forEach((_, id) => connections.delete(id));
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
app.use((err, req, res, _next) => {
|
||||
console.error('Server error:', err.message);
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: process.env.NODE_ENV === 'development' ? err.message : 'Unknown error',
|
||||
});
|
||||
});
|
||||
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({ error: 'Not found', path: req.path });
|
||||
});
|
||||
|
||||
const PORT = process.env.PORT || 3001;
|
||||
app.listen(PORT, () => {
|
||||
console.log(`\n SFTP Backend Server`);
|
||||
console.log(` Port: ${PORT}`);
|
||||
console.log(` URL: http://localhost:${PORT}`);
|
||||
console.log(` Photo Dir: ${DEFAULT_PHOTO_DIR}`);
|
||||
console.log(` Status: Ready\n`);
|
||||
});
|
||||
|
||||
module.exports = app;
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Google Photos OAuth & API Test
|
||||
*
|
||||
* Usage:
|
||||
* node TEST/testGooglePhotosAPI.js <ACCESS_TOKEN>
|
||||
* GOOGLE_ACCESS_TOKEN=ya29.xxx node TEST/testGooglePhotosAPI.js
|
||||
*
|
||||
* How to get a token for manual testing:
|
||||
* 1. Go to https://developers.google.com/oauthplayground
|
||||
* 2. Select "Photos Library API v1" → photoslibrary scope
|
||||
* 3. Authorise the API
|
||||
* 4. Exchange the authorization code for tokens
|
||||
* 5. Copy the access token and pass it to this script
|
||||
*
|
||||
* Tests:
|
||||
* 1. Validates the token against the userinfo endpoint
|
||||
* 2. Lists media items from Google Photos
|
||||
* 3. Lists albums from Google Photos
|
||||
*/
|
||||
|
||||
const API_BASE = 'https://photoslibrary.googleapis.com/v1';
|
||||
const USERINFO_URL = 'https://www.googleapis.com/oauth2/v2/userinfo';
|
||||
|
||||
async function main() {
|
||||
const token = process.argv[2] || process.env.GOOGLE_ACCESS_TOKEN;
|
||||
|
||||
if (!token) {
|
||||
console.error('\nNo access token provided.');
|
||||
console.error('Usage: node TEST/testGooglePhotosAPI.js <ACCESS_TOKEN>');
|
||||
console.error('Or set: GOOGLE_ACCESS_TOKEN=ya29.xxx node TEST/testGooglePhotosAPI.js');
|
||||
console.error('\nTo obtain a token go to https://developers.google.com/oauthplayground');
|
||||
console.error('and authorise the Photos Library API v1 scope.\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const headers = { Authorization: `Bearer ${token}` };
|
||||
|
||||
console.log('\n--- Google Photos API Test ---\n');
|
||||
|
||||
// Test 1 — User info
|
||||
console.log('[1] Fetching user info...');
|
||||
try {
|
||||
const res = await fetch(USERINFO_URL, { headers });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
|
||||
const user = await res.json();
|
||||
console.log(` Authenticated as: ${user.email} (${user.name})\n`);
|
||||
} catch (err) {
|
||||
console.log(` Failed: ${err.message}\n`);
|
||||
}
|
||||
|
||||
// Test 2 — List media items
|
||||
console.log('[2] Listing media items (first 10)...');
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/mediaItems?pageSize=10`, { headers });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
|
||||
const data = await res.json();
|
||||
const items = data.mediaItems || [];
|
||||
console.log(` Found ${items.length} items`);
|
||||
items.slice(0, 5).forEach((item, i) => {
|
||||
console.log(` ${i + 1}. ${item.filename} (${item.mimeType})`);
|
||||
});
|
||||
if (items.length > 5) console.log(` ... and ${items.length - 5} more`);
|
||||
if (data.nextPageToken) console.log(' More pages available');
|
||||
console.log();
|
||||
} catch (err) {
|
||||
console.log(` Failed: ${err.message}\n`);
|
||||
}
|
||||
|
||||
// Test 3 — List albums
|
||||
console.log('[3] Listing albums...');
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/albums?pageSize=10`, { headers });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
|
||||
const data = await res.json();
|
||||
const albums = data.albums || [];
|
||||
console.log(` Found ${albums.length} albums`);
|
||||
albums.slice(0, 5).forEach((album, i) => {
|
||||
console.log(` ${i + 1}. "${album.title}" - ${album.mediaItemsCount || 0} items`);
|
||||
});
|
||||
console.log();
|
||||
} catch (err) {
|
||||
console.log(` Failed: ${err.message}\n`);
|
||||
}
|
||||
|
||||
console.log('--- Tests complete ---\n');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Unhandled error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* NAS Backend API Test
|
||||
*
|
||||
* Usage:
|
||||
* node TEST/testNASAPI.js
|
||||
*
|
||||
* Environment variables:
|
||||
* BACKEND_URL (default http://localhost:3001)
|
||||
* NAS_HOST e.g. 192.168.1.100
|
||||
* NAS_PORT default 22
|
||||
* NAS_USER SSH username
|
||||
* NAS_PASS SSH password
|
||||
* NAS_FOLDER default /home/user/photos
|
||||
*
|
||||
* Tests:
|
||||
* 1. Backend health check
|
||||
* 2. SFTP connect
|
||||
* 3. List directory
|
||||
* 4. Disconnect
|
||||
*/
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3001';
|
||||
|
||||
async function main() {
|
||||
console.log('\n--- NAS Backend API Test ---');
|
||||
console.log(`Backend: ${BACKEND_URL}\n`);
|
||||
|
||||
// 1 — Health check
|
||||
console.log('[1] Health check...');
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}/api/health`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
console.log(` Status: ${data.status}, connections: ${data.activeConnections}, photo dir: ${data.defaultPhotoDir}\n`);
|
||||
} catch (err) {
|
||||
console.log(` Failed: ${err.message}`);
|
||||
console.log(' Start the backend first: cd ServerFiles && node server.js\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const host = process.env.NAS_HOST;
|
||||
const port = parseInt(process.env.NAS_PORT || '22', 10);
|
||||
const username = process.env.NAS_USER;
|
||||
const password = process.env.NAS_PASS;
|
||||
const folder = process.env.NAS_FOLDER || '/home/user/photos';
|
||||
|
||||
if (!host || !username || !password) {
|
||||
console.log('[2-4] Skipped — set NAS_HOST, NAS_USER, NAS_PASS to run connection tests.\n');
|
||||
console.log('--- Tests complete (partial) ---\n');
|
||||
return;
|
||||
}
|
||||
|
||||
// 2 — Connect
|
||||
let connectionToken = null;
|
||||
console.log(`[2] SFTP connect to ${host}:${port}...`);
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}/api/sftp/connect`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ host, port, username, password }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.error || 'Connect failed');
|
||||
connectionToken = data.connectionToken;
|
||||
console.log(` Connected.\n`);
|
||||
} catch (err) {
|
||||
console.log(` Failed: ${err.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const authHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${connectionToken}`,
|
||||
};
|
||||
|
||||
// 3 — List directory
|
||||
console.log(`[3] List directory "${folder}"...`);
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}/api/sftp/list`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders,
|
||||
body: JSON.stringify({ path: folder }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
|
||||
const data = await res.json();
|
||||
console.log(` Found ${data.count || 0} items in ${data.path}`);
|
||||
(data.files || []).slice(0, 5).forEach((f, i) => {
|
||||
console.log(` ${i + 1}. ${f.name} ${f.isDirectory ? '(dir)' : `(${f.size} bytes)`}`);
|
||||
});
|
||||
console.log();
|
||||
} catch (err) {
|
||||
console.log(` Failed: ${err.message}\n`);
|
||||
}
|
||||
|
||||
// 4 — Disconnect
|
||||
console.log('[4] Disconnect...');
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}/api/sftp/disconnect`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders,
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
console.log(` ${data.message}\n`);
|
||||
} catch (err) {
|
||||
console.log(` Failed: ${err.message}\n`);
|
||||
}
|
||||
|
||||
console.log('--- Tests complete ---\n');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Unhandled error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -25,6 +25,7 @@
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"scheme": "media-aggregation",
|
||||
"plugins": [
|
||||
"expo-sqlite"
|
||||
]
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
plugins: ['react-native-reanimated/plugin'],
|
||||
};
|
||||
};
|
||||
|
||||
+4
-1
@@ -28,8 +28,11 @@
|
||||
"expo-web-browser": "~15.0.10",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0"
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-worklets": "0.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.29.0"
|
||||
|
||||
+36
-61
@@ -1,71 +1,46 @@
|
||||
// Google Photos Library API wrapper — uses the provided access token getter for authenticated requests
|
||||
const GooglePhotosAPI = (getAccessToken) => {
|
||||
const callFetch = async (endpoint, method = 'GET', dataObj = null) => {
|
||||
const token = await getAccessToken();
|
||||
if (!token) return { isSuccess: false, message: 'Missing access token' };
|
||||
const callFetch = async (endpoint, method = 'GET', dataObj = null) => {
|
||||
const token = await getAccessToken();
|
||||
if (!token) return { isSuccess: false, message: 'Missing access token' };
|
||||
|
||||
let requestObj = {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
};
|
||||
|
||||
if (dataObj) {
|
||||
requestObj = {
|
||||
...requestObj,
|
||||
headers: {
|
||||
...requestObj.headers,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(dataObj),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, requestObj);
|
||||
const result = response.status !== 204 ? await response.json() : null;
|
||||
return response.ok
|
||||
? { isSuccess: true, result }
|
||||
: { isSuccess: false, message: result?.error?.message || 'Request failed' };
|
||||
} catch (error) {
|
||||
return { isSuccess: false, message: error.message };
|
||||
}
|
||||
let requestObj = {
|
||||
method,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
};
|
||||
|
||||
const API_ROOT = 'https://photoslibrary.googleapis.com/v1';
|
||||
if (dataObj) {
|
||||
requestObj.headers['Content-Type'] = 'application/json';
|
||||
requestObj.body = JSON.stringify(dataObj);
|
||||
}
|
||||
|
||||
return {
|
||||
// List media items
|
||||
listMediaItems: (pageSize = 25, pageToken) => {
|
||||
const params = new URLSearchParams({ pageSize });
|
||||
if (pageToken) params.append('pageToken', pageToken);
|
||||
return callFetch(`${API_ROOT}/mediaItems?${params.toString()}`, 'GET');
|
||||
},
|
||||
try {
|
||||
const response = await fetch(endpoint, requestObj);
|
||||
const result = response.status !== 204 ? await response.json() : null;
|
||||
return response.ok
|
||||
? { isSuccess: true, result }
|
||||
: { isSuccess: false, message: result?.error?.message || 'Request failed' };
|
||||
} catch (error) {
|
||||
return { isSuccess: false, message: error.message };
|
||||
}
|
||||
};
|
||||
|
||||
// Get a single media item by ID
|
||||
getMediaItem: (mediaItemId) =>
|
||||
callFetch(`${API_ROOT}/mediaItems/${mediaItemId}`, 'GET'),
|
||||
const API_ROOT = 'https://photoslibrary.googleapis.com/v1';
|
||||
|
||||
// Search media items with filters (e.g., albumId)
|
||||
searchMediaItems: (filters) =>
|
||||
callFetch(`${API_ROOT}/mediaItems:search`, 'POST', filters),
|
||||
|
||||
// Create an album
|
||||
createAlbum: (title) =>
|
||||
callFetch(`${API_ROOT}/albums`, 'POST', { album: { title } }),
|
||||
|
||||
// Add media items to an album
|
||||
addItemsToAlbum: (albumId, mediaItemIds) =>
|
||||
callFetch(`${API_ROOT}/albums/${albumId}:batchAddMediaItems`, 'POST', {
|
||||
mediaItemIds,
|
||||
}),
|
||||
|
||||
// Remove media items from an album
|
||||
removeItemsFromAlbum: (albumId, mediaItemIds) =>
|
||||
callFetch(`${API_ROOT}/albums/${albumId}:batchRemoveMediaItems`, 'POST', {
|
||||
mediaItemIds,
|
||||
}),
|
||||
};
|
||||
return {
|
||||
listMediaItems: (pageSize = 25, pageToken) => {
|
||||
const params = new URLSearchParams({ pageSize });
|
||||
if (pageToken) params.append('pageToken', pageToken);
|
||||
return callFetch(`${API_ROOT}/mediaItems?${params.toString()}`, 'GET');
|
||||
},
|
||||
getMediaItem: (mediaItemId) => callFetch(`${API_ROOT}/mediaItems/${mediaItemId}`, 'GET'),
|
||||
searchMediaItems: (filters) => callFetch(`${API_ROOT}/mediaItems:search`, 'POST', filters),
|
||||
createAlbum: (title) => callFetch(`${API_ROOT}/albums`, 'POST', { album: { title } }),
|
||||
addItemsToAlbum: (albumId, mediaItemIds) =>
|
||||
callFetch(`${API_ROOT}/albums/${albumId}:batchAddMediaItems`, 'POST', { mediaItemIds }),
|
||||
removeItemsFromAlbum: (albumId, mediaItemIds) =>
|
||||
callFetch(`${API_ROOT}/albums/${albumId}:batchRemoveMediaItems`, 'POST', { mediaItemIds }),
|
||||
};
|
||||
};
|
||||
|
||||
export default GooglePhotosAPI;
|
||||
+6
-16
@@ -1,18 +1,8 @@
|
||||
/**
|
||||
* iCloud Photos API - Placeholder
|
||||
*
|
||||
* Apple does not provide a public REST API for iCloud Photos.
|
||||
* This file is reserved for future integration if Apple provides API access
|
||||
* or if a third-party solution becomes available.
|
||||
*/
|
||||
|
||||
const iCloudPhotosAPI = () => {
|
||||
return {
|
||||
listMediaItems: async () => ({ isSuccess: false, message: 'iCloud API not available' }),
|
||||
getMediaItem: async () => ({ isSuccess: false, message: 'iCloud API not available' }),
|
||||
getAlbums: async () => ({ isSuccess: false, message: 'iCloud API not available' }),
|
||||
};
|
||||
};
|
||||
// iCloud Photos API placeholder — Apple does not provide a public REST API for iCloud Photos
|
||||
const iCloudPhotosAPI = () => ({
|
||||
listMediaItems: async () => ({ isSuccess: false, message: 'iCloud API not available' }),
|
||||
getMediaItem: async () => ({ isSuccess: false, message: 'iCloud API not available' }),
|
||||
getAlbums: async () => ({ isSuccess: false, message: 'iCloud API not available' }),
|
||||
});
|
||||
|
||||
export default iCloudPhotosAPI;
|
||||
|
||||
+67
-157
@@ -1,13 +1,6 @@
|
||||
import axios from 'axios';
|
||||
|
||||
/**
|
||||
* NAS API - HTTP Client for SFTP Backend
|
||||
*
|
||||
* Connects to the Node.js backend server (ServerFiles/server.js)
|
||||
* which handles all actual SSH/SFTP operations.
|
||||
*/
|
||||
|
||||
// Default backend URL - update this to match your server address
|
||||
// HTTP client that communicates with the SFTP backend (ServerFiles/server.js)
|
||||
const DEFAULT_BACKEND_URL = 'http://192.168.1.100:3001';
|
||||
|
||||
class NASAPI {
|
||||
@@ -18,113 +11,72 @@ class NASAPI {
|
||||
this.credentials = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to NAS via the backend server
|
||||
* The backend handles the actual SSH connection
|
||||
*/
|
||||
// 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');
|
||||
|
||||
this.credentials = { host, port: port || 22, username, photoFolder };
|
||||
|
||||
try {
|
||||
if (!host || !username || !password) {
|
||||
throw new Error('Host, username, and password are required');
|
||||
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}` };
|
||||
}
|
||||
|
||||
// Store credentials locally
|
||||
this.credentials = { host, port: port || 22, username, photoFolder };
|
||||
|
||||
// Try to connect via backend
|
||||
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;
|
||||
console.log(`NAS connected via backend: ${host}:${port}`);
|
||||
return { success: true, message: `Connected to ${host}` };
|
||||
}
|
||||
} catch (backendErr) {
|
||||
// Backend not available - store credentials for when it becomes available
|
||||
console.log('Backend not available, storing NAS credentials locally');
|
||||
}
|
||||
|
||||
// Even without backend, store config so user can proceed
|
||||
this.isConnected = true;
|
||||
return {
|
||||
success: true,
|
||||
message: `NAS configured: ${host} (backend connection pending)`,
|
||||
connectionData: this.credentials,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('NAS connection error:', error);
|
||||
throw error;
|
||||
} 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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get authorization headers for backend requests
|
||||
*/
|
||||
getHeaders() {
|
||||
if (!this.connectionToken) return {};
|
||||
return { Authorization: `Bearer ${this.connectionToken}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* List directory contents on the NAS
|
||||
*/
|
||||
async listDirectory(path = '/') {
|
||||
if (!this.connectionToken) {
|
||||
return { path, files: [], message: 'Backend not connected' };
|
||||
}
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${this.backendUrl}/api/sftp/list`,
|
||||
{ path },
|
||||
{ headers: this.getHeaders(), timeout: 15000 }
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('List directory error:', error.message);
|
||||
throw new Error('Failed to list directory: ' + error.message);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get photos from designated NAS folder
|
||||
*/
|
||||
// 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' };
|
||||
}
|
||||
if (!this.connectionToken) return { mediaItems: [], folder: targetFolder, message: 'Backend not connected' };
|
||||
|
||||
try {
|
||||
const listing = await this.listDirectory(targetFolder);
|
||||
const photoExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.heic'];
|
||||
const videoExtensions = ['.mp4', '.mov', '.avi', '.mkv', '.webm'];
|
||||
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 photoExtensions.includes('.' + ext) || videoExtensions.includes('.' + ext);
|
||||
const ext = '.' + (file.name || '').toLowerCase().split('.').pop();
|
||||
return photoExts.includes(ext) || videoExts.includes(ext);
|
||||
})
|
||||
.map((file) => ({
|
||||
source_service: 'NAS',
|
||||
source_id: `nas_${file.name}`,
|
||||
title: file.name,
|
||||
remote_path: `${targetFolder}/${file.name}`,
|
||||
media_type: videoExtensions.includes('.' + (file.name || '').toLowerCase().split('.').pop()) ? 'video' : 'photo',
|
||||
created_date: file.modifyTime,
|
||||
size: file.size,
|
||||
}));
|
||||
.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) {
|
||||
@@ -133,84 +85,42 @@ class NASAPI {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload file to NAS
|
||||
*/
|
||||
async uploadFile(localPath, remotePath) {
|
||||
if (!this.connectionToken) {
|
||||
throw new Error('Backend not connected - cannot upload');
|
||||
}
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('remotePath', remotePath);
|
||||
formData.append('file', { uri: localPath, name: remotePath.split('/').pop(), type: 'application/octet-stream' });
|
||||
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' });
|
||||
|
||||
const response = await axios.post(
|
||||
`${this.backendUrl}/api/sftp/upload`,
|
||||
formData,
|
||||
{ headers: { ...this.getHeaders(), 'Content-Type': 'multipart/form-data' }, timeout: 60000 }
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error.message);
|
||||
throw new Error('Upload failed: ' + error.message);
|
||||
}
|
||||
const response = await axios.post(
|
||||
`${this.backendUrl}/api/sftp/upload`, formData,
|
||||
{ headers: { ...this.getHeaders(), 'Content-Type': 'multipart/form-data' }, timeout: 60000 }
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create directory on NAS
|
||||
*/
|
||||
async createDirectory(remotePath) {
|
||||
if (!this.connectionToken) {
|
||||
throw new Error('Backend not connected');
|
||||
}
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${this.backendUrl}/api/sftp/mkdir`,
|
||||
{ path: remotePath },
|
||||
{ headers: this.getHeaders(), timeout: 10000 }
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Create directory error:', error.message);
|
||||
throw error;
|
||||
}
|
||||
if (!this.connectionToken) throw new Error('Backend not connected');
|
||||
const response = await axios.post(
|
||||
`${this.backendUrl}/api/sftp/mkdir`, { path: remotePath },
|
||||
{ headers: this.getHeaders(), timeout: 10000 }
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete file from NAS
|
||||
*/
|
||||
async deleteFile(remotePath) {
|
||||
if (!this.connectionToken) {
|
||||
throw new Error('Backend not connected');
|
||||
}
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${this.backendUrl}/api/sftp/delete`,
|
||||
{ path: remotePath },
|
||||
{ headers: this.getHeaders(), timeout: 10000 }
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Delete error:', error.message);
|
||||
throw error;
|
||||
}
|
||||
if (!this.connectionToken) throw new Error('Backend not connected');
|
||||
const response = await axios.post(
|
||||
`${this.backendUrl}/api/sftp/delete`, { path: remotePath },
|
||||
{ headers: this.getHeaders(), timeout: 10000 }
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from NAS
|
||||
*/
|
||||
async disconnect() {
|
||||
if (this.connectionToken) {
|
||||
try {
|
||||
await axios.post(
|
||||
`${this.backendUrl}/api/sftp/disconnect`,
|
||||
{},
|
||||
{ headers: this.getHeaders(), timeout: 5000 }
|
||||
);
|
||||
} catch (err) {
|
||||
// Ignore disconnect errors
|
||||
}
|
||||
await axios.post(`${this.backendUrl}/api/sftp/disconnect`, {}, { headers: this.getHeaders(), timeout: 5000 });
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
this.connectionToken = null;
|
||||
this.isConnected = false;
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
/**
|
||||
* Abstract base class for authentication services
|
||||
* This provides a common interface for different authentication providers
|
||||
*/
|
||||
// Abstract base class for authentication services — provides a common interface for all providers
|
||||
export class AuthServiceBase {
|
||||
constructor() {
|
||||
if (new.target === AuthServiceBase) {
|
||||
@@ -9,26 +6,11 @@ export class AuthServiceBase {
|
||||
}
|
||||
}
|
||||
|
||||
async authenticate() {
|
||||
throw new Error('authenticate() must be implemented');
|
||||
}
|
||||
|
||||
async getAccessToken() {
|
||||
throw new Error('getAccessToken() must be implemented');
|
||||
}
|
||||
|
||||
async refreshToken() {
|
||||
throw new Error('refreshToken() must be implemented');
|
||||
}
|
||||
|
||||
async revokeAccess() {
|
||||
throw new Error('revokeAccess() must be implemented');
|
||||
}
|
||||
|
||||
async getUserInfo() {
|
||||
throw new Error('getUserInfo() must be implemented');
|
||||
}
|
||||
async authenticate() { throw new Error('authenticate() must be implemented'); }
|
||||
async getAccessToken() { throw new Error('getAccessToken() must be implemented'); }
|
||||
async refreshToken() { throw new Error('refreshToken() must be implemented'); }
|
||||
async revokeAccess() { throw new Error('revokeAccess() must be implemented'); }
|
||||
async getUserInfo() { throw new Error('getUserInfo() must be implemented'); }
|
||||
}
|
||||
|
||||
export default AuthServiceBase;
|
||||
|
||||
+12
-19
@@ -4,7 +4,7 @@ import { AuthServiceBase } from './AuthServiceBase';
|
||||
|
||||
WebBrowser.maybeCompleteAuthSession();
|
||||
|
||||
// TODO: Replace with your real Google Cloud OAuth Client ID
|
||||
// Replace with your real Google Cloud OAuth Client ID (see ignorefiles/GOOGLE_OAUTH_SETUP.md)
|
||||
const GOOGLE_CLIENT_ID = 'YOUR_GOOGLE_CLIENT_ID.apps.googleusercontent.com';
|
||||
|
||||
const GOOGLE_DISCOVERY = {
|
||||
@@ -13,7 +13,7 @@ const GOOGLE_DISCOVERY = {
|
||||
revocationEndpoint: 'https://oauth2.googleapis.com/revoke',
|
||||
};
|
||||
|
||||
// Google Photos Library API scopes - read and write
|
||||
// Google Photos Library API scopes — read, write, and user profile
|
||||
const SCOPES = [
|
||||
'https://www.googleapis.com/auth/photoslibrary',
|
||||
'https://www.googleapis.com/auth/photoslibrary.appendonly',
|
||||
@@ -31,19 +31,15 @@ class GoogleAUTH extends AuthServiceBase {
|
||||
this.idToken = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate with Google using expo-auth-session
|
||||
* Falls back to placeholder tokens when no real client ID is set
|
||||
*/
|
||||
// Authenticate via Google OAuth; uses placeholder tokens when no real client ID is configured
|
||||
async authenticate() {
|
||||
try {
|
||||
if (GOOGLE_CLIENT_ID === 'YOUR_GOOGLE_CLIENT_ID.apps.googleusercontent.com') {
|
||||
console.log('Google Auth: Using placeholder mode (no client ID configured)');
|
||||
console.log('Google Auth: placeholder mode (no client ID configured)');
|
||||
this.accessToken = 'placeholder_google_token_' + Date.now();
|
||||
this.refreshToken = 'placeholder_refresh_' + Date.now();
|
||||
this.expiresAt = Date.now() + 3600000;
|
||||
this.email = 'google-user@placeholder.com';
|
||||
|
||||
return {
|
||||
success: true,
|
||||
accessToken: this.accessToken,
|
||||
@@ -52,8 +48,10 @@ class GoogleAUTH extends AuthServiceBase {
|
||||
};
|
||||
}
|
||||
|
||||
// Real OAuth flow
|
||||
const redirectUri = AuthSession.makeRedirectUri({ preferLocalhost: true });
|
||||
const redirectUri = AuthSession.makeRedirectUri({
|
||||
scheme: 'media-aggregation',
|
||||
preferLocalhost: true,
|
||||
});
|
||||
|
||||
const authRequest = new AuthSession.AuthRequest({
|
||||
clientId: GOOGLE_CLIENT_ID,
|
||||
@@ -114,6 +112,7 @@ class GoogleAUTH extends AuthServiceBase {
|
||||
}
|
||||
}
|
||||
|
||||
// Return a valid access token, refreshing if expired
|
||||
async getAccessToken() {
|
||||
if (this.accessToken && this.expiresAt > Date.now()) return this.accessToken;
|
||||
if (this.refreshToken) return await this.refreshAccessToken();
|
||||
@@ -165,9 +164,7 @@ class GoogleAUTH extends AuthServiceBase {
|
||||
return info || { id: 'google_user', email: this.email || 'unknown', name: 'Google User' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch media items from Google Photos Library API
|
||||
*/
|
||||
// Fetch media items from the Google Photos Library API
|
||||
async getPhotos(pageSize = 50, pageToken = null) {
|
||||
try {
|
||||
const token = await this.getAccessToken();
|
||||
@@ -204,9 +201,7 @@ class GoogleAUTH extends AuthServiceBase {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch albums from Google Photos Library API
|
||||
*/
|
||||
// Fetch album list from the Google Photos Library API
|
||||
async getAlbums(pageSize = 50, pageToken = null) {
|
||||
try {
|
||||
const token = await this.getAccessToken();
|
||||
@@ -227,9 +222,7 @@ class GoogleAUTH extends AuthServiceBase {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload media to Google Photos Library
|
||||
*/
|
||||
// Upload a photo to Google Photos Library
|
||||
async uploadPhoto(filePath, filename, mimeType = 'image/jpeg') {
|
||||
try {
|
||||
const token = await this.getAccessToken();
|
||||
|
||||
+6
-25
@@ -1,10 +1,6 @@
|
||||
import { AuthServiceBase } from './AuthServiceBase';
|
||||
|
||||
/**
|
||||
* iCloud Photos Authentication Service
|
||||
* Placeholder - Apple does not provide a public iCloud Photos API.
|
||||
* This is kept for future integration if/when Apple provides API access.
|
||||
*/
|
||||
// iCloud Photos placeholder — Apple does not provide a public iCloud Photos API
|
||||
class IOSAUTH extends AuthServiceBase {
|
||||
constructor() {
|
||||
super();
|
||||
@@ -12,13 +8,8 @@ class IOSAUTH extends AuthServiceBase {
|
||||
}
|
||||
|
||||
async authenticate() {
|
||||
console.log('iCloud Auth: Placeholder - no Apple API available');
|
||||
this.accessToken = 'icloud_placeholder_' + Date.now();
|
||||
return {
|
||||
success: true,
|
||||
accessToken: this.accessToken,
|
||||
message: 'iCloud placeholder - full integration pending',
|
||||
};
|
||||
return { success: true, accessToken: this.accessToken, message: 'iCloud placeholder — full integration pending' };
|
||||
}
|
||||
|
||||
async getAccessToken() {
|
||||
@@ -26,26 +17,16 @@ class IOSAUTH extends AuthServiceBase {
|
||||
throw new Error('Not authenticated with iCloud');
|
||||
}
|
||||
|
||||
async refreshToken() {
|
||||
return this.authenticate();
|
||||
}
|
||||
async refreshToken() { return this.authenticate(); }
|
||||
|
||||
async revokeAccess() {
|
||||
this.accessToken = null;
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async getUserInfo() {
|
||||
return { id: 'icloud_placeholder', name: 'iCloud User' };
|
||||
}
|
||||
|
||||
async getPhotos() {
|
||||
return { mediaItems: [], nextPageToken: null };
|
||||
}
|
||||
|
||||
async getAlbums() {
|
||||
return { albums: [], nextPageToken: null };
|
||||
}
|
||||
async getUserInfo() { return { id: 'icloud_placeholder', name: 'iCloud User' }; }
|
||||
async getPhotos() { return { mediaItems: [], nextPageToken: null }; }
|
||||
async getAlbums() { return { albums: [], nextPageToken: null }; }
|
||||
}
|
||||
|
||||
export default IOSAUTH;
|
||||
|
||||
+34
-154
@@ -1,12 +1,7 @@
|
||||
import { AuthServiceBase } from './AuthServiceBase';
|
||||
import NASAPI from '../API/NASAPI';
|
||||
|
||||
/**
|
||||
* NAS SFTP Authentication Service
|
||||
*
|
||||
* Uses NASAPI for direct NAS connection management
|
||||
* No backend server required
|
||||
*/
|
||||
// NAS SFTP authentication — connects to the backend server via NASAPI
|
||||
class NASAUTH extends AuthServiceBase {
|
||||
constructor() {
|
||||
super();
|
||||
@@ -19,17 +14,9 @@ class NASAUTH extends AuthServiceBase {
|
||||
this.isConnected = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate with NAS/SFTP server
|
||||
*/
|
||||
async authenticate(host, port = 22, username, password, photoFolder = '/Photos') {
|
||||
try {
|
||||
// Validate input
|
||||
if (!host || !username || !password) {
|
||||
throw new Error('Host, username, and password are required');
|
||||
}
|
||||
|
||||
console.log(`Connecting to NAS at ${host}:${port}...`);
|
||||
if (!host || !username || !password) throw new Error('Host, username, and password are required');
|
||||
|
||||
this.host = host;
|
||||
this.port = port || 22;
|
||||
@@ -37,17 +24,10 @@ class NASAUTH extends AuthServiceBase {
|
||||
this.password = password;
|
||||
this.photoFolder = photoFolder;
|
||||
|
||||
// Connect via NASAPI
|
||||
const result = await this.nasAPI.connect(host, this.port, username, password, photoFolder);
|
||||
|
||||
await this.nasAPI.connect(host, this.port, username, password, photoFolder);
|
||||
this.isConnected = true;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `NAS configured: ${host}`,
|
||||
host: this.host,
|
||||
username: this.username,
|
||||
};
|
||||
return { success: true, message: `NAS configured: ${host}`, host: this.host, username: this.username };
|
||||
} catch (error) {
|
||||
console.error('NAS authentication error:', error.message);
|
||||
this.isConnected = false;
|
||||
@@ -55,167 +35,67 @@ class NASAUTH extends AuthServiceBase {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current access/connection status
|
||||
*/
|
||||
async getAccessToken() {
|
||||
if (!this.isConnected) {
|
||||
throw new Error('Not connected to NAS');
|
||||
}
|
||||
return {
|
||||
host: this.host,
|
||||
username: this.username,
|
||||
isConnected: this.isConnected,
|
||||
};
|
||||
if (!this.isConnected) throw new Error('Not connected to NAS');
|
||||
return { host: this.host, username: this.username, isConnected: this.isConnected };
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh connection (reconnect if needed)
|
||||
*/
|
||||
async refreshToken() {
|
||||
try {
|
||||
if (this.host && this.username && this.password) {
|
||||
return await this.authenticate(
|
||||
this.host,
|
||||
this.port,
|
||||
this.username,
|
||||
this.password,
|
||||
this.photoFolder
|
||||
);
|
||||
}
|
||||
throw new Error('No NAS credentials available for reconnection');
|
||||
} catch (error) {
|
||||
console.error('Connection refresh error:', error);
|
||||
throw error;
|
||||
if (this.host && this.username && this.password) {
|
||||
return await this.authenticate(this.host, this.port, this.username, this.password, this.photoFolder);
|
||||
}
|
||||
throw new Error('No NAS credentials available for reconnection');
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from NAS
|
||||
*/
|
||||
async revokeAccess() {
|
||||
try {
|
||||
await this.nasAPI.disconnect();
|
||||
|
||||
this.isConnected = false;
|
||||
this.host = null;
|
||||
this.username = null;
|
||||
this.password = null;
|
||||
this.photoFolder = null;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'NAS connection closed',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Disconnect error:', error.message);
|
||||
this.isConnected = false;
|
||||
throw error;
|
||||
}
|
||||
this.isConnected = false;
|
||||
this.host = null;
|
||||
this.username = null;
|
||||
this.password = null;
|
||||
this.photoFolder = null;
|
||||
return { success: true, message: 'NAS connection closed' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get NAS server information
|
||||
*/
|
||||
async getUserInfo() {
|
||||
try {
|
||||
if (!this.isConnected) {
|
||||
throw new Error('Not connected to NAS');
|
||||
}
|
||||
|
||||
return {
|
||||
host: this.host,
|
||||
username: this.username,
|
||||
port: this.port,
|
||||
photoFolder: this.photoFolder,
|
||||
connectedAt: new Date().toISOString(),
|
||||
serverType: 'NAS SFTP',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Get user info error:', error);
|
||||
throw error;
|
||||
}
|
||||
if (!this.isConnected) throw new Error('Not connected to NAS');
|
||||
return {
|
||||
host: this.host,
|
||||
username: this.username,
|
||||
port: this.port,
|
||||
photoFolder: this.photoFolder,
|
||||
connectedAt: new Date().toISOString(),
|
||||
serverType: 'NAS SFTP',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List directories on NAS
|
||||
*/
|
||||
async listDirectory(remotePath = '/') {
|
||||
try {
|
||||
if (!this.isConnected) {
|
||||
throw new Error('Not connected to NAS');
|
||||
}
|
||||
|
||||
return await this.nasAPI.listDirectory(remotePath);
|
||||
} catch (error) {
|
||||
console.error('List directory error:', error);
|
||||
throw error;
|
||||
}
|
||||
if (!this.isConnected) throw new Error('Not connected to NAS');
|
||||
return await this.nasAPI.listDirectory(remotePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload file to NAS
|
||||
*/
|
||||
async uploadFile(localPath, remotePath) {
|
||||
try {
|
||||
if (!this.isConnected) {
|
||||
throw new Error('Not connected to NAS');
|
||||
}
|
||||
|
||||
return await this.nasAPI.uploadFile(localPath, remotePath);
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
throw error;
|
||||
}
|
||||
if (!this.isConnected) throw new Error('Not connected to NAS');
|
||||
return await this.nasAPI.uploadFile(localPath, remotePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get photos from designated NAS photo folder
|
||||
*/
|
||||
async getPhotos(photoFolder = null) {
|
||||
try {
|
||||
if (!this.isConnected) {
|
||||
throw new Error('Not connected to NAS');
|
||||
}
|
||||
|
||||
const folder = photoFolder || this.photoFolder || '/Photos';
|
||||
return await this.nasAPI.getPhotos(folder);
|
||||
} catch (error) {
|
||||
console.error('Get photos error:', error);
|
||||
throw error;
|
||||
}
|
||||
if (!this.isConnected) throw new Error('Not connected to NAS');
|
||||
return await this.nasAPI.getPhotos(photoFolder || this.photoFolder || '/Photos');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create directory on NAS
|
||||
*/
|
||||
async createDirectory(remotePath) {
|
||||
try {
|
||||
if (!this.isConnected) {
|
||||
throw new Error('Not connected to NAS');
|
||||
}
|
||||
|
||||
return await this.nasAPI.createDirectory(remotePath);
|
||||
} catch (error) {
|
||||
console.error('Create directory error:', error);
|
||||
throw error;
|
||||
}
|
||||
if (!this.isConnected) throw new Error('Not connected to NAS');
|
||||
return await this.nasAPI.createDirectory(remotePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete file from NAS
|
||||
*/
|
||||
async deleteFile(remotePath) {
|
||||
try {
|
||||
if (!this.isConnected) {
|
||||
throw new Error('Not connected to NAS');
|
||||
}
|
||||
|
||||
return await this.nasAPI.deleteFile(remotePath);
|
||||
} catch (error) {
|
||||
console.error('Delete file error:', error);
|
||||
throw error;
|
||||
}
|
||||
if (!this.isConnected) throw new Error('Not connected to NAS');
|
||||
return await this.nasAPI.deleteFile(remotePath);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+191
-11
@@ -1,45 +1,202 @@
|
||||
import React from 'react';
|
||||
import React, { useRef, useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Modal, View, Text, TouchableOpacity, Image, StyleSheet,
|
||||
Dimensions, FlatList,
|
||||
} from 'react-native';
|
||||
import { useTheme } from '../services/ThemeContext';
|
||||
|
||||
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||
const THUMB_SIZE = 60;
|
||||
const THUMB_GAP = 4;
|
||||
|
||||
const getServiceIcon = (type) =>
|
||||
({ GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱');
|
||||
const getServiceName = (type) =>
|
||||
({ GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud', NAS: 'NAS' }[type] || type);
|
||||
|
||||
const MediaViewer = ({ visible, media, onClose }) => {
|
||||
/**
|
||||
* Fullscreen media viewer with thumbnail carousel.
|
||||
*
|
||||
* 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
|
||||
*/
|
||||
const MediaViewer = ({ visible, media, allMedia, onClose }) => {
|
||||
const { theme } = useTheme();
|
||||
const mainListRef = useRef(null);
|
||||
const thumbListRef = useRef(null);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [currentMedia, setCurrentMedia] = useState(media);
|
||||
|
||||
const mediaList = allMedia && allMedia.length > 0 ? allMedia : (media ? [media] : []);
|
||||
const hasCarousel = mediaList.length > 1;
|
||||
|
||||
// When the modal opens or media prop changes, scroll to the correct item
|
||||
useEffect(() => {
|
||||
if (!visible || !media || mediaList.length === 0) return;
|
||||
|
||||
const idx = mediaList.findIndex(
|
||||
(m) => (m.source_id || m.id) === (media.source_id || media.id)
|
||||
);
|
||||
const newIndex = idx >= 0 ? idx : 0;
|
||||
setActiveIndex(newIndex);
|
||||
setCurrentMedia(mediaList[newIndex]);
|
||||
|
||||
// Delay scroll so FlatList has rendered
|
||||
setTimeout(() => {
|
||||
if (mainListRef.current && newIndex >= 0) {
|
||||
mainListRef.current.scrollToOffset({ offset: newIndex * SCREEN_WIDTH, animated: false });
|
||||
}
|
||||
if (thumbListRef.current && newIndex >= 0) {
|
||||
try {
|
||||
thumbListRef.current.scrollToIndex({ index: newIndex, animated: false, viewPosition: 0.5 });
|
||||
} catch (e) { /* index might be out of range during initial render */ }
|
||||
}
|
||||
}, 100);
|
||||
}, [visible, media]);
|
||||
|
||||
const onMainScroll = useCallback(
|
||||
(event) => {
|
||||
const offsetX = event.nativeEvent.contentOffset.x;
|
||||
const idx = Math.round(offsetX / SCREEN_WIDTH);
|
||||
if (idx >= 0 && idx < mediaList.length && idx !== activeIndex) {
|
||||
setActiveIndex(idx);
|
||||
setCurrentMedia(mediaList[idx]);
|
||||
if (thumbListRef.current) {
|
||||
try {
|
||||
thumbListRef.current.scrollToIndex({ index: idx, animated: true, viewPosition: 0.5 });
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
},
|
||||
[activeIndex, mediaList]
|
||||
);
|
||||
|
||||
const onThumbPress = (index) => {
|
||||
setActiveIndex(index);
|
||||
setCurrentMedia(mediaList[index]);
|
||||
if (mainListRef.current) {
|
||||
mainListRef.current.scrollToOffset({ offset: index * SCREEN_WIDTH, animated: true });
|
||||
}
|
||||
};
|
||||
|
||||
if (!media) return null;
|
||||
|
||||
const renderMainItem = ({ item }) => (
|
||||
<View style={{ width: SCREEN_WIDTH, height: SCREEN_HEIGHT }}>
|
||||
{item.local_path ? (
|
||||
<Image source={{ uri: item.local_path }} style={styles.fullImage} resizeMode="contain" />
|
||||
) : (
|
||||
<View style={styles.fullPlaceholder}>
|
||||
<Text style={{ fontSize: 64 }}>📷</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderThumb = ({ item, index }) => (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.thumbItem,
|
||||
index === activeIndex && { borderColor: '#fff', borderWidth: 2 },
|
||||
]}
|
||||
onPress={() => onThumbPress(index)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{item.local_path ? (
|
||||
<Image source={{ uri: item.local_path }} style={styles.thumbImage} resizeMode="cover" />
|
||||
) : (
|
||||
<View style={styles.thumbPlaceholder}>
|
||||
<Text style={{ fontSize: 20 }}>📷</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
const displayMedia = currentMedia || media;
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||
<View style={[styles.container, { backgroundColor: theme.modalBg }]}>
|
||||
{/* Close button */}
|
||||
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
|
||||
<Text style={styles.closeText}>✕</Text>
|
||||
</TouchableOpacity>
|
||||
{media.local_path ? (
|
||||
<Image source={{ uri: media.local_path }} style={styles.fullImage} resizeMode="contain" />
|
||||
) : (
|
||||
<View style={styles.fullPlaceholder}>
|
||||
<Text style={{ fontSize: 64 }}>📷</Text>
|
||||
|
||||
{/* Counter */}
|
||||
{hasCarousel && (
|
||||
<View style={styles.counter}>
|
||||
<Text style={styles.counterText}>
|
||||
{activeIndex + 1} / {mediaList.length}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Main image area – swipeable */}
|
||||
{hasCarousel ? (
|
||||
<FlatList
|
||||
ref={mainListRef}
|
||||
data={mediaList}
|
||||
renderItem={renderMainItem}
|
||||
keyExtractor={(item, i) => item.source_id || item.id?.toString() || i.toString()}
|
||||
horizontal
|
||||
pagingEnabled
|
||||
showsHorizontalScrollIndicator={false}
|
||||
onMomentumScrollEnd={onMainScroll}
|
||||
getItemLayout={(_, index) => ({
|
||||
length: SCREEN_WIDTH,
|
||||
offset: SCREEN_WIDTH * index,
|
||||
index,
|
||||
})}
|
||||
style={styles.mainList}
|
||||
/>
|
||||
) : (
|
||||
// Single image mode (backward compat)
|
||||
displayMedia.local_path ? (
|
||||
<Image source={{ uri: displayMedia.local_path }} style={styles.fullImage} resizeMode="contain" />
|
||||
) : (
|
||||
<View style={styles.fullPlaceholder}>
|
||||
<Text style={{ fontSize: 64 }}>📷</Text>
|
||||
</View>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Thumbnail carousel */}
|
||||
{hasCarousel && (
|
||||
<View style={styles.thumbCarousel}>
|
||||
<FlatList
|
||||
ref={thumbListRef}
|
||||
data={mediaList}
|
||||
renderItem={renderThumb}
|
||||
keyExtractor={(item, i) => 'thumb_' + (item.source_id || item.id?.toString() || i)}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.thumbContent}
|
||||
getItemLayout={(_, index) => ({
|
||||
length: THUMB_SIZE + THUMB_GAP,
|
||||
offset: (THUMB_SIZE + THUMB_GAP) * index,
|
||||
index,
|
||||
})}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Info panel */}
|
||||
<View style={[styles.infoPanel, { backgroundColor: theme.modalInfoBg }]}>
|
||||
<Text style={styles.title}>{media.title || 'Photo'}</Text>
|
||||
<Text style={styles.title}>{displayMedia.title || 'Photo'}</Text>
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.label}>Source</Text>
|
||||
<Text style={styles.value}>
|
||||
{getServiceIcon(media.source_service)} {getServiceName(media.source_service)}
|
||||
{getServiceIcon(displayMedia.source_service)} {getServiceName(displayMedia.source_service)}
|
||||
</Text>
|
||||
</View>
|
||||
{media.created_date && (
|
||||
{displayMedia.created_date && (
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.label}>Date</Text>
|
||||
<Text style={styles.value}>
|
||||
{new Date(media.created_date).toLocaleDateString()}
|
||||
{new Date(displayMedia.created_date).toLocaleDateString()}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
@@ -58,8 +215,31 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'center', alignItems: 'center',
|
||||
},
|
||||
closeText: { color: '#fff', fontSize: 20, fontWeight: '600' },
|
||||
counter: {
|
||||
position: 'absolute', top: 48, left: 0, right: 0, zIndex: 20,
|
||||
alignItems: 'center',
|
||||
},
|
||||
counterText: { color: '#fff', fontSize: 14, fontWeight: '600' },
|
||||
mainList: { flex: 1 },
|
||||
fullImage: { flex: 1, width: '100%' },
|
||||
fullPlaceholder: { flex: 1, justifyContent: 'center', alignItems: 'center' },
|
||||
// Thumbnail carousel
|
||||
thumbCarousel: {
|
||||
position: 'absolute', bottom: 110, left: 0, right: 0, zIndex: 15,
|
||||
backgroundColor: 'rgba(0,0,0,0.4)',
|
||||
paddingVertical: 8,
|
||||
},
|
||||
thumbContent: { paddingHorizontal: 12, gap: THUMB_GAP },
|
||||
thumbItem: {
|
||||
width: THUMB_SIZE, height: THUMB_SIZE, borderRadius: 6,
|
||||
overflow: 'hidden', borderWidth: 2, borderColor: 'transparent',
|
||||
},
|
||||
thumbImage: { width: '100%', height: '100%' },
|
||||
thumbPlaceholder: {
|
||||
width: '100%', height: '100%', backgroundColor: '#333',
|
||||
justifyContent: 'center', alignItems: 'center',
|
||||
},
|
||||
// Info panel
|
||||
infoPanel: {
|
||||
padding: 16, paddingBottom: 32,
|
||||
borderTopLeftRadius: 16, borderTopRightRadius: 16,
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
|
||||
/**
|
||||
* Circular health indicator dot
|
||||
*
|
||||
* status values:
|
||||
* 'inactive' → grey (not used / not connected)
|
||||
* 'healthy' → green (connected and working)
|
||||
* 'error' → red (connected but errors fetching data)
|
||||
* 'checking' → amber (currently checking)
|
||||
*/
|
||||
// Circular health indicator: inactive=grey, healthy=green, error=red, checking=amber
|
||||
const COLORS = {
|
||||
inactive: '#9e9e9e',
|
||||
healthy: '#34a853',
|
||||
|
||||
@@ -121,7 +121,7 @@ const CollectionsScreen = ({ route, navigation }) => {
|
||||
contentContainerStyle={styles.gridContent}
|
||||
/>
|
||||
)}
|
||||
<MediaViewer visible={viewerVisible} media={selectedMedia} onClose={() => setViewerVisible(false)} />
|
||||
<MediaViewer visible={viewerVisible} media={selectedMedia} allMedia={albumMedia} onClose={() => setViewerVisible(false)} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -187,6 +187,7 @@ const HomeScreen = ({ navigation, route }) => {
|
||||
<MediaViewer
|
||||
visible={viewerVisible}
|
||||
media={selectedMedia}
|
||||
allMedia={filteredMedia}
|
||||
onClose={() => setViewerVisible(false)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -6,10 +6,7 @@ import {
|
||||
import { useTheme } from '../services/ThemeContext';
|
||||
import DatabaseService from '../services/DatabaseService';
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// App icon placeholder – change the require path when you
|
||||
// have a final image in the assets folder.
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// App icon — change this path when the final asset is ready
|
||||
const APP_ICON = require('../../assets/icon.png');
|
||||
|
||||
const LoginScreen = ({ navigation }) => {
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
import { useTheme } from '../services/ThemeContext';
|
||||
import DatabaseService from '../services/DatabaseService';
|
||||
|
||||
// Same icon constant as LoginScreen – edit in one place
|
||||
// App icon — same constant as LoginScreen
|
||||
const APP_ICON = require('../../assets/icon.png');
|
||||
|
||||
const RegisterScreen = ({ navigation }) => {
|
||||
|
||||
@@ -92,7 +92,7 @@ const SearchScreen = ({ route }) => {
|
||||
</>
|
||||
)}
|
||||
|
||||
<MediaViewer visible={viewerVisible} media={selectedMedia} onClose={() => setViewerVisible(false)} />
|
||||
<MediaViewer visible={viewerVisible} media={selectedMedia} allMedia={results} onClose={() => setViewerVisible(false)} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,24 +1,19 @@
|
||||
import * as Crypto from 'expo-crypto';
|
||||
|
||||
/**
|
||||
* Credential Encryption Service
|
||||
*
|
||||
* Uses expo-crypto for password hashing (SHA-256)
|
||||
* Uses base64 encoding for storing account data objects in the database
|
||||
* Handles password hashing (SHA-256) and base64 encoding of account data objects
|
||||
*/
|
||||
class CredentialEncryption {
|
||||
/**
|
||||
* Hash a password using SHA-256 via expo-crypto
|
||||
* Returns a hex digest string
|
||||
* Hash a password with SHA-256 and return a hex digest
|
||||
*/
|
||||
async hashPassword(password) {
|
||||
try {
|
||||
if (!password) throw new Error('Password is required');
|
||||
const digest = await Crypto.digestStringAsync(
|
||||
return await Crypto.digestStringAsync(
|
||||
Crypto.CryptoDigestAlgorithm.SHA256,
|
||||
password
|
||||
);
|
||||
return digest;
|
||||
} catch (error) {
|
||||
console.error('Password hashing error:', error);
|
||||
throw new Error('Failed to hash password: ' + error.message);
|
||||
@@ -26,7 +21,7 @@ class CredentialEncryption {
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a password against a stored hash
|
||||
* Compare a plaintext password against a stored hash
|
||||
*/
|
||||
async verifyPassword(password, storedHash) {
|
||||
try {
|
||||
@@ -39,15 +34,13 @@ class CredentialEncryption {
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt an object for database storage (base64-encoded JSON)
|
||||
* Encode an object as a base64 JSON string for database storage
|
||||
*/
|
||||
encrypt(data) {
|
||||
try {
|
||||
if (!data) throw new Error('No data provided to encrypt');
|
||||
const jsonString = JSON.stringify(data);
|
||||
// btoa is available in React Native / Hermes
|
||||
const encoded = btoa(unescape(encodeURIComponent(jsonString)));
|
||||
return encoded;
|
||||
return btoa(unescape(encodeURIComponent(jsonString)));
|
||||
} catch (error) {
|
||||
console.error('Encryption error:', error);
|
||||
throw new Error('Failed to encrypt data: ' + error.message);
|
||||
@@ -55,7 +48,7 @@ class CredentialEncryption {
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a base64-encoded JSON string back to an object
|
||||
* Decode a base64 JSON string back to an object
|
||||
*/
|
||||
decrypt(encryptedData) {
|
||||
try {
|
||||
@@ -70,4 +63,3 @@ class CredentialEncryption {
|
||||
}
|
||||
|
||||
export default new CredentialEncryption();
|
||||
|
||||
@@ -5,9 +5,7 @@ let db = null;
|
||||
let initPromise = null;
|
||||
|
||||
const DatabaseService = {
|
||||
/**
|
||||
* Initialize the database and create tables
|
||||
*/
|
||||
// Open the database and create all tables
|
||||
initDatabase: async () => {
|
||||
if (initPromise) return initPromise;
|
||||
|
||||
@@ -63,13 +61,9 @@ const DatabaseService = {
|
||||
)
|
||||
`);
|
||||
|
||||
// Migration: add columns if they don't exist
|
||||
try {
|
||||
await db.execAsync(`ALTER TABLE users ADD COLUMN auto_backup_services TEXT DEFAULT '[]'`);
|
||||
} catch (e) { /* column already exists */ }
|
||||
try {
|
||||
await db.execAsync(`ALTER TABLE users ADD COLUMN theme_preference TEXT DEFAULT 'light'`);
|
||||
} catch (e) { /* column already exists */ }
|
||||
// Add optional columns (safe migration — errors ignored if columns already exist)
|
||||
try { await db.execAsync(`ALTER TABLE users ADD COLUMN auto_backup_services TEXT DEFAULT '[]'`); } catch (_) {}
|
||||
try { await db.execAsync(`ALTER TABLE users ADD COLUMN theme_preference TEXT DEFAULT 'light'`); } catch (_) {}
|
||||
|
||||
console.log('Database initialized successfully');
|
||||
return true;
|
||||
@@ -89,9 +83,6 @@ const DatabaseService = {
|
||||
if (!db) throw new Error('Database not initialized');
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new user with hashed password
|
||||
*/
|
||||
createUser: async (username, email, password) => {
|
||||
try {
|
||||
await DatabaseService.ensureDb();
|
||||
@@ -107,9 +98,7 @@ const DatabaseService = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Login user by username or email, verify hashed password
|
||||
*/
|
||||
// Authenticate by username or email
|
||||
loginUser: async (identifier, password) => {
|
||||
try {
|
||||
await DatabaseService.ensureDb();
|
||||
@@ -129,9 +118,6 @@ const DatabaseService = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if username or email already exists
|
||||
*/
|
||||
userExists: async (username, email) => {
|
||||
try {
|
||||
await DatabaseService.ensureDb();
|
||||
@@ -150,34 +136,26 @@ const DatabaseService = {
|
||||
try {
|
||||
await DatabaseService.ensureDb();
|
||||
if (!userId && userId !== 0) throw new Error('User ID is required');
|
||||
const row = await db.getFirstAsync('SELECT * FROM users WHERE id = ?', [userId]);
|
||||
return row || null;
|
||||
return (await db.getFirstAsync('SELECT * FROM users WHERE id = ?', [userId])) || null;
|
||||
} catch (error) {
|
||||
console.error('Error getting user:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Link an external service account to a user
|
||||
*/
|
||||
// Link an external service account (Google, iCloud, NAS) to a local user
|
||||
linkAccount: async (userId, serviceType, accountData, isDefault = false) => {
|
||||
try {
|
||||
await DatabaseService.ensureDb();
|
||||
if (!userId) throw new Error('User ID is required');
|
||||
if (!serviceType) throw new Error('Service type is required');
|
||||
if (!accountData || typeof accountData !== 'object') {
|
||||
throw new Error('Account data must be a valid object');
|
||||
}
|
||||
if (!accountData || typeof accountData !== 'object') throw new Error('Account data must be a valid object');
|
||||
|
||||
const encryptedData = CredentialEncryption.encrypt(accountData);
|
||||
if (!encryptedData) throw new Error('Encryption returned empty data');
|
||||
|
||||
// Remove existing account of same type first
|
||||
await db.runAsync(
|
||||
'DELETE FROM linked_accounts WHERE user_id = ? AND service_type = ?',
|
||||
[userId, serviceType]
|
||||
);
|
||||
// Replace any existing link for the same service type
|
||||
await db.runAsync('DELETE FROM linked_accounts WHERE user_id = ? AND service_type = ?', [userId, serviceType]);
|
||||
|
||||
const result = await db.runAsync(
|
||||
'INSERT INTO linked_accounts (user_id, service_type, account_data, is_default) VALUES (?, ?, ?, ?)',
|
||||
@@ -195,13 +173,9 @@ const DatabaseService = {
|
||||
await DatabaseService.ensureDb();
|
||||
if (!userId && userId !== 0) throw new Error('User ID is required');
|
||||
const rows = await db.getAllAsync(
|
||||
'SELECT * FROM linked_accounts WHERE user_id = ? AND is_enabled = 1',
|
||||
[userId]
|
||||
'SELECT * FROM linked_accounts WHERE user_id = ? AND is_enabled = 1', [userId]
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
account_data: CredentialEncryption.decrypt(row.account_data),
|
||||
}));
|
||||
return rows.map((row) => ({ ...row, account_data: CredentialEncryption.decrypt(row.account_data) }));
|
||||
} catch (error) {
|
||||
console.error('Error getting linked accounts:', error);
|
||||
throw error;
|
||||
@@ -238,8 +212,7 @@ const DatabaseService = {
|
||||
try {
|
||||
await DatabaseService.ensureDb();
|
||||
const row = await db.getFirstAsync(
|
||||
'SELECT * FROM linked_accounts WHERE user_id = ? AND is_default = 1',
|
||||
[userId]
|
||||
'SELECT * FROM linked_accounts WHERE user_id = ? AND is_default = 1', [userId]
|
||||
);
|
||||
if (!row) return null;
|
||||
return { ...row, account_data: CredentialEncryption.decrypt(row.account_data) };
|
||||
@@ -270,10 +243,7 @@ const DatabaseService = {
|
||||
try {
|
||||
await DatabaseService.ensureDb();
|
||||
if (!userId && userId !== 0) throw new Error('User ID is required');
|
||||
return await db.getAllAsync(
|
||||
'SELECT * FROM media WHERE user_id = ? ORDER BY created_date DESC',
|
||||
[userId]
|
||||
);
|
||||
return await db.getAllAsync('SELECT * FROM media WHERE user_id = ? ORDER BY created_date DESC', [userId]);
|
||||
} catch (error) {
|
||||
console.error('Error getting media:', error);
|
||||
throw error;
|
||||
@@ -352,14 +322,8 @@ const DatabaseService = {
|
||||
getAllLinkedAccounts: async (userId) => {
|
||||
try {
|
||||
await DatabaseService.ensureDb();
|
||||
const rows = await db.getAllAsync(
|
||||
'SELECT * FROM linked_accounts WHERE user_id = ?',
|
||||
[userId]
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
account_data: CredentialEncryption.decrypt(row.account_data),
|
||||
}));
|
||||
const rows = await db.getAllAsync('SELECT * FROM linked_accounts WHERE user_id = ?', [userId]);
|
||||
return rows.map((row) => ({ ...row, account_data: CredentialEncryption.decrypt(row.account_data) }));
|
||||
} catch (error) {
|
||||
console.error('Error getting all linked accounts:', error);
|
||||
throw error;
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as SecureStore from 'expo-secure-store';
|
||||
|
||||
const THEME_KEY = 'app_theme_preference';
|
||||
|
||||
// Available theme definitions
|
||||
const lightTheme = {
|
||||
name: 'light',
|
||||
background: '#ffffff',
|
||||
|
||||
Reference in new issue
Block a user