"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    var desc = Object.getOwnPropertyDescriptor(m, k);
    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
      desc = { enumerable: true, get: function() { return m[k]; } };
    }
    Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
    Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
    o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
    var ownKeys = function(o) {
        ownKeys = Object.getOwnPropertyNames || function (o) {
            var ar = [];
            for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
            return ar;
        };
        return ownKeys(o);
    };
    return function (mod) {
        if (mod && mod.__esModule) return mod;
        var result = {};
        if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
        __setModuleDefault(result, mod);
        return result;
    };
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const express_1 = require("express");
const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
const os = __importStar(require("os"));
const promises_1 = require("stream/promises");
const multer_1 = __importDefault(require("multer"));
const config_1 = require("../config");
const logger_1 = require("../logger");
const log = (0, logger_1.modLogger)('filesystem');
const router = (0, express_1.Router)();
/**
 * Security: restrict filesystem access to user's workspace and registered project directories.
 * Prevents path traversal attacks (e.g. reading /etc/shadow).
 */
const SENSITIVE_DIR_SEGMENTS = [
    ['.ssh'], ['.gnupg'], ['.gpg-agent'],
    ['.aws'], ['.azure'], ['.gcloud'], ['.docker'], ['.kube'],
    // .config/gcloud — Windows uses \ separators; build via path.join to stay portable.
    ['.config', 'gcloud'],
];
const SENSITIVE_DIRS = SENSITIVE_DIR_SEGMENTS.map((segs) => path.sep + path.join(...segs));
// Specific sensitive files (by relative path under $HOME) — blocked unconditionally,
// including for admin. These hold credentials, OAuth refresh tokens, bcrypt hashes,
// HMAC secrets, or private per-plugin state.
const SENSITIVE_FILES_IN_CCWEB = [
    'config.json', // JWT secret + bcrypt(admin)
    'users.json', // bcrypt(users) — offline crackable
    'approval-secret', // HMAC — leak lets anyone forge PermissionRequests
];
const SENSITIVE_CCWEB_PREFIXES = [
    path.sep + 'plugin-data' + path.sep, // per-plugin tokens / state
];
// Relative-to-HOME segments (kept as arrays so path.join handles platform separators).
const SENSITIVE_FILES_IN_HOME = [
    ['.npmrc'], // may contain npm _authToken
    ['.docker', 'config.json'], // registry tokens
];
function isSensitivePath(p) {
    const home = os.homedir();
    const rel = p.startsWith(home) ? p.slice(home.length) : '';
    for (const dir of SENSITIVE_DIRS) {
        if (rel === dir || rel.startsWith(dir + path.sep))
            return true;
    }
    const ccwebDir = path.join(home, '.ccweb');
    for (const f of SENSITIVE_FILES_IN_CCWEB) {
        if (p === path.join(ccwebDir, f))
            return true;
    }
    if (p.startsWith(ccwebDir + path.sep)) {
        const relCcweb = p.slice(ccwebDir.length);
        for (const prefix of SENSITIVE_CCWEB_PREFIXES) {
            if (relCcweb.startsWith(prefix))
                return true;
        }
    }
    for (const segs of SENSITIVE_FILES_IN_HOME) {
        if (p === path.join(home, ...segs))
            return true;
    }
    return false;
}
function isWithinAllowedDirs(p, username) {
    if ((0, config_1.isAdminUser)(username)) {
        const home = os.homedir();
        if (isSensitivePath(p))
            return false;
        return p === home || p.startsWith(home + path.sep);
    }
    const workspace = (0, config_1.getUserWorkspace)(username);
    if (p === workspace || p.startsWith(workspace + path.sep))
        return true;
    const projects = (0, config_1.getProjects)();
    for (const proj of projects) {
        if (proj.owner && proj.owner !== username)
            continue;
        const projectDir = path.resolve(proj.folderPath);
        if (p === projectDir || p.startsWith(projectDir + path.sep))
            return true;
    }
    return false;
}
/**
 * Walk up `p` until we find an existing ancestor; return its realpath, or null
 * if even `/` doesn't resolve. Used so that for not-yet-existing targets we can
 * still validate that the real (symlink-resolved) write location stays inside
 * an allowed directory — preventing `<allowed>/link/new.txt` where `link` is a
 * symlink to `/etc/`.
 */
