"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.__tsfTypeAliases = exports.FileUpload = void 0;
exports.isMultipartRequest = isMultipartRequest;
exports.parseMultipartRequest = parseMultipartRequest;
exports.parseByteSize = parseByteSize;
exports.normalizeAllowedTypes = normalizeAllowedTypes;
exports.mimeMatchesAllowedTypes = mimeMatchesAllowedTypes;
exports.cleanupUploadedFiles = cleanupUploadedFiles;
const tslib_1 = require("tslib");
const _a = tslib_1.__importStar(require("@zyno-io/ts-server-foundation/type-metadata-runtime"));
const busboy_1 = tslib_1.__importDefault(require("busboy"));
const node_fs_1 = require("node:fs");
const promises_1 = require("node:fs/promises");
const node_os_1 = require("node:os");
const node_path_1 = require("node:path");
const node_stream_1 = require("node:stream");
const promises_2 = require("node:stream/promises");
const helpers_1 = require("../helpers");
const errors_1 = require("./errors");
const form_body_1 = require("./form-body");
const headers_1 = require("./headers");
const __tsf_metadata_type = _a.createCompactMetadataRegistryV1("[1,[{\"kind\":6},{\"kind\":7},{\"kind\":2,\"typeName\":\"(error?: Error | null, data?: Buffer) =\\u003e void\"},{\"kind\":3},{\"kind\":2,\"typeName\":\"(error?: Error | null) =\\u003e void\"},{\"kind\":2,\"typeName\":\"{\\n            name: string;\\n            upload: FileUpload;\\n            policy?: FileUploadPolicy;\\n            sampleSize: number;\\n            detect?: (buffer: Buffer) =\\u003e Promise\\u003cFileTypeResult | undefined\\u003e;\\n        }\"}]]", []);
let _FileUploadMetadata_1;
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
class FileUpload {
    path;
    size;
    type;
    declaredType;
    detectedType;
    detectedExtension;
    originalName;
    get name() {
        return this.originalName;
    }
    static __tsfType = _FileUploadMetadata_1 = _a.decodeCompactMetadataV1("[1,{\"kind\":16,\"name\":\"FileUpload\",\"typeName\":\"FileUpload\",\"classType\":{\"$tsf\":0},\"properties\":[{\"name\":\"path\",\"type\":{\"$tsfType\":0},\"optional\":false},{\"name\":\"size\",\"type\":{\"$tsfType\":1},\"optional\":false},{\"name\":\"type\",\"type\":{\"$tsfType\":0},\"optional\":false},{\"name\":\"declaredType\",\"type\":{\"$tsfType\":0},\"optional\":false},{\"name\":\"detectedType\",\"type\":{\"$tsfType\":0},\"optional\":true},{\"name\":\"detectedExtension\",\"type\":{\"$tsfType\":0},\"optional\":true},{\"name\":\"originalName\",\"type\":{\"$tsfType\":0},\"optional\":false}],\"methods\":[],\"hasConstructor\":false,\"constructorParameters\":[]}]", [() => FileUpload], __tsf_metadata_type);
}
exports.FileUpload = FileUpload;
_FileUploadMetadata_1.classType = FileUpload;
FileUpload.__tsfType = _FileUploadMetadata_1;
const multipartJsonKey = '_payload';
const UploadTempDirsSymbol = Symbol.for('@zyno-io/ts-server-foundation:http-upload-temp-dirs');
function isMultipartRequest(request) {
    const contentType = getContentType(request);
    return /^multipart\/form-data\b/i.test(contentType);
}
let fileTypeModulePromise;
async function parseMultipartRequest(request, policy = {}) {
    const contentType = getContentType(request);
    if (!isMultipartRequest(request)) {
        throw new errors_1.HttpBadRequestError('Request is not multipart/form-data');
    }
    let parser;
    try {
        parser = (0, busboy_1.default)({
            headers: { 'content-type': contentType },
            limits: {
                fields: request.bodyLimits.maxFormFields,
                fieldNameSize: request.bodyLimits.maxFormFieldNameLength
            }
        });
    }
    catch {
        throw new errors_1.HttpBadRequestError('Failed to parse multipart body');
    }
    const fields = new form_body_1.FormBodyBuilder(request.bodyLimits);
    const uploadedFiles = Object.create(null);
    const fileWrites = [];
    const uploadDir = await (0, promises_1.mkdtemp)((0, node_path_1.join)((0, node_os_1.tmpdir)(), 'tsf-upload-'));
    getUploadTempDirs(request).push(uploadDir);
    const fileWriteErrors = new Set();
    let multipartError;
    let jsonPayload;
    parser.on('field', (name, value, info) => {
        if (info.nameTruncated) {
            multipartError ??= new errors_1.HttpPayloadTooLargeError('Form field name is too long');
            return;
        }
        if (info.valueTruncated) {
            multipartError ??= new errors_1.HttpPayloadTooLargeError(`Form field "${name}" is too large`);
            return;
        }
        if (name === multipartJsonKey) {
            try {
                if (jsonPayload !== undefined)
                    throw new Error('Multipart JSON payload may only be provided once');
                const parsed = value ? JSON.parse(value) : {};
                if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
                    throw new Error('Multipart JSON payload must be an object');
                }
                jsonPayload = parsed;
            }
            catch {
                multipartError ??= new errors_1.HttpBadRequestError('Failed to parse multipart JSON payload');
            }
            return;
        }
        try {
            fields.add(name, value);
        }
        catch (error) {
            multipartError ??= normalizeMultipartFieldError(error);
        }
    });
    parser.on('fieldsLimit', () => {
        multipartError ??= new errors_1.HttpPayloadTooLargeError('Form contains too many fields');
    });
    parser.on('file', (name, file, info) => {
        try {
            (0, form_body_1.assertTopLevelFormFieldName)(name, request.bodyLimits);
        }
        catch (error) {
            multipartError ??= normalizeMultipartFieldError(error);
            file.resume();
            return;
        }
        const filePolicy = policy.files?.[name];
        if (!filePolicy && policy.rejectUndeclaredFiles) {
            multipartError ??= new errors_1.HttpBadRequestError(`Unexpected file field "${name}"`);
            file.resume();
            return;
        }
        const write = writeMultipartFile(uploadDir, name, file, info, filePolicy).then(upload => ({ name, upload }));
        write.catch(error => {
            fileWriteErrors.add(error);
        });
        fileWrites.push(write);
    });
    let completedFiles;
    try {
        await (0, promises_2.pipeline)(request.stream, parser);
        if (multipartError)
            throw multipartError;
        if (jsonPayload)
            fields.mergeObject(jsonPayload);
        completedFiles = await Promise.all(fileWrites);
    }
    catch (error) {
        await Promise.allSettled(fileWrites);
        if (error instanceof errors_1.HttpError || fileWriteErrors.has(error))
            throw error;
        throw new errors_1.HttpBadRequestError('Failed to parse multipart body');
    }
    // Promise.all preserves the parser's file-event order even when a later,
    // smaller upload finishes writing first.
    const fileFields = new form_body_1.FormBodyBuilder(request.bodyLimits);
    for (const { name, upload } of completedFiles) {
        assignMultiValue(uploadedFiles, name, upload);
        fileFields.addTopLevelFile(name, upload);
    }
    fields.merge(fileFields);
    return { body: fields.build(), uploadedFiles };
}
function parseByteSize(value) {
    if (typeof value === 'number' && Number.isFinite(value) && value >= 0)
        return Math.floor(value);
    if (typeof value !== 'string')
        return undefined;
    const match = /^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb|kib|mib|gib)?$/i.exec(value.trim());
    if (!match)
        return undefined;
    const amount = Number(match[1]);
    if (!Number.isFinite(amount) || amount < 0)
        return undefined;
    const unit = (match[2] ?? 'b').toLowerCase();
    const multiplier = unit === 'gb' || unit === 'gib' ? 1024 ** 3 : unit === 'mb' || unit === 'mib' ? 1024 ** 2 : unit === 'kb' || unit === 'kib' ? 1024 : 1;
    return Math.floor(amount * multiplier);
}
function normalizeAllowedTypes(value) {
    if (typeof value === 'string') {
        const pattern = normalizeMimePattern(value);
        return pattern ? [pattern] : undefined;
    }
    if (!Array.isArray(value))
        return undefined;
    return value.map(item => (typeof item === 'string' ? normalizeMimePattern(item) : '')).filter(Boolean);
}
function mimeMatchesAllowedTypes(mimeType, allowedTypes) {
    if (!allowedTypes?.length)
        return true;
    const normalized = normalizeMimeType(mimeType);
    return allowedTypes.some(pattern => mimeMatchesPattern(normalized, pattern));
}
async function writeMultipartFile(uploadDir, name, file, info, policy) {
    const declaredType = normalizeMimeType(info.mimeType || 'application/octet-stream');
    if (!mimeMatchesAllowedTypes(declaredType, policy?.allowedTypes)) {
        file.resume();
        throw new errors_1.HttpUnsupportedMediaTypeError(`File field "${name}" has unsupported content type`);
    }
    const upload = new FileUpload();
    upload.path = (0, node_path_1.join)(uploadDir, `${(0, helpers_1.uuid7)()}-${sanitizeFilename(info.filename || 'upload')}`);
    upload.size = 0;
    upload.type = declaredType;
    upload.declaredType = declaredType;
    upload.originalName = info.filename || 'upload';
    const fileType = policy?.allowedTypes?.length ? await loadFileTypeModule() : undefined;
    const validator = new FileUploadValidationTransform({
        name,
        upload,
        policy,
        sampleSize: fileType?.reasonableDetectionSizeInBytes ?? 4100,
        detect: fileType ? buffer => fileType.fileTypeFromBuffer(buffer) : undefined
    });
    await (0, promises_2.pipeline)(file, validator, (0, node_fs_1.createWriteStream)(upload.path));
    return upload;
}
let _FileUploadValidationTransformMetadata_1;
class FileUploadValidationTransform extends node_stream_1.Transform {
    options;
    buffered = [];
    sample = [];
    sampleBytes = 0;
    validated = false;
    constructor(options) {
        super();
        this.options = options;
    }
    _transform(chunk, encoding, callback) {
        const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding);
        this.options.upload.size += buffer.length;
        if (this.options.policy?.maxSizeBytes !== undefined && this.options.upload.size > this.options.policy.maxSizeBytes) {
            callback(new errors_1.HttpPayloadTooLargeError(`File field "${this.options.name}" is too large`));
            return;
        }
        if (!this.options.detect || this.validated) {
            callback(null, buffer);
            return;
        }
        this.buffered.push(buffer);
        this.appendSample(buffer);
        if (this.sampleBytes < this.options.sampleSize) {
            callback();
            return;
        }
        this.validateAndRelease(callback);
    }
    _flush(callback) {
        if (!this.options.detect || this.validated) {
            callback();
            return;
        }
        this.validateAndRelease(callback);
    }
    appendSample(buffer) {
        if (this.sampleBytes >= this.options.sampleSize)
            return;
        const remaining = this.options.sampleSize - this.sampleBytes;
        const sample = buffer.length > remaining ? buffer.subarray(0, remaining) : buffer;
        this.sample.push(sample);
        this.sampleBytes += sample.length;
    }
    validateAndRelease(callback) {
        this.validateDetectedType()
            .then(() => {
            for (const chunk of this.buffered)
                this.push(chunk);
            this.buffered = [];
            callback();
        })
            .catch(callback);
    }
    async validateDetectedType() {
        if (this.validated)
            return;
        this.validated = true;
        const sample = Buffer.concat(this.sample, this.sampleBytes);
        const detected = sample.length ? await this.options.detect?.(sample) : undefined;
        if (!detected?.mime) {
            throw new errors_1.HttpUnsupportedMediaTypeError(`File field "${this.options.name}" has unsupported content type`);
        }
        const detectedType = normalizeMimeType(detected.mime);
        if (!mimeMatchesAllowedTypes(detectedType, this.options.policy?.allowedTypes)) {
            throw new errors_1.HttpUnsupportedMediaTypeError(`File field "${this.options.name}" has unsupported content type`);
        }
        this.options.upload.detectedType = detectedType;
        this.options.upload.detectedExtension = detected.ext;
    }
    static __tsfType = _FileUploadValidationTransformMetadata_1 = _a.decodeCompactMetadataV1("[1,{\"kind\":16,\"name\":\"FileUploadValidationTransform\",\"typeName\":\"FileUploadValidationTransform\",\"classType\":{\"$tsf\":0},\"properties\":[],\"methods\":[{\"name\":\"_transform\",\"parameters\":[{\"name\":\"chunk\",\"type\":{\"kind\":12,\"types\":[{\"kind\":16,\"typeName\":\"Buffer\",\"classType\":{\"$tsf\":1}},{\"kind\":6}]},\"optional\":false,\"default\":false},{\"name\":\"encoding\",\"type\":{\"kind\":16,\"typeName\":\"BufferEncoding\",\"classType\":{\"$tsf\":2}},\"optional\":false,\"default\":false},{\"name\":\"callback\",\"type\":{\"$tsfType\":2},\"optional\":false,\"default\":false}],\"returnType\":{\"$tsfType\":3}},{\"name\":\"_flush\",\"parameters\":[{\"name\":\"callback\",\"type\":{\"$tsfType\":4},\"optional\":false,\"default\":false}],\"returnType\":{\"$tsfType\":3}}],\"hasConstructor\":true,\"constructorParameters\":[{\"name\":\"options\",\"type\":{\"$tsfType\":5},\"optional\":false,\"default\":false}]}]", [() => FileUploadValidationTransform, () => (typeof Buffer !== "undefined" ? Buffer : (typeof exports !== "undefined" ? exports.Buffer : undefined)), () => (typeof BufferEncoding !== "undefined" ? BufferEncoding : (typeof exports !== "undefined" ? exports.BufferEncoding : undefined))], __tsf_metadata_type);
}
_FileUploadValidationTransformMetadata_1.classType = FileUploadValidationTransform;
FileUploadValidationTransform.__tsfType = _FileUploadValidationTransformMetadata_1;
function loadFileTypeModule() {
    fileTypeModulePromise ??= new Function('specifier', 'return import(specifier)')('file-type');
    return fileTypeModulePromise;
}
async function cleanupUploadedFiles(request) {
    const uploadDirs = getUploadTempDirs(request);
    if (!uploadDirs.length)
        return;
    request.store[UploadTempDirsSymbol] = [];
    request.uploadedFiles = Object.create(null);
    await Promise.all(uploadDirs.map(dir => (0, promises_1.rm)(dir, { recursive: true, force: true })));
}
function getContentType(request) {
    return (0, headers_1.getRequestHeader)(request, 'content-type') ?? '';
}
function assignMultiValue(target, name, value) {
    const current = target[name];
    if (current === undefined) {
        target[name] = value;
    }
    else if (Array.isArray(current)) {
        current.push(value);
    }
    else {
        target[name] = [current, value];
    }
}
function normalizeMultipartFieldError(error) {
    return error instanceof errors_1.HttpError ? error : new errors_1.HttpBadRequestError('Failed to parse multipart body');
}
function normalizeMimeType(value) {
    return value.split(';')[0].trim().toLowerCase() || 'application/octet-stream';
}
function normalizeMimePattern(value) {
    const normalized = normalizeMimeType(value);
    return isMimePattern(normalized) ? normalized : '';
}
function mimeMatchesPattern(mimeType, pattern) {
    if (pattern === '*/*')
        return true;
    const [patternType, patternSubtype] = pattern.split('/');
    const [mimeTypeName, mimeSubtype] = mimeType.split('/');
    if (!patternType || !patternSubtype || !mimeTypeName || !mimeSubtype)
        return false;
    if (patternSubtype === '*')
        return patternType === mimeTypeName;
    return patternType === mimeTypeName && patternSubtype === mimeSubtype;
}
function isMimePattern(value) {
    return /^(?:\*|[a-z0-9!#$&^_.+-]+)\/(?:\*|[a-z0-9!#$&^_.+-]+)$/i.test(value);
}
function sanitizeFilename(filename) {
    return filename.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 120) || 'upload';
}
function getUploadTempDirs(request) {
    const existing = request.store[UploadTempDirsSymbol];
    if (Array.isArray(existing))
        return existing;
    const dirs = [];
    request.store[UploadTempDirsSymbol] = dirs;
    return dirs;
}
exports.__tsfTypeAliases = _a.decodeCompactMetadataV1("[1,{\"FileUploadAllowedTypes\":{\"kind\":12,\"types\":[{\"kind\":6},{\"kind\":14,\"type\":{\"kind\":6}}],\"typeName\":\"FileUploadAllowedTypes\"},\"FileUploadOptions\":{\"kind\":18,\"typeName\":\"FileUploadOptions\",\"types\":[{\"kind\":20,\"name\":\"maxSize\",\"type\":{\"kind\":12,\"types\":[{\"kind\":12,\"types\":[{\"kind\":7},{\"kind\":16,\"typeName\":\"`${number}${'B' | 'KB' | 'MB' | 'GB' | 'KiB' | 'MiB' | 'GiB'}`\",\"classType\":{\"$tsf\":0}}]},{\"kind\":4}]},\"optional\":true},{\"kind\":20,\"name\":\"allowedTypes\",\"type\":{\"kind\":12,\"types\":[{\"kind\":12,\"types\":[{\"kind\":6},{\"kind\":14,\"type\":{\"kind\":6}}],\"typeName\":\"FileUploadAllowedTypes\"},{\"kind\":4}]},\"optional\":true}]},\"FileUploadPolicy\":{\"kind\":18,\"typeName\":\"FileUploadPolicy\",\"types\":[{\"kind\":20,\"name\":\"maxSizeBytes\",\"type\":{\"kind\":12,\"types\":[{\"kind\":7},{\"kind\":4}]},\"optional\":true},{\"kind\":20,\"name\":\"allowedTypes\",\"type\":{\"kind\":12,\"types\":[{\"kind\":14,\"type\":{\"kind\":6}},{\"kind\":4}]},\"optional\":true}]},\"MultipartRequestPolicy\":{\"kind\":18,\"typeName\":\"MultipartRequestPolicy\",\"types\":[{\"kind\":20,\"name\":\"files\",\"type\":{\"kind\":12,\"types\":[{\"kind\":18,\"typeName\":\"Record\",\"utilityType\":\"Record\",\"typeArguments\":[{\"kind\":6},{\"kind\":18,\"typeName\":\"FileUploadPolicy\",\"types\":[{\"kind\":20,\"name\":\"maxSizeBytes\",\"type\":{\"kind\":12,\"types\":[{\"kind\":7},{\"kind\":4}]},\"optional\":true},{\"kind\":20,\"name\":\"allowedTypes\",\"type\":{\"kind\":12,\"types\":[{\"kind\":14,\"type\":{\"kind\":6}},{\"kind\":4}]},\"optional\":true}]}],\"index\":{\"kind\":18,\"typeName\":\"FileUploadPolicy\",\"types\":[{\"kind\":20,\"name\":\"maxSizeBytes\",\"type\":{\"kind\":12,\"types\":[{\"kind\":7},{\"kind\":4}]},\"optional\":true},{\"kind\":20,\"name\":\"allowedTypes\",\"type\":{\"kind\":12,\"types\":[{\"kind\":14,\"type\":{\"kind\":6}},{\"kind\":4}]},\"optional\":true}]},\"types\":[]},{\"kind\":4}]},\"optional\":true},{\"kind\":20,\"name\":\"rejectUndeclaredFiles\",\"type\":{\"kind\":12,\"types\":[{\"kind\":8},{\"kind\":4}]},\"optional\":true}]},\"ParsedMultipartBody\":{\"kind\":18,\"typeName\":\"ParsedMultipartBody\",\"types\":[{\"kind\":20,\"name\":\"body\",\"type\":{\"kind\":18,\"typeName\":\"Record\",\"utilityType\":\"Record\",\"typeArguments\":[{\"kind\":6},{\"kind\":2}],\"index\":{\"kind\":2},\"types\":[]},\"optional\":false},{\"kind\":20,\"name\":\"uploadedFiles\",\"type\":{\"kind\":18,\"typeName\":\"Record\",\"utilityType\":\"Record\",\"typeArguments\":[{\"kind\":6},{\"kind\":14,\"type\":{\"kind\":12,\"types\":[{\"kind\":16,\"typeName\":\"FileUpload\",\"classType\":{\"$tsf\":1}},{\"kind\":16,\"typeName\":\"FileUpload\",\"classType\":{\"$tsf\":1}}]}}],\"index\":{\"kind\":14,\"type\":{\"kind\":12,\"types\":[{\"kind\":16,\"typeName\":\"FileUpload\",\"classType\":{\"$tsf\":1}},{\"kind\":16,\"typeName\":\"FileUpload\",\"classType\":{\"$tsf\":1}}]}},\"types\":[],\"typeName\":\"UploadedFiles\"},\"optional\":false}]},\"UploadedFiles\":{\"kind\":18,\"typeName\":\"Record\",\"utilityType\":\"Record\",\"typeArguments\":[{\"kind\":6},{\"kind\":14,\"type\":{\"kind\":12,\"types\":[{\"kind\":16,\"typeName\":\"FileUpload\",\"classType\":{\"$tsf\":1}},{\"kind\":16,\"typeName\":\"FileUpload\",\"classType\":{\"$tsf\":1}}]}}],\"index\":{\"kind\":14,\"type\":{\"kind\":12,\"types\":[{\"kind\":16,\"typeName\":\"FileUpload\",\"classType\":{\"$tsf\":1}},{\"kind\":16,\"typeName\":\"FileUpload\",\"classType\":{\"$tsf\":1}}]}},\"types\":[],\"typeName\":\"UploadedFiles\"}}]", [() => `${number}${'B' | 'KB' | 'MB' | 'GB' | 'KiB' | 'MiB' | 'GiB'}`, () => (typeof FileUpload !== "undefined" ? FileUpload : (typeof exports !== "undefined" ? exports.FileUpload : undefined))], __tsf_metadata_type);
//# sourceMappingURL=uploads.js.map