48ae196c4c
Updated getPortableDataPath to prefer storing 'user-data' in Documents/My Games/<AppName>, with a fallback to the app directory if necessary. This enhances data portability and aligns with common user data storage conventions.
378 lines
12 KiB
JavaScript
378 lines
12 KiB
JavaScript
/**
|
|
* Portable Data Manager for Nebula Browser
|
|
*
|
|
* Handles portable user data storage for all platforms (Windows, macOS, Linux).
|
|
* Data is stored in a 'user-data' folder within the application directory,
|
|
* keeping all user data local to the compiled project.
|
|
*
|
|
* Security considerations:
|
|
* - Data is stored with restricted permissions (0700 for directories, 0600 for files on Unix)
|
|
* - Path validation prevents directory traversal attacks
|
|
* - Portable mode is enabled by default on all platforms
|
|
* - Can be disabled via NEBULA_PORTABLE=0 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;
|
|
}
|
|
|
|
/**
|
|
* Get the application's root directory
|
|
* Works for both development and packaged builds
|
|
*/
|
|
_getAppRootDir() {
|
|
// In packaged app, process.resourcesPath points to resources folder
|
|
// We want the parent directory (the app folder itself)
|
|
if (app.isPackaged) {
|
|
// For packaged apps:
|
|
// - Windows: path\to\app\resources -> path\to\app
|
|
// - macOS: path/to/App.app/Contents/Resources -> path/to/App.app/Contents
|
|
// - Linux: path/to/app/resources -> path/to/app
|
|
const resourcesPath = process.resourcesPath;
|
|
|
|
if (process.platform === 'darwin') {
|
|
// On macOS, go up two levels from Resources to get to App.app parent folder
|
|
// But we want to store data inside the .app bundle's Contents folder for portability
|
|
return path.dirname(resourcesPath); // Contents folder
|
|
} else {
|
|
// Windows and Linux: go up one level from resources
|
|
return path.dirname(resourcesPath);
|
|
}
|
|
} else {
|
|
// Development mode: use __dirname (the directory containing main.js)
|
|
return __dirname;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if we're running in portable mode
|
|
* Portable mode is enabled by default on all platforms.
|
|
*
|
|
* Can be disabled by setting NEBULA_PORTABLE=0 or NEBULA_PORTABLE=false
|
|
* Can specify custom path via NEBULA_PORTABLE_PATH environment variable
|
|
*/
|
|
isPortableMode() {
|
|
if (this._isPortable !== null) {
|
|
return this._isPortable;
|
|
}
|
|
|
|
// Check if NEBULA_PORTABLE is explicitly set to disable
|
|
const portableEnv = process.env.NEBULA_PORTABLE;
|
|
if (portableEnv !== undefined) {
|
|
const isDisabled = portableEnv === '0' ||
|
|
portableEnv.toLowerCase() === 'false' ||
|
|
portableEnv.toLowerCase() === 'no';
|
|
|
|
if (isDisabled) {
|
|
this._isPortable = false;
|
|
console.log('[Portable] Portable mode disabled via NEBULA_PORTABLE environment variable');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Portable mode is enabled by default on all platforms
|
|
this._isPortable = true;
|
|
return this._isPortable;
|
|
}
|
|
|
|
/**
|
|
* Get the portable data directory path
|
|
* Uses NEBULA_PORTABLE_PATH if set, otherwise creates 'user-data' in Documents/My Games/<AppName>
|
|
* with a safe fallback to the app directory.
|
|
*/
|
|
getPortableDataPath() {
|
|
if (this._portableDataPath !== null) {
|
|
return this._portableDataPath;
|
|
}
|
|
|
|
if (!this.isPortableMode()) {
|
|
this._portableDataPath = '';
|
|
return '';
|
|
}
|
|
|
|
// First, check if custom path is provided via environment variable
|
|
const customPath = process.env.NEBULA_PORTABLE_PATH;
|
|
if (customPath) {
|
|
const resolvedPath = path.resolve(customPath);
|
|
if (this._isPathSafe(resolvedPath)) {
|
|
this._portableDataPath = resolvedPath;
|
|
console.log(`[Portable] Using custom portable path: ${resolvedPath}`);
|
|
return this._portableDataPath;
|
|
} else {
|
|
console.warn('[Portable] Custom path is unsafe, using default location');
|
|
}
|
|
}
|
|
|
|
// Default: prefer Documents/My Games/<AppName>/user-data
|
|
let dataPath = '';
|
|
try {
|
|
const docsDir = app.getPath('documents');
|
|
const appName = app.getName() || 'NebulaBrowser';
|
|
dataPath = path.join(docsDir, 'My Games', appName, 'user-data');
|
|
} catch (err) {
|
|
console.warn('[Portable] Failed to resolve Documents path, using app directory');
|
|
}
|
|
|
|
if (!dataPath) {
|
|
const appRoot = this._getAppRootDir();
|
|
dataPath = path.join(appRoot, 'user-data');
|
|
}
|
|
|
|
// Validate the path
|
|
if (this._isPathSafe(dataPath)) {
|
|
this._portableDataPath = dataPath;
|
|
console.log(`[Portable] Using portable data path: ${dataPath}`);
|
|
} else {
|
|
console.error('[Portable] Default path is unsafe, falling back to system 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
|
|
// Note: Don't create 'Cache', 'Cookies', 'Network' - Electron manages these internally
|
|
const subdirs = ['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
|
|
* On Unix systems (macOS, Linux), applies restricted permissions (0700)
|
|
* On Windows, creates directory with default permissions (ACLs handle security)
|
|
*/
|
|
_ensureSecureDirectory(dirPath) {
|
|
if (!fs.existsSync(dirPath)) {
|
|
if (process.platform === 'win32') {
|
|
// Windows: create directory with default permissions
|
|
// Windows ACLs handle security through inheritance
|
|
fs.mkdirSync(dirPath, { recursive: true });
|
|
} else {
|
|
// Unix (macOS, Linux): 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 (Unix only)
|
|
if (process.platform !== 'win32') {
|
|
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
|
|
* On Unix systems, applies restricted permissions (0600)
|
|
* On Windows, writes with default permissions
|
|
*/
|
|
writeSecureFile(filePath, data) {
|
|
// Ensure parent directory exists with secure permissions
|
|
const dir = path.dirname(filePath);
|
|
this._ensureSecureDirectory(dir);
|
|
|
|
// Write file
|
|
if (process.platform === 'win32') {
|
|
fs.writeFileSync(filePath, data);
|
|
} else {
|
|
fs.writeFileSync(filePath, data, { mode: 0o600 });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Async version of secure file write
|
|
* On Unix systems, applies restricted permissions (0600)
|
|
* On Windows, writes with default permissions
|
|
*/
|
|
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------- on Unix)
|
|
if (process.platform === 'win32') {
|
|
await fs.promises.writeFile(filePath, data);
|
|
} else {
|
|
await fs.promises.writeFile(filePath, data, { mode: 0o600 });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate path safety (prevent directory traversal)
|
|
* Works across Windows, macOS, and Linux
|
|
*/
|
|
_isPathSafe(testPath) {
|
|
// Resolve to absolute path
|
|
const resolved = path.resolve(testPath);
|
|
|
|
// Check for directory traversal patterns
|
|
if (resolved.includes('..')) {
|
|
return false;
|
|
}
|
|
|
|
// Platform-specific system path checks
|
|
if (process.platform === 'win32') {
|
|
// Windows: block system directories
|
|
const dangerousWin = [
|
|
'C:\\Windows',
|
|
'C:\\Program Files',
|
|
'C:\\Program Files (x86)',
|
|
'C:\\ProgramData'
|
|
];
|
|
const resolvedLower = resolved.toLowerCase();
|
|
for (const pattern of dangerousWin) {
|
|
if (resolvedLower.startsWith(pattern.toLowerCase())) {
|
|
return false;
|
|
}
|
|
}
|
|
} else if (process.platform === 'darwin') {
|
|
// macOS: block system directories
|
|
const dangerousMac = ['/System', '/Library', '/usr', '/bin', '/sbin', '/etc', '/var'];
|
|
for (const pattern of dangerousMac) {
|
|
if (resolved.startsWith(pattern) && !resolved.includes('.app')) {
|
|
return false;
|
|
}
|
|
}
|
|
} else {
|
|
// Linux: block system directories
|
|
const dangerousLinux = ['~root', '/etc', '/var/run', '/proc', '/sys', '/dev'];
|
|
for (const pattern of dangerousLinux) {
|
|
if (resolved.includes(pattern)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
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(),
|
|
appRootDir: this._getAppRootDir(),
|
|
initialized: this._initialized,
|
|
platform: process.platform,
|
|
isPackaged: app.isPackaged,
|
|
envPortable: process.env.NEBULA_PORTABLE,
|
|
envPath: process.env.NEBULA_PORTABLE_PATH
|
|
};
|
|
}
|
|
}
|
|
|
|
// Export singleton instance
|
|
const portableDataManager = new PortableDataManager();
|
|
module.exports = portableDataManager;
|