function nearestExistingRealpath(p) {
    let cur = p;
    for (let i = 0; i < 64; i++) {
        try {
            return fs.realpathSync(cur);
        }
        catch {
            const parent = path.dirname(cur);
            if (parent === cur)
                return null;
            cur = parent;
        }
    }
    return null;
}
function isPathAllowed(resolvedPath, username) {
    if (!isWithinAllowedDirs(resolvedPath, username))
        return false;
    // Also verify the real path (after symlink resolution) is within allowed dirs.
    // This prevents symlink attacks where a link inside an allowed dir points outside.
    let lstat = null;
    try {
        lstat = fs.lstatSync(resolvedPath);
    }
    catch { /* ENOENT — handled below */ }
    if (lstat && lstat.isSymbolicLink()) {
        // Symlinks must resolve successfully and point within allowed dirs.
        // Broken symlinks are denied — there's no safe fallback.
        try {
            const realPath = fs.realpathSync(resolvedPath);
            if (!isWithinAllowedDirs(realPath, username))
                return false;
        }
        catch {
            return false; // broken symlink — deny
        }
        return true;
    }
    if (lstat) {
        // Regular file/dir — resolve any parent symlinks
        try {
            const realPath = fs.realpathSync(resolvedPath);
            if (realPath !== resolvedPath && !isWithinAllowedDirs(realPath, username))
                return false;
        }
        catch {
            // Should be reachable only on race; treat as deny.
            return false;
        }
        return true;
    }
    // Target does not exist yet (new file create / write). Walk up to the nearest
    // existing ancestor, realpath it, and verify the would-be parent is allowed.
    // Without this, `<allowed>/link/new.txt` where `link → /etc/` slips through
    // because lstat on the leaf fails and we'd return true.
    const parent = path.dirname(resolvedPath);
    const realParent = nearestExistingRealpath(parent);
    if (!realParent)
        return false;
    // Re-attach the part of the original path that lies beyond the existing ancestor.
    const existing = (function findExisting(p) {
        let c = p;
        for (let i = 0; i < 64; i++) {
            if (fs.existsSync(c))
                return c;
            const par = path.dirname(c);
            if (par === c)
                return c;
            c = par;
        }
        return c;
    })(parent);
    const tail = parent.slice(existing.length); // may be '' if parent itself exists
    const reconstructedParent = path.join(realParent, tail);
    if (!isWithinAllowedDirs(reconstructedParent, username))
        return false;
    return true;
}
// GET /api/filesystem?path=...
router.get('/', (req, res) => {
    const username = req.user?.username;
    const defaultPath = (0, config_1.getUserWorkspace)(username);
    const requestedPath = req.query['path'] || defaultPath;
    // Normalize and resolve the path
    const resolvedPath = path.resolve(requestedPath);
    if (!isPathAllowed(resolvedPath, username)) {
        res.status(403).json({ error: 'Access denied: path outside allowed directories' });
        return;
    }
    // Auto-create workspace directory if it doesn't exist (e.g. ~/Projects on fresh WSL installs)
    if (!fs.existsSync(resolvedPath) && resolvedPath === path.resolve(defaultPath)) {
        try {
            fs.mkdirSync(resolvedPath, { recursive: true });
        }
        catch { /* will fail below with ENOENT */ }
    }
    let entries = [];
    try {
        const dirents = fs.readdirSync(resolvedPath, { withFileTypes: true });
        entries = dirents
            .map((d) => ({
            name: d.name,
            type: (d.isDirectory() ? 'dir' : 'file'),
            path: path.join(resolvedPath, d.name),
        }))
            .sort((a, b) => {
            // Directories first, then alphabetical
            if (a.type === 'dir' && b.type !== 'dir')
                return -1;
            if (a.type !== 'dir' && b.type === 'dir')
                return 1;
            return a.name.localeCompare(b.name);
        });
    }
    catch (err) {
        const error = err;
        if (error.code === 'EACCES') {
            res.status(403).json({ error: 'Permission denied', path: resolvedPath });
            return;
        }
        if (error.code === 'ENOENT') {
            res.status(404).json({ error: 'Path not found', path: resolvedPath });
            return;
        }
        res.status(500).json({ error: 'Failed to read directory', path: resolvedPath });
        return;
    }
    const parent = path.dirname(resolvedPath);
    // Don't expose parent if it would be outside the user's workspace
    const parentAllowed = parent !== resolvedPath && isPathAllowed(parent, username);
    res.json({
        path: resolvedPath,
        parent: parentAllowed ? parent : null,
        entries,
    });
});
// POST /api/filesystem/mkdir  body: { path: string, name: string }
router.post('/mkdir', (req, res) => {
    const { path: parentPath, name } = req.body;
    if (!parentPath || !name) {
        res.status(400).json({ error: 'path and name are required' });
        return;
    }
    // Reject names with path separators, parent traversal, or dangerous characters (including Windows-invalid chars)
    if (/[/\\\0:*?<>|"]/.test(name) || name === '..' || name === '.' || name.trim() !== name || name.length > 255) {
        res.status(400).json({ error: 'Invalid folder name' });
        return;
    }
    const resolvedParent = path.resolve(parentPath);
    if (!isPathAllowed(resolvedParent, req.user?.username)) {
        res.status(403).json({ error: 'Access denied: path outside allowed directories' });
        return;
    }
    const newDir = path.join(resolvedParent, name);
    try {
        fs.mkdirSync(newDir, { recursive: false });
        res.json({ path: newDir });
    }
    catch (err) {
        const error = err;
        if (error.code === 'EEXIST') {
            res.status(409).json({ error: 'Folder already exists' });
            return;
        }
        if (error.code === 'EACCES') {
            res.status(403).json({ error: 'Permission denied' });
            return;
        }
        res.status(500).json({ error: 'Failed to create folder' });
    }
});
// GET /api/filesystem/file?path=...
router.get('/file', (req, res) => {
    const requestedPath = req.query['path'];
    if (!requestedPath) {
        res.status(400).json({ error: 'path is required' });
        return;
    }
    const resolvedPath = path.resolve(requestedPath);
    if (!isPathAllowed(resolvedPath, req.user?.username)) {
        res.status(403).json({ error: 'Access denied: path outside allowed directories' });
        return;
    }
    const SIZE_LIMIT = parseInt(process.env.CCWEB_FILE_SIZE_LIMIT || '', 10) || 5 * 1024 * 1024; // 5 MB default
    let stat;
    try {
        stat = fs.statSync(resolvedPath);
    }
    catch (err) {
        const error = err;
        if (error.code === 'ENOENT') {
            res.status(404).json({ error: 'File not found' });
            return;
        }
        res.status(500).json({ error: 'Failed to stat file' });
        return;
    }
    if (!stat.isFile()) {
        res.status(400).json({ error: 'Path is not a file' });
        return;
    }
    if (stat.size > SIZE_LIMIT) {
        res.json({ path: resolvedPath, tooLarge: true, size: stat.size, content: null });
        return;
    }
    let buffer;
    try {
        buffer = fs.readFileSync(resolvedPath);
    }
    catch (err) {
        const error = err;
        if (error.code === 'EACCES') {
            res.status(403).json({ error: 'Permission denied' });
            return;
        }
        res.status(500).json({ error: 'Failed to read file' });
        return;
    }
    // Heuristic binary detection: look for null bytes in first 8KB
    const sample = buffer.slice(0, 8192);
    const isBinary = sample.includes(0);
    if (isBinary) {
        res.json({ path: resolvedPath, binary: true, size: stat.size, content: null });
        return;
    }
    res.json({ path: resolvedPath, binary: false, tooLarge: false, size: stat.size, content: buffer.toString('utf8') });
});
// GET /api/filesystem/raw?path=...  — serve file with correct Content-Type (for images, etc.)
const MIME_MAP = {
    png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
    webp: 'image/webp', svg: 'image/svg+xml', bmp: 'image/bmp', ico: 'image/x-icon',
    avif: 'image/avif', tiff: 'image/tiff', tif: 'image/tiff',
    docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
    xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
    xls: 'application/vnd.ms-excel',
    pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
    pdf: 'application/pdf',
};
router.get('/raw', async (req, res) => {
    const requestedPath = req.query['path'];
    if (!requestedPath) {
        res.status(400).json({ error: 'path is required' });
        return;
    }
    const resolvedPath = path.resolve(requestedPath);
    if (!isPathAllowed(resolvedPath, req.user?.username)) {
        res.status(403).json({ error: 'Access denied' });
        return;
    }
    let stat;
    try {
        stat = fs.statSync(resolvedPath);
    }
    catch {
        res.status(404).json({ error: 'File not found' });
        return;
    }
    if (!stat.isFile()) {
        res.status(400).json({ error: 'Not a file' });
        return;
    }
    const isDownload = req.query['dl'] === '1';
    // Preview mode serves <img>/<video>/etc. sources in the browser — we cap
    // at 20 MB there so an accidentally-huge image can't blow up the page.
    // Downloads (dl=1) are an explicit user action; no size cap.
    const PREVIEW_LIMIT = 20 * 1024 * 1024;
    if (!isDownload && stat.size > PREVIEW_LIMIT) {
        res.status(413).json({ error: 'File too large for preview' });
        return;
    }
    const ext = path.extname(resolvedPath).slice(1).toLowerCase();
    const mime = MIME_MAP[ext] || 'application/octet-stream';
    res.setHeader('Content-Type', mime);
    res.setHeader('Cache-Control', 'no-cache');
    if (isDownload) {
        // RFC 5987: emit both a plain ASCII fallback (for old UAs) and
        // `filename*=UTF-8''<pct-encoded>` so modern browsers render Chinese /
        // emoji / spaces correctly instead of showing percent escapes as the
        // literal saved filename.
        const fileName = path.basename(resolvedPath);
        const ascii = fileName.replace(/[^\x20-\x7e]/g, '_').replace(/"/g, '\\"');
        const encoded = encodeURIComponent(fileName).replace(/'/g, '%27');
        res.setHeader('Content-Disposition', `attachment; filename="${ascii}"; filename*=UTF-8''${encoded}`);
        // Deliberately NOT setting Content-Length for downloads. When the file
        // is concurrently appended / truncated (common for CLI session JSONL),
        // or when stat metadata lags actual content (APFS clones, network FS),
        // a pre-declared Content-Length that disagrees with the bytes actually
        // streamed leaves the browser waiting forever for the "missing" tail
        // bytes — the "stuck at 99%" bug. Without Content-Length, Node falls
        // back to chunked transfer encoding, which terminates cleanly at EOF
        // regardless of the stat.size figure.
    }
    else {
        res.setHeader('Content-Length', String(stat.size));
    }
    // Use stream/promises `pipeline` for the whole stream lifecycle:
    // - normal EOF: pipeline resolves, response ends cleanly
    // - stream 'error': pipeline rejects, we destroy the response so the
    //   client sees an aborted connection (not "99% stuck")
    // - client disconnect (res 'close' before EOF): pipeline rejects with
    //   ERR_STREAM_PREMATURE_CLOSE, and pipeline itself destroys both sides
    //   — no manual `res.on('close')` needed, and no double-destroy race
    //   that the older `stream.pipe(res)` + manual cleanup combo had.
    const stream = fs.createReadStream(resolvedPath);
    try {
        await (0, promises_1.pipeline)(stream, res);
    }
    catch (err) {
        // Premature close on client abort is expected; don't log as error.
        const code = err?.code;
        if (code !== 'ERR_STREAM_PREMATURE_CLOSE') {
            log.error({ err, path: resolvedPath }, 'file stream error during /raw');
        }
        if (!res.headersSent) {
            res.status(500).json({ error: 'Read error' });
        }
        else if (!res.writableEnded) {
            res.destroy(err);
        }
    }
});
// PUT /api/filesystem/file  body: { path: string, content: string }
router.put('/file', (req, res) => {
    const { path: filePath, content } = req.body;
    if (!filePath || content === undefined) {
        res.status(400).json({ error: 'path and content are required' });
        return;
    }
    const resolvedPath = path.resolve(filePath);
    if (!isPathAllowed(resolvedPath, req.user?.username)) {
        res.status(403).json({ error: 'Access denied: path outside allowed directories' });
        return;
    }
    const WRITE_LIMIT = parseInt(process.env.CCWEB_FILE_SIZE_LIMIT || '', 10) || 10 * 1024 * 1024; // 10 MB
    if (Buffer.byteLength(content, 'utf-8') > WRITE_LIMIT) {
        res.status(413).json({ error: 'File too large to write' });
        return;
    }
    try {
        fs.writeFileSync(resolvedPath, content, 'utf-8');
        res.json({ path: resolvedPath, size: Buffer.byteLength(content, 'utf-8') });
    }
    catch (err) {
        const error = err;
        if (error.code === 'EACCES') {
            res.status(403).json({ error: 'Permission denied' });
            return;
        }
        res.status(500).json({ error: 'Failed to write file' });
    }
});
// POST /api/filesystem/upload  — upload files to a directory
const upload = (0, multer_1.default)({ dest: path.join(os.tmpdir(), 'ccweb-uploads'), limits: { fileSize: 1024 * 1024 * 1024 } });
/**
 * multer/busboy decodes the multipart frame's filename bytes as latin-1, but
 * browsers send UTF-8 (HTML5 spec + de facto behavior). The result for a name
 * like `中文.txt` is a mojibake string where each UTF-8 byte became a latin-1
 * char. Re-encode the misread chars back to bytes and decode as UTF-8 to
 * recover the original name. Idempotent for ASCII names (latin-1 ASCII bytes
 * round-trip identically through UTF-8).
 */
function decodeUploadName(name) {
    return Buffer.from(name, 'latin1').toString('utf8');
}
router.post('/upload', upload.array('files', 20), (req, res) => {
    const targetDir = req.body?.path;
    if (!targetDir) {
        res.status(400).json({ error: 'path is required' });
        return;
    }
    const resolvedDir = path.resolve(targetDir);
    if (!isPathAllowed(resolvedDir, req.user?.username)) {
        res.status(403).json({ error: 'Access denied: path outside allowed directories' });
        return;
    }
    const files = req.files;
    if (!files || files.length === 0) {
        res.status(400).json({ error: 'No files uploaded' });
        return;
    }
    // Allow callers to opt into overwriting existing files. Default is fail-soft
    // (skip the offender, keep going with the rest) — silently overwriting was
    // the audit's P1 #B8 finding. Frontend can pass `overwrite=true` after a
    // confirm dialog if the user really wants to replace.
    const overwrite = req.body?.overwrite === 'true' || req.body?.overwrite === true;
    const results = [];
    const errors = [];
    const skipped = [];
    for (const file of files) {
        const decodedName = decodeUploadName(file.originalname);
        const safeName = path.basename(decodedName);
        const dest = path.join(resolvedDir, safeName);
        if (!overwrite && fs.existsSync(dest)) {
            skipped.push(decodedName);
            try {
                fs.unlinkSync(file.path);
            }
            catch { }
            continue;
        }
        try {
            fs.renameSync(file.path, dest);
            results.push({ name: decodedName, path: dest, size: file.size });
        }
        catch {
            // rename may fail across filesystems, fallback to copy+delete
            try {
                fs.copyFileSync(file.path, dest);
                fs.unlinkSync(file.path);
                results.push({ name: decodedName, path: dest, size: file.size });
            }
            catch (err) {
                errors.push(`${decodedName}: ${err.message}`);
                try {
                    fs.unlinkSync(file.path);
                }
                catch { }
            }
        }
    }
    res.json({ uploaded: results, errors, skipped });
});
// DELETE /api/filesystem?path=...  — delete a file or directory
router.delete('/', (req, res) => {
    const requestedPath = req.query['path'];
    if (!requestedPath) {
        res.status(400).json({ error: 'path is required' });
        return;
    }
    const resolvedPath = path.resolve(requestedPath);
    if (!isPathAllowed(resolvedPath, req.user?.username)) {
        res.status(403).json({ error: 'Access denied: path outside allowed directories' });
        return;
    }
    let stat;
    try {
        stat = fs.lstatSync(resolvedPath);
    }
    catch (err) {
        const error = err;
        if (error.code === 'ENOENT') {
            res.status(404).json({ error: 'Path not found' });
            return;
        }
        res.status(500).json({ error: 'Failed to stat path' });
        return;
    }
    try {
        if (stat.isDirectory()) {
            fs.rmSync(resolvedPath, { recursive: true, force: true });
        }
        else {
            fs.unlinkSync(resolvedPath);
        }
        res.json({ deleted: resolvedPath });
    }
    catch (err) {
        const error = err;
        if (error.code === 'EACCES') {
            res.status(403).json({ error: 'Permission denied' });
            return;
        }
        res.status(500).json({ error: 'Failed to delete' });
    }
});
exports.default = router;
//# sourceMappingURL=filesystem.js.map