mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-20 05:11:48 +00:00
feat(dashboard): add code editor for raw JSON settings editing
- Add GET/PUT /api/file endpoints for generic file access with security validation - Add GET /api/files endpoint to list editable JSON files in ~/.ccs/ - Create CodeEditor component with JSON syntax highlighting (prism-react-renderer) - Add "Raw JSON" tab to SettingsDialog for direct JSON editing - Support conflict detection, atomic writes, and automatic backups - Lazy load editor to minimize initial bundle impact (~31KB gzipped) Closes #73
This commit is contained in:
1 parent
f83051be40
commit
2b1a3b4879
5 files changed
+452
-12
No files matched your search
@@ -779,3 +779,190 @@ apiRoutes.get('/secrets/:profile/exists', (req: Request, res: Response) => {
|
||||
keys: Object.keys(secrets), // Only key names, not values
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Generic File API (Issue #73) ====================
|
||||
|
||||
/**
|
||||
* Security: Validate file path is within allowed directories
|
||||
* - ~/.ccs/ directory: read/write allowed
|
||||
* - ~/.claude/settings.json: read-only
|
||||
*/
|
||||
function validateFilePath(filePath: string): { valid: boolean; readonly: boolean; error?: string } {
|
||||
const expandedPath = expandPath(filePath);
|
||||
const normalizedPath = path.normalize(expandedPath);
|
||||
const ccsDir = getCcsDir();
|
||||
const claudeSettingsPath = expandPath('~/.claude/settings.json');
|
||||
|
||||
// Check if path is within ~/.ccs/
|
||||
if (normalizedPath.startsWith(ccsDir)) {
|
||||
// Block access to sensitive subdirectories
|
||||
const relativePath = normalizedPath.slice(ccsDir.length);
|
||||
if (relativePath.includes('/.git/') || relativePath.includes('/node_modules/')) {
|
||||
return { valid: false, readonly: false, error: 'Access to this path is not allowed' };
|
||||
}
|
||||
return { valid: true, readonly: false };
|
||||
}
|
||||
|
||||
// Allow read-only access to ~/.claude/settings.json
|
||||
if (normalizedPath === claudeSettingsPath) {
|
||||
return { valid: true, readonly: true };
|
||||
}
|
||||
|
||||
return { valid: false, readonly: false, error: 'Access to this path is not allowed' };
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/file - Read a file with path validation
|
||||
* Query params: path (required)
|
||||
* Returns: { content: string, mtime: number, readonly: boolean, path: string }
|
||||
*/
|
||||
apiRoutes.get('/file', (req: Request, res: Response): void => {
|
||||
const filePath = req.query.path as string;
|
||||
|
||||
if (!filePath) {
|
||||
res.status(400).json({ error: 'Missing required query parameter: path' });
|
||||
return;
|
||||
}
|
||||
|
||||
const validation = validateFilePath(filePath);
|
||||
if (!validation.valid) {
|
||||
res.status(403).json({ error: validation.error });
|
||||
return;
|
||||
}
|
||||
|
||||
const expandedPath = expandPath(filePath);
|
||||
|
||||
if (!fs.existsSync(expandedPath)) {
|
||||
res.status(404).json({ error: 'File not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = fs.statSync(expandedPath);
|
||||
const content = fs.readFileSync(expandedPath, 'utf8');
|
||||
|
||||
res.json({
|
||||
content,
|
||||
mtime: stat.mtime.getTime(),
|
||||
readonly: validation.readonly,
|
||||
path: expandedPath,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/file - Write a file with conflict detection and backup
|
||||
* Query params: path (required)
|
||||
* Body: { content: string, expectedMtime?: number }
|
||||
* Returns: { success: true, mtime: number, backupPath?: string }
|
||||
*/
|
||||
apiRoutes.put('/file', (req: Request, res: Response): void => {
|
||||
const filePath = req.query.path as string;
|
||||
const { content, expectedMtime } = req.body;
|
||||
|
||||
if (!filePath) {
|
||||
res.status(400).json({ error: 'Missing required query parameter: path' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof content !== 'string') {
|
||||
res.status(400).json({ error: 'Missing required field: content' });
|
||||
return;
|
||||
}
|
||||
|
||||
const validation = validateFilePath(filePath);
|
||||
if (!validation.valid) {
|
||||
res.status(403).json({ error: validation.error });
|
||||
return;
|
||||
}
|
||||
|
||||
if (validation.readonly) {
|
||||
res.status(403).json({ error: 'File is read-only' });
|
||||
return;
|
||||
}
|
||||
|
||||
const expandedPath = expandPath(filePath);
|
||||
const ccsDir = getCcsDir();
|
||||
|
||||
// Conflict detection (if file exists and expectedMtime provided)
|
||||
if (fs.existsSync(expandedPath) && expectedMtime !== undefined) {
|
||||
const stat = fs.statSync(expandedPath);
|
||||
if (stat.mtime.getTime() !== expectedMtime) {
|
||||
res.status(409).json({
|
||||
error: 'File modified externally',
|
||||
currentMtime: stat.mtime.getTime(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Create backup if file exists
|
||||
let backupPath: string | undefined;
|
||||
if (fs.existsSync(expandedPath)) {
|
||||
const backupDir = path.join(ccsDir, 'backups');
|
||||
if (!fs.existsSync(backupDir)) {
|
||||
fs.mkdirSync(backupDir, { recursive: true });
|
||||
}
|
||||
const filename = path.basename(expandedPath);
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
backupPath = path.join(backupDir, `${filename}.${timestamp}.bak`);
|
||||
fs.copyFileSync(expandedPath, backupPath);
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
const parentDir = path.dirname(expandedPath);
|
||||
if (!fs.existsSync(parentDir)) {
|
||||
fs.mkdirSync(parentDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Write atomically
|
||||
const tempPath = expandedPath + '.tmp';
|
||||
fs.writeFileSync(tempPath, content);
|
||||
fs.renameSync(tempPath, expandedPath);
|
||||
|
||||
const newStat = fs.statSync(expandedPath);
|
||||
res.json({
|
||||
success: true,
|
||||
mtime: newStat.mtime.getTime(),
|
||||
backupPath,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/files - List editable files in ~/.ccs/
|
||||
* Returns: { files: Array<{ name: string, path: string, mtime: number }> }
|
||||
*/
|
||||
apiRoutes.get('/files', (_req: Request, res: Response): void => {
|
||||
const ccsDir = getCcsDir();
|
||||
|
||||
if (!fs.existsSync(ccsDir)) {
|
||||
res.json({ files: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = fs.readdirSync(ccsDir, { withFileTypes: true });
|
||||
const files = entries
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
|
||||
.map((entry) => {
|
||||
const filePath = path.join(ccsDir, entry.name);
|
||||
const stat = fs.statSync(filePath);
|
||||
return {
|
||||
name: entry.name,
|
||||
path: `~/.ccs/${entry.name}`,
|
||||
mtime: stat.mtime.getTime(),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
res.json({ files });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user