"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 (mod) {
    if (mod && mod.__esModule) return mod;
    var result = {};
    if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
    __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 });
exports.StaticAssetHandler = exports.StaticAssetEntry = void 0;
const node_fs_1 = require("node:fs");
const escape_string_regexp_1 = __importDefault(require("escape-string-regexp"));
const mime = __importStar(require("mime-types"));
const LogUtil_1 = require("../../logging/LogUtil");
const ContentTypes_1 = require("../../util/ContentTypes");
const InternalServerError_1 = require("../../util/errors/InternalServerError");
const NotFoundHttpError_1 = require("../../util/errors/NotFoundHttpError");
const NotImplementedHttpError_1 = require("../../util/errors/NotImplementedHttpError");
const PathUtil_1 = require("../../util/PathUtil");
const StreamUtil_1 = require("../../util/StreamUtil");
const HttpHandler_1 = require("../HttpHandler");
/**
 * Used to link file paths with relative URLs.
 * By using a separate class instead of a key/value map it is easier to replace values in Components.js.
 */
class StaticAssetEntry {
    relativeUrl;
    filePath;
    constructor(relativeUrl, filePath) {
        this.relativeUrl = relativeUrl;
        this.filePath = filePath;
    }
}
exports.StaticAssetEntry = StaticAssetEntry;
/**
 * Handler that serves static resources on specific paths.
 * Relative file paths are assumed to be relative to the current working directory.
 * Relative file paths can be preceded by `@css:`, e.g. `@css:foo/bar`,
 * in case they need to be relative to the module root.
 * File paths ending in a slash assume the target is a folder and map all of its contents.
 */
class StaticAssetHandler extends HttpHandler_1.HttpHandler {
    mappings;
    pathMatcher;
    expires;
    logger = (0, LogUtil_1.getLoggerFor)(this);
    /**
     * Creates a handler for the provided static resources.
     * @param assets - A list of {@link StaticAssetEntry}.
     * @param baseUrl - The base URL of the server.
     * @param options - Specific options.
     * @param options.expires - Cache expiration time in seconds.
     */
    constructor(assets, baseUrl, options = {}) {
        super();
        this.mappings = {};
        const rootPath = (0, PathUtil_1.ensureTrailingSlash)(new URL(baseUrl).pathname);
        for (const { relativeUrl, filePath } of assets) {
            this.mappings[(0, PathUtil_1.trimLeadingSlashes)(relativeUrl)] = (0, PathUtil_1.resolveAssetPath)(filePath);
        }
        this.pathMatcher = this.createPathMatcher(rootPath);
        this.expires = Number.isInteger(options.expires) ? Math.max(0, options.expires) : 0;
    }
    /**
     * Creates a regular expression that matches the URL paths.
     */
    createPathMatcher(rootPath) {
        // Sort longest paths first to ensure the longest match has priority
        const paths = Object.keys(this.mappings)
            .sort((pathA, pathB) => pathB.length - pathA.length);
        // Collect regular expressions for files and folders separately.
        // The arrays need initial values to prevent matching everything, as they will if these are empty.
        const files = ['.^'];
        const folders = ['.^'];
        for (const path of paths) {
            const filePath = this.mappings[path];
            if (filePath.endsWith('/') && !path.endsWith('/')) {
                throw new InternalServerError_1.InternalServerError(`Server is misconfigured: StaticAssetHandler can not ` +
                    `have a file path ending on a slash if the URL does not, but received ${path} and ${filePath}`);
            }
            (filePath.endsWith('/') ? folders : files).push((0, escape_string_regexp_1.default)(path));
        }
        // Either match an exact document or a file within a folder (stripping the query string)
        return new RegExp(`^${rootPath}(?:(${files.join('|')})|(${folders.join('|')})([^?]+))(?:\\?.*)?$`, 'u');
    }
    /**
     * Obtains the file path corresponding to the asset URL
     */
    getFilePath({ url }) {
        // Verify if the URL matches any of the paths
        const match = this.pathMatcher.exec(url ?? '');
        if (!match || match[0].includes('/..')) {
            throw new NotImplementedHttpError_1.NotImplementedHttpError(`No static resource configured at ${url}`);
        }
        // The mapping is either a known document, or a file within a folder
        const [, document, folder, file] = match;
        return typeof document === 'string' ?
            this.mappings[document] :
            (0, PathUtil_1.joinFilePath)(this.mappings[folder], decodeURIComponent(file));
    }
    async canHandle({ request }) {
        if (request.method !== 'GET' && request.method !== 'HEAD') {
            throw new NotImplementedHttpError_1.NotImplementedHttpError('Only GET and HEAD requests are supported');
        }
        this.getFilePath(request);
    }
    async handle({ request, response }) {
        // Determine the asset to serve
        const filePath = this.getFilePath(request);
        this.logger.debug(`Serving ${request.url} via static asset ${filePath}`);
        // Resolve when asset loading succeeds
        const asset = (0, node_fs_1.createReadStream)(filePath);
        return new Promise((resolve, reject) => {
            // Write a 200 response when the asset becomes readable
            asset.once('readable', () => {
                const contentType = mime.lookup(filePath) || ContentTypes_1.APPLICATION_OCTET_STREAM;
                response.writeHead(200, {
                    // eslint-disable-next-line ts/naming-convention
                    'content-type': contentType,
                    ...this.getCacheHeaders(),
                });
                // With HEAD, only write the headers
                if (request.method === 'HEAD') {
                    response.end();
                    asset.destroy();
                    // With GET, pipe the entire response
                }
                else {
                    (0, StreamUtil_1.pipeSafely)(asset, response);
                }
                resolve();
            });
            // Pass the error when something goes wrong
            asset.once('error', (error) => {
                const { code } = error;
                // When the file if not found or a folder, signal a 404
                if (code === 'ENOENT' || code === 'EISDIR') {
                    this.logger.debug(`Static asset ${filePath} not found`);
                    reject(new NotFoundHttpError_1.NotFoundHttpError(`Cannot find ${request.url}`));
                    // In other cases, we might already have started writing, so just hang up
                }
                else {
                    this.logger.warn(`Error reading asset ${filePath}: ${error.message}`);
                    response.end();
                    asset.destroy();
                    resolve();
                }
            });
        });
    }
    getCacheHeaders() {
        return this.expires <= 0 ?
            {} :
            {
                // eslint-disable-next-line ts/naming-convention
                'cache-control': `max-age=${this.expires}`,
                expires: new Date(Date.now() + (this.expires * 1000)).toUTCString(),
            };
    }
}
exports.StaticAssetHandler = StaticAssetHandler;
//# sourceMappingURL=StaticAssetHandler.js.map