Save user data to internal folder (Linux)
This commit is contained in:
@@ -9,6 +9,7 @@ const PerformanceMonitor = require('./performance-monitor');
|
|||||||
const GPUFallback = require('./gpu-fallback');
|
const GPUFallback = require('./gpu-fallback');
|
||||||
const GPUConfig = require('./gpu-config');
|
const GPUConfig = require('./gpu-config');
|
||||||
const PluginManager = require('./plugin-manager');
|
const PluginManager = require('./plugin-manager');
|
||||||
|
const portableData = require('./portable-data');
|
||||||
|
|
||||||
// Initialize performance monitoring and GPU management
|
// Initialize performance monitoring and GPU management
|
||||||
const perfMonitor = new PerformanceMonitor();
|
const perfMonitor = new PerformanceMonitor();
|
||||||
@@ -16,6 +17,39 @@ const gpuFallback = new GPUFallback();
|
|||||||
const gpuConfig = new GPUConfig();
|
const gpuConfig = new GPUConfig();
|
||||||
const pluginManager = new PluginManager();
|
const pluginManager = new PluginManager();
|
||||||
|
|
||||||
|
// Initialize portable data paths BEFORE app.ready (must be done early)
|
||||||
|
// This only affects Linux when NEBULA_PORTABLE=1 is set
|
||||||
|
portableData.initialize();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the path for a user data file (bookmarks, history, etc.)
|
||||||
|
* Uses portable path on Linux when in portable mode, otherwise uses __dirname
|
||||||
|
* @param {string} filename - The filename (e.g., 'bookmarks.json')
|
||||||
|
* @returns {string} The full path to the file
|
||||||
|
*/
|
||||||
|
function getDataFilePath(filename) {
|
||||||
|
const portablePath = portableData.getDataFilePath(filename);
|
||||||
|
if (portablePath) {
|
||||||
|
return portablePath;
|
||||||
|
}
|
||||||
|
return path.join(__dirname, filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the directory path for user data files
|
||||||
|
* Uses portable path on Linux when in portable mode, otherwise uses __dirname
|
||||||
|
* @returns {string} The directory path
|
||||||
|
*/
|
||||||
|
function getDataDirPath() {
|
||||||
|
if (portableData.isPortableMode()) {
|
||||||
|
const portablePath = portableData.getPortableDataPath();
|
||||||
|
if (portablePath) {
|
||||||
|
return portablePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return __dirname;
|
||||||
|
}
|
||||||
|
|
||||||
// Try to enable WebAuthn/platform authenticator features early.
|
// Try to enable WebAuthn/platform authenticator features early.
|
||||||
// This helps Chromium expose platform authenticators (Touch ID / built-in) where supported.
|
// This helps Chromium expose platform authenticators (Touch ID / built-in) where supported.
|
||||||
try {
|
try {
|
||||||
@@ -733,7 +767,7 @@ ipcMain.handle('window-close', event => {
|
|||||||
// Site history is now handled via localStorage in the renderer
|
// Site history is now handled via localStorage in the renderer
|
||||||
// But keep these handlers for compatibility and potential future use
|
// But keep these handlers for compatibility and potential future use
|
||||||
ipcMain.handle('load-site-history', async () => {
|
ipcMain.handle('load-site-history', async () => {
|
||||||
const filePath = path.join(__dirname, 'site-history.json');
|
const filePath = getDataFilePath('site-history.json');
|
||||||
try {
|
try {
|
||||||
const data = await fs.promises.readFile(filePath, 'utf-8');
|
const data = await fs.promises.readFile(filePath, 'utf-8');
|
||||||
return JSON.parse(data);
|
return JSON.parse(data);
|
||||||
@@ -743,9 +777,13 @@ ipcMain.handle('load-site-history', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('save-site-history', async (event, history) => {
|
ipcMain.handle('save-site-history', async (event, history) => {
|
||||||
const filePath = path.join(__dirname, 'site-history.json');
|
const filePath = getDataFilePath('site-history.json');
|
||||||
try {
|
try {
|
||||||
|
if (portableData.isPortableMode()) {
|
||||||
|
await portableData.writeSecureFileAsync(filePath, JSON.stringify(history, null, 2));
|
||||||
|
} else {
|
||||||
await fs.promises.writeFile(filePath, JSON.stringify(history, null, 2));
|
await fs.promises.writeFile(filePath, JSON.stringify(history, null, 2));
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return false;
|
return false;
|
||||||
@@ -753,7 +791,7 @@ ipcMain.handle('save-site-history', async (event, history) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('clear-site-history', async () => {
|
ipcMain.handle('clear-site-history', async () => {
|
||||||
const filePath = path.join(__dirname, 'site-history.json');
|
const filePath = getDataFilePath('site-history.json');
|
||||||
try {
|
try {
|
||||||
await fs.promises.writeFile(filePath, JSON.stringify([], null, 2));
|
await fs.promises.writeFile(filePath, JSON.stringify([], null, 2));
|
||||||
return true;
|
return true;
|
||||||
@@ -763,7 +801,7 @@ ipcMain.handle('clear-site-history', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('load-search-history', async () => {
|
ipcMain.handle('load-search-history', async () => {
|
||||||
const filePath = path.join(__dirname, 'search-history.json');
|
const filePath = getDataFilePath('search-history.json');
|
||||||
try {
|
try {
|
||||||
const data = await fs.promises.readFile(filePath, 'utf-8');
|
const data = await fs.promises.readFile(filePath, 'utf-8');
|
||||||
return JSON.parse(data);
|
return JSON.parse(data);
|
||||||
@@ -773,9 +811,13 @@ ipcMain.handle('load-search-history', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('save-search-history', async (event, history) => {
|
ipcMain.handle('save-search-history', async (event, history) => {
|
||||||
const filePath = path.join(__dirname, 'search-history.json');
|
const filePath = getDataFilePath('search-history.json');
|
||||||
try {
|
try {
|
||||||
|
if (portableData.isPortableMode()) {
|
||||||
|
await portableData.writeSecureFileAsync(filePath, JSON.stringify(history, null, 2));
|
||||||
|
} else {
|
||||||
await fs.promises.writeFile(filePath, JSON.stringify(history, null, 2));
|
await fs.promises.writeFile(filePath, JSON.stringify(history, null, 2));
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return false;
|
return false;
|
||||||
@@ -817,7 +859,7 @@ ipcMain.on('set-display-scale', (event, scale) => {
|
|||||||
// Bookmark management
|
// Bookmark management
|
||||||
ipcMain.handle('load-bookmarks', async () => {
|
ipcMain.handle('load-bookmarks', async () => {
|
||||||
try {
|
try {
|
||||||
const bookmarksPath = path.join(__dirname, 'bookmarks.json');
|
const bookmarksPath = getDataFilePath('bookmarks.json');
|
||||||
try {
|
try {
|
||||||
await fs.promises.access(bookmarksPath);
|
await fs.promises.access(bookmarksPath);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -831,8 +873,8 @@ ipcMain.handle('load-bookmarks', async () => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading bookmarks:', error);
|
console.error('Error loading bookmarks:', error);
|
||||||
// Try to create a backup if the file is corrupted
|
// Try to create a backup if the file is corrupted
|
||||||
const bookmarksPath = path.join(__dirname, 'bookmarks.json');
|
const bookmarksPath = getDataFilePath('bookmarks.json');
|
||||||
const backupPath = path.join(__dirname, `bookmarks.backup.${Date.now()}.json`);
|
const backupPath = getDataFilePath(`bookmarks.backup.${Date.now()}.json`);
|
||||||
try {
|
try {
|
||||||
await fs.promises.copyFile(bookmarksPath, backupPath);
|
await fs.promises.copyFile(bookmarksPath, backupPath);
|
||||||
console.log(`Corrupted bookmarks file backed up to: ${backupPath}`);
|
console.log(`Corrupted bookmarks file backed up to: ${backupPath}`);
|
||||||
@@ -845,13 +887,18 @@ ipcMain.handle('load-bookmarks', async () => {
|
|||||||
|
|
||||||
ipcMain.handle('save-bookmarks', async (event, bookmarks) => {
|
ipcMain.handle('save-bookmarks', async (event, bookmarks) => {
|
||||||
try {
|
try {
|
||||||
const bookmarksPath = path.join(__dirname, 'bookmarks.json');
|
const bookmarksPath = getDataFilePath('bookmarks.json');
|
||||||
try {
|
try {
|
||||||
await fs.promises.access(bookmarksPath);
|
await fs.promises.access(bookmarksPath);
|
||||||
const backupPath = path.join(__dirname, 'bookmarks.backup.json');
|
const backupPath = getDataFilePath('bookmarks.backup.json');
|
||||||
await fs.promises.copyFile(bookmarksPath, backupPath);
|
await fs.promises.copyFile(bookmarksPath, backupPath);
|
||||||
} catch {}
|
} catch {}
|
||||||
|
// Use secure file writing in portable mode
|
||||||
|
if (portableData.isPortableMode()) {
|
||||||
|
await portableData.writeSecureFileAsync(bookmarksPath, JSON.stringify(bookmarks, null, 2));
|
||||||
|
} else {
|
||||||
await fs.promises.writeFile(bookmarksPath, JSON.stringify(bookmarks, null, 2));
|
await fs.promises.writeFile(bookmarksPath, JSON.stringify(bookmarks, null, 2));
|
||||||
|
}
|
||||||
console.log(`Saved ${bookmarks.length} bookmarks to file`);
|
console.log(`Saved ${bookmarks.length} bookmarks to file`);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -886,8 +933,8 @@ ipcMain.handle('clear-browser-data', async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Also reset on-disk history JSON files managed by the app
|
// Also reset on-disk history JSON files managed by the app
|
||||||
const siteHistoryPath = path.join(__dirname, 'site-history.json');
|
const siteHistoryPath = getDataFilePath('site-history.json');
|
||||||
const searchHistoryPath = path.join(__dirname, 'search-history.json');
|
const searchHistoryPath = getDataFilePath('search-history.json');
|
||||||
try { await fs.promises.writeFile(siteHistoryPath, JSON.stringify([], null, 2)); } catch {}
|
try { await fs.promises.writeFile(siteHistoryPath, JSON.stringify([], null, 2)); } catch {}
|
||||||
try { await fs.promises.writeFile(searchHistoryPath, JSON.stringify([], null, 2)); } catch {}
|
try { await fs.promises.writeFile(searchHistoryPath, JSON.stringify([], null, 2)); } catch {}
|
||||||
|
|
||||||
@@ -900,7 +947,7 @@ ipcMain.handle('clear-browser-data', async () => {
|
|||||||
|
|
||||||
// Optional: standalone clear for search history JSON
|
// Optional: standalone clear for search history JSON
|
||||||
ipcMain.handle('clear-search-history', async () => {
|
ipcMain.handle('clear-search-history', async () => {
|
||||||
const filePath = path.join(__dirname, 'search-history.json');
|
const filePath = getDataFilePath('search-history.json');
|
||||||
try {
|
try {
|
||||||
await fs.promises.writeFile(filePath, JSON.stringify([], null, 2));
|
await fs.promises.writeFile(filePath, JSON.stringify([], null, 2));
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
/**
|
||||||
|
* Portable Data Manager for Nebula Browser
|
||||||
|
*
|
||||||
|
* Handles portable user data storage on Linux when running from extracted AppImage
|
||||||
|
* or portable installations. Does not affect Windows or macOS behavior.
|
||||||
|
*
|
||||||
|
* Security considerations:
|
||||||
|
* - Data is stored with restricted permissions (0700 for directories, 0600 for files)
|
||||||
|
* - Path validation prevents directory traversal attacks
|
||||||
|
* - Only enabled when explicitly set via NEBULA_PORTABLE environment variable
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { app } = require('electron');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const os = require('os');
|
||||||
|
|
||||||
|
class PortableDataManager {
|
||||||
|
constructor() {
|
||||||
|
this._isPortable = null;
|
||||||
|
this._portableDataPath = null;
|
||||||
|
this._initialized = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if we're running in portable mode on Linux
|
||||||
|
* Portable mode is determined by:
|
||||||
|
* 1. NEBULA_PORTABLE environment variable is set and truthy
|
||||||
|
* 2. NEBULA_PORTABLE_PATH environment variable provides the data path
|
||||||
|
* 3. Platform must be Linux (does not affect Windows or macOS)
|
||||||
|
*/
|
||||||
|
isPortableMode() {
|
||||||
|
if (this._isPortable !== null) {
|
||||||
|
return this._isPortable;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only portable mode on Linux
|
||||||
|
if (process.platform !== 'linux') {
|
||||||
|
this._isPortable = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if NEBULA_PORTABLE is set and truthy
|
||||||
|
const portableEnv = process.env.NEBULA_PORTABLE;
|
||||||
|
const isTruthy = portableEnv &&
|
||||||
|
(portableEnv === '1' ||
|
||||||
|
portableEnv.toLowerCase() === 'true' ||
|
||||||
|
portableEnv.toLowerCase() === 'yes');
|
||||||
|
|
||||||
|
this._isPortable = isTruthy;
|
||||||
|
return this._isPortable;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the portable data directory path
|
||||||
|
* Uses NEBULA_PORTABLE_PATH if set, otherwise returns null
|
||||||
|
*/
|
||||||
|
getPortableDataPath() {
|
||||||
|
if (this._portableDataPath !== null) {
|
||||||
|
return this._portableDataPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.isPortableMode()) {
|
||||||
|
this._portableDataPath = '';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const portablePath = process.env.NEBULA_PORTABLE_PATH;
|
||||||
|
if (!portablePath) {
|
||||||
|
console.warn('[Portable] NEBULA_PORTABLE is set but NEBULA_PORTABLE_PATH is missing');
|
||||||
|
this._portableDataPath = '';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate and resolve the path
|
||||||
|
const resolvedPath = path.resolve(portablePath);
|
||||||
|
|
||||||
|
// Security: ensure path doesn't contain suspicious patterns
|
||||||
|
if (this._isPathSafe(resolvedPath)) {
|
||||||
|
this._portableDataPath = resolvedPath;
|
||||||
|
} else {
|
||||||
|
console.error('[Portable] Unsafe path detected, falling back to default');
|
||||||
|
this._portableDataPath = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return this._portableDataPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize portable data directory with secure permissions
|
||||||
|
* Must be called before app.ready event
|
||||||
|
*/
|
||||||
|
initialize() {
|
||||||
|
if (this._initialized) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.isPortableMode()) {
|
||||||
|
console.log('[Portable] Not in portable mode, using default paths');
|
||||||
|
this._initialized = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataPath = this.getPortableDataPath();
|
||||||
|
if (!dataPath) {
|
||||||
|
console.warn('[Portable] No valid portable path, using default paths');
|
||||||
|
this._initialized = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create the data directory with secure permissions (owner only: rwx)
|
||||||
|
this._ensureSecureDirectory(dataPath);
|
||||||
|
|
||||||
|
// Create subdirectories for organized storage
|
||||||
|
const subdirs = ['Cache', 'Cookies', 'Local Storage', 'Session Storage', 'IndexedDB'];
|
||||||
|
for (const subdir of subdirs) {
|
||||||
|
this._ensureSecureDirectory(path.join(dataPath, subdir));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set Electron's user data path to our portable location
|
||||||
|
// This must be done BEFORE app.ready event
|
||||||
|
app.setPath('userData', dataPath);
|
||||||
|
app.setPath('sessionData', dataPath);
|
||||||
|
|
||||||
|
// Also redirect cache to be portable
|
||||||
|
const cachePath = path.join(dataPath, 'Cache');
|
||||||
|
app.setPath('cache', cachePath);
|
||||||
|
|
||||||
|
console.log(`[Portable] User data path set to: ${dataPath}`);
|
||||||
|
console.log(`[Portable] Cache path set to: ${cachePath}`);
|
||||||
|
|
||||||
|
this._initialized = true;
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Portable] Failed to initialize portable data:', err);
|
||||||
|
this._initialized = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the path for a data file (bookmarks, history, etc.)
|
||||||
|
* Returns portable path if in portable mode, otherwise returns __dirname path
|
||||||
|
*/
|
||||||
|
getDataFilePath(filename) {
|
||||||
|
// Validate filename to prevent directory traversal
|
||||||
|
if (!this._isFilenameSafe(filename)) {
|
||||||
|
throw new Error(`Invalid filename: ${filename}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isPortableMode()) {
|
||||||
|
const portablePath = this.getPortableDataPath();
|
||||||
|
if (portablePath) {
|
||||||
|
return path.join(portablePath, filename);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to __dirname (project directory) for non-portable or if portable not configured
|
||||||
|
// Note: In production, you might want to use app.getPath('userData') as fallback
|
||||||
|
return null; // Return null to indicate caller should use their default path
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure a directory exists with secure permissions
|
||||||
|
*/
|
||||||
|
_ensureSecureDirectory(dirPath) {
|
||||||
|
if (!fs.existsSync(dirPath)) {
|
||||||
|
// Create with restricted permissions (owner only: rwx------)
|
||||||
|
fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 });
|
||||||
|
console.log(`[Portable] Created secure directory: ${dirPath}`);
|
||||||
|
} else {
|
||||||
|
// Verify and fix permissions on existing directory
|
||||||
|
try {
|
||||||
|
const stats = fs.statSync(dirPath);
|
||||||
|
if (stats.isDirectory()) {
|
||||||
|
// Set secure permissions
|
||||||
|
fs.chmodSync(dirPath, 0o700);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[Portable] Could not verify permissions for ${dirPath}:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write a file with secure permissions
|
||||||
|
*/
|
||||||
|
writeSecureFile(filePath, data) {
|
||||||
|
// Ensure parent directory exists with secure permissions
|
||||||
|
const dir = path.dirname(filePath);
|
||||||
|
this._ensureSecureDirectory(dir);
|
||||||
|
|
||||||
|
// Write file
|
||||||
|
fs.writeFileSync(filePath, data, { mode: 0o600 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Async version of secure file write
|
||||||
|
*/
|
||||||
|
async writeSecureFileAsync(filePath, data) {
|
||||||
|
// Ensure parent directory exists with secure permissions
|
||||||
|
const dir = path.dirname(filePath);
|
||||||
|
this._ensureSecureDirectory(dir);
|
||||||
|
|
||||||
|
// Write file with restricted permissions (owner only: rw-------)
|
||||||
|
await fs.promises.writeFile(filePath, data, { mode: 0o600 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate path safety (prevent directory traversal)
|
||||||
|
*/
|
||||||
|
_isPathSafe(testPath) {
|
||||||
|
// Resolve to absolute path
|
||||||
|
const resolved = path.resolve(testPath);
|
||||||
|
|
||||||
|
// Check for suspicious patterns
|
||||||
|
const dangerous = ['..', '~root', '/etc', '/var/run', '/proc', '/sys', '/dev'];
|
||||||
|
for (const pattern of dangerous) {
|
||||||
|
if (resolved.includes(pattern)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure it's not trying to write to system directories
|
||||||
|
const systemPaths = ['/bin', '/sbin', '/usr/bin', '/usr/sbin', '/boot', '/lib', '/lib64'];
|
||||||
|
for (const sysPath of systemPaths) {
|
||||||
|
if (resolved.startsWith(sysPath)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate filename safety
|
||||||
|
*/
|
||||||
|
_isFilenameSafe(filename) {
|
||||||
|
// Check for directory traversal
|
||||||
|
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for hidden files that might be system files
|
||||||
|
if (filename.startsWith('.') && !filename.endsWith('.json')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get status information for debugging
|
||||||
|
*/
|
||||||
|
getStatus() {
|
||||||
|
return {
|
||||||
|
isPortable: this.isPortableMode(),
|
||||||
|
portablePath: this.getPortableDataPath(),
|
||||||
|
initialized: this._initialized,
|
||||||
|
platform: process.platform,
|
||||||
|
envPortable: process.env.NEBULA_PORTABLE,
|
||||||
|
envPath: process.env.NEBULA_PORTABLE_PATH
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton instance
|
||||||
|
const portableDataManager = new PortableDataManager();
|
||||||
|
module.exports = portableDataManager;
|
||||||
Reference in New Issue
Block a user