@@ -779,3 +779,190 @@ apiRoutes.get('/secrets/:profile/exists', (req: Request, res: Response) => {
779779 keys : Object . keys ( secrets ) , // Only key names, not values
780780 } ) ;
781781} ) ;
782+
783+ // ==================== Generic File API (Issue #73) ====================
784+
785+ /**
786+ * Security: Validate file path is within allowed directories
787+ * - ~/.ccs/ directory: read/write allowed
788+ * - ~/.claude/settings.json: read-only
789+ */
790+ function validateFilePath ( filePath : string ) : { valid : boolean ; readonly : boolean ; error ?: string } {
791+ const expandedPath = expandPath ( filePath ) ;
792+ const normalizedPath = path . normalize ( expandedPath ) ;
793+ const ccsDir = getCcsDir ( ) ;
794+ const claudeSettingsPath = expandPath ( '~/.claude/settings.json' ) ;
795+
796+ // Check if path is within ~/.ccs/
797+ if ( normalizedPath . startsWith ( ccsDir ) ) {
798+ // Block access to sensitive subdirectories
799+ const relativePath = normalizedPath . slice ( ccsDir . length ) ;
800+ if ( relativePath . includes ( '/.git/' ) || relativePath . includes ( '/node_modules/' ) ) {
801+ return { valid : false , readonly : false , error : 'Access to this path is not allowed' } ;
802+ }
803+ return { valid : true , readonly : false } ;
804+ }
805+
806+ // Allow read-only access to ~/.claude/settings.json
807+ if ( normalizedPath === claudeSettingsPath ) {
808+ return { valid : true , readonly : true } ;
809+ }
810+
811+ return { valid : false , readonly : false , error : 'Access to this path is not allowed' } ;
812+ }
813+
814+ /**
815+ * GET /api/file - Read a file with path validation
816+ * Query params: path (required)
817+ * Returns: { content: string, mtime: number, readonly: boolean, path: string }
818+ */
819+ apiRoutes . get ( '/file' , ( req : Request , res : Response ) : void => {
820+ const filePath = req . query . path as string ;
821+
822+ if ( ! filePath ) {
823+ res . status ( 400 ) . json ( { error : 'Missing required query parameter: path' } ) ;
824+ return ;
825+ }
826+
827+ const validation = validateFilePath ( filePath ) ;
828+ if ( ! validation . valid ) {
829+ res . status ( 403 ) . json ( { error : validation . error } ) ;
830+ return ;
831+ }
832+
833+ const expandedPath = expandPath ( filePath ) ;
834+
835+ if ( ! fs . existsSync ( expandedPath ) ) {
836+ res . status ( 404 ) . json ( { error : 'File not found' } ) ;
837+ return ;
838+ }
839+
840+ try {
841+ const stat = fs . statSync ( expandedPath ) ;
842+ const content = fs . readFileSync ( expandedPath , 'utf8' ) ;
843+
844+ res . json ( {
845+ content,
846+ mtime : stat . mtime . getTime ( ) ,
847+ readonly : validation . readonly ,
848+ path : expandedPath ,
849+ } ) ;
850+ } catch ( error ) {
851+ res . status ( 500 ) . json ( { error : ( error as Error ) . message } ) ;
852+ }
853+ } ) ;
854+
855+ /**
856+ * PUT /api/file - Write a file with conflict detection and backup
857+ * Query params: path (required)
858+ * Body: { content: string, expectedMtime?: number }
859+ * Returns: { success: true, mtime: number, backupPath?: string }
860+ */
861+ apiRoutes . put ( '/file' , ( req : Request , res : Response ) : void => {
862+ const filePath = req . query . path as string ;
863+ const { content, expectedMtime } = req . body ;
864+
865+ if ( ! filePath ) {
866+ res . status ( 400 ) . json ( { error : 'Missing required query parameter: path' } ) ;
867+ return ;
868+ }
869+
870+ if ( typeof content !== 'string' ) {
871+ res . status ( 400 ) . json ( { error : 'Missing required field: content' } ) ;
872+ return ;
873+ }
874+
875+ const validation = validateFilePath ( filePath ) ;
876+ if ( ! validation . valid ) {
877+ res . status ( 403 ) . json ( { error : validation . error } ) ;
878+ return ;
879+ }
880+
881+ if ( validation . readonly ) {
882+ res . status ( 403 ) . json ( { error : 'File is read-only' } ) ;
883+ return ;
884+ }
885+
886+ const expandedPath = expandPath ( filePath ) ;
887+ const ccsDir = getCcsDir ( ) ;
888+
889+ // Conflict detection (if file exists and expectedMtime provided)
890+ if ( fs . existsSync ( expandedPath ) && expectedMtime !== undefined ) {
891+ const stat = fs . statSync ( expandedPath ) ;
892+ if ( stat . mtime . getTime ( ) !== expectedMtime ) {
893+ res . status ( 409 ) . json ( {
894+ error : 'File modified externally' ,
895+ currentMtime : stat . mtime . getTime ( ) ,
896+ } ) ;
897+ return ;
898+ }
899+ }
900+
901+ try {
902+ // Create backup if file exists
903+ let backupPath : string | undefined ;
904+ if ( fs . existsSync ( expandedPath ) ) {
905+ const backupDir = path . join ( ccsDir , 'backups' ) ;
906+ if ( ! fs . existsSync ( backupDir ) ) {
907+ fs . mkdirSync ( backupDir , { recursive : true } ) ;
908+ }
909+ const filename = path . basename ( expandedPath ) ;
910+ const timestamp = new Date ( ) . toISOString ( ) . replace ( / [: .] / g, '-' ) ;
911+ backupPath = path . join ( backupDir , `${ filename } .${ timestamp } .bak` ) ;
912+ fs . copyFileSync ( expandedPath , backupPath ) ;
913+ }
914+
915+ // Ensure parent directory exists
916+ const parentDir = path . dirname ( expandedPath ) ;
917+ if ( ! fs . existsSync ( parentDir ) ) {
918+ fs . mkdirSync ( parentDir , { recursive : true } ) ;
919+ }
920+
921+ // Write atomically
922+ const tempPath = expandedPath + '.tmp' ;
923+ fs . writeFileSync ( tempPath , content ) ;
924+ fs . renameSync ( tempPath , expandedPath ) ;
925+
926+ const newStat = fs . statSync ( expandedPath ) ;
927+ res . json ( {
928+ success : true ,
929+ mtime : newStat . mtime . getTime ( ) ,
930+ backupPath,
931+ } ) ;
932+ } catch ( error ) {
933+ res . status ( 500 ) . json ( { error : ( error as Error ) . message } ) ;
934+ }
935+ } ) ;
936+
937+ /**
938+ * GET /api/files - List editable files in ~/.ccs/
939+ * Returns: { files: Array<{ name: string, path: string, mtime: number }> }
940+ */
941+ apiRoutes . get ( '/files' , ( _req : Request , res : Response ) : void => {
942+ const ccsDir = getCcsDir ( ) ;
943+
944+ if ( ! fs . existsSync ( ccsDir ) ) {
945+ res . json ( { files : [ ] } ) ;
946+ return ;
947+ }
948+
949+ try {
950+ const entries = fs . readdirSync ( ccsDir , { withFileTypes : true } ) ;
951+ const files = entries
952+ . filter ( ( entry ) => entry . isFile ( ) && entry . name . endsWith ( '.json' ) )
953+ . map ( ( entry ) => {
954+ const filePath = path . join ( ccsDir , entry . name ) ;
955+ const stat = fs . statSync ( filePath ) ;
956+ return {
957+ name : entry . name ,
958+ path : `~/.ccs/${ entry . name } ` ,
959+ mtime : stat . mtime . getTime ( ) ,
960+ } ;
961+ } )
962+ . sort ( ( a , b ) => a . name . localeCompare ( b . name ) ) ;
963+
964+ res . json ( { files } ) ;
965+ } catch ( error ) {
966+ res . status ( 500 ) . json ( { error : ( error as Error ) . message } ) ;
967+ }
968+ } ) ;
0 commit comments