Save user data to internal folder (Linux)

This commit is contained in:
2025-12-30 18:31:55 +13:00
parent 4bd2ef0e2d
commit b9f7779fc5
2 changed files with 333 additions and 16 deletions
+270
View File
@@ -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;