Initial commit: DashCaddy v1.0

Full codebase including API server (32 modules + routes), dashboard frontend,
DashCA certificate distribution, installer script, and deployment skills.
This commit is contained in:
2026-03-05 02:26:12 -08:00
commit f61e85d9a7
337 changed files with 75282 additions and 0 deletions

View File

@@ -0,0 +1,237 @@
/**
* State Manager - Thread-safe file operations with locking
*
* Prevents data corruption when multiple API requests modify state files concurrently.
* Uses file-based locking with automatic retry and timeout handling.
*
* @module state-manager
*/
const lockfile = require('proper-lockfile');
const fs = require('fs').promises;
const fsSync = require('fs');
const path = require('path');
class StateManager {
/**
* Create a StateManager instance
* @param {string} filePath - Path to the state file (e.g., services.json)
* @param {Object} options - Configuration options
* @param {number} options.lockTimeout - Max time to wait for lock (ms)
* @param {number} options.lockRetries - Number of lock acquisition retries
* @param {number} options.lockRetryInterval - Time between retries (ms)
*/
constructor(filePath, options = {}) {
this.filePath = filePath;
this.lockOptions = {
retries: {
retries: options.lockRetries || 10,
minTimeout: options.lockRetryInterval || 100,
maxTimeout: (options.lockRetryInterval || 100) * 3
},
stale: options.lockTimeout || 30000 // 30 seconds
};
// Ensure file exists
this._ensureFileExists();
}
/**
* Ensure the state file exists, create with empty array if not
* @private
*/
_ensureFileExists() {
if (!fsSync.existsSync(this.filePath)) {
const dir = path.dirname(this.filePath);
if (!fsSync.existsSync(dir)) {
fsSync.mkdirSync(dir, { recursive: true });
}
fsSync.writeFileSync(this.filePath, '[]', 'utf8');
}
}
/**
* Read the state file (no locking required for read-only operations)
* @returns {Promise<any>} Parsed JSON data
* @throws {Error} If file doesn't exist or JSON is invalid
*/
async read() {
try {
const content = await fs.readFile(this.filePath, 'utf8');
return JSON.parse(content);
} catch (error) {
if (error.code === 'ENOENT') {
// File doesn't exist — recreate without locking (no file to lock)
this._ensureFileExists();
return [];
}
throw new Error(`Failed to read state file: ${error.message}`);
}
}
/**
* Write data to the state file (with locking)
* @param {any} data - Data to write (will be JSON.stringify'd)
* @returns {Promise<void>}
* @throws {Error} If lock cannot be acquired or write fails
*/
async write(data) {
let release;
try {
// Acquire lock
release = await lockfile.lock(this.filePath, this.lockOptions);
// Write data with pretty formatting
await fs.writeFile(this.filePath, JSON.stringify(data, null, 2), 'utf8');
} catch (error) {
if (error.code === 'ELOCKED') {
throw new Error('State file is locked by another process. Try again.');
}
throw new Error(`Failed to write state file: ${error.message}`);
} finally {
// Always release lock
if (release) {
try {
await release();
} catch (e) {
// Lock release failure (non-critical, lock will expire via stale timeout)
}
}
}
}
/**
* Update the state file using a callback function (atomic operation)
* This is the recommended method for most operations.
*
* @param {Function} updateFn - Function that receives current data and returns updated data
* @returns {Promise<any>} The updated data
* @throws {Error} If lock cannot be acquired or update fails
*
* @example
* // Add a new service
* await stateManager.update(services => {
* services.push({ id: 'new-service', name: 'New Service' });
* return services;
* });
*
* @example
* // Remove a service
* await stateManager.update(services => {
* return services.filter(s => s.id !== 'old-service');
* });
*/
async update(updateFn) {
let release;
try {
// Acquire lock
release = await lockfile.lock(this.filePath, this.lockOptions);
// Read current data
const content = await fs.readFile(this.filePath, 'utf8');
const currentData = JSON.parse(content);
// Apply update function
const updatedData = await updateFn(currentData);
// Write updated data
await fs.writeFile(this.filePath, JSON.stringify(updatedData, null, 2), 'utf8');
return updatedData;
} catch (error) {
if (error.code === 'ELOCKED') {
throw new Error('State file is locked by another process. Try again.');
}
throw new Error(`Failed to update state file: ${error.message}`);
} finally {
// Always release lock
if (release) {
try {
await release();
} catch (e) {
// Lock release failure (non-critical, lock will expire via stale timeout)
}
}
}
}
/**
* Check if the state file is currently locked
* @returns {Promise<boolean>} True if locked, false otherwise
*/
async isLocked() {
try {
return await lockfile.check(this.filePath);
} catch (error) {
return false;
}
}
/**
* Forcefully unlock the state file (use with caution!)
* Only use this if a lock is stuck due to a crashed process.
* @returns {Promise<void>}
*/
async forceUnlock() {
try {
await lockfile.unlock(this.filePath);
} catch (error) {
// Ignore errors if file wasn't locked
if (error.code !== 'ENOTACQUIRED') {
throw error;
}
}
}
/**
* Add an item to the state array (convenience method)
* @param {any} item - Item to add
* @returns {Promise<any>} Updated array
*/
async addItem(item) {
return await this.update(items => {
items.push(item);
return items;
});
}
/**
* Remove an item from the state array by ID (convenience method)
* @param {string} id - ID of item to remove
* @returns {Promise<any>} Updated array
*/
async removeItem(id) {
return await this.update(items => {
return items.filter(item => item.id !== id);
});
}
/**
* Update an item in the state array by ID (convenience method)
* @param {string} id - ID of item to update
* @param {Object} updates - Properties to update
* @returns {Promise<any>} Updated array
*/
async updateItem(id, updates) {
return await this.update(items => {
return items.map(item => {
if (item.id === id) {
return { ...item, ...updates };
}
return item;
});
});
}
/**
* Find an item in the state array by ID (convenience method)
* @param {string} id - ID of item to find
* @returns {Promise<any|null>} Found item or null
*/
async findItem(id) {
const items = await this.read();
return items.find(item => item.id === id) || null;
}
}
module.exports = StateManager;