// *****************************************************************************
// Copyright (C) 2026 STMicroelectronics and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************

import * as cookie from 'cookie';
import * as crypto from 'crypto';
import * as http from 'http';
import express = require('express');
import { inject, injectable } from 'inversify';
import { environment } from '../../common/index';
import { MaybePromise } from '../../common';
import { BackendApplicationContribution, EarlyExpressMiddleware } from '../backend-application';
import { WsRequestValidatorContribution } from '../ws-request-validators';
import { generateUuid } from '../../common/uuid';

export const BrowserConnectionToken = Symbol('BrowserConnectionToken');

export const BROWSER_TOKEN_COOKIE_NAME = 'theia-connection-token';

export interface BrowserConnectionToken {
    value: string;
}

export const HttpConnectionValidator = Symbol('HttpConnectionValidator');

/**
 * Express middleware provider that rejects HTTP requests lacking a valid connection token.
 *
 * The connection-token cookie is only *bootstrapped* globally (see
 * {@link BrowserConnectionTokenBackendContribution}); enforcement is opt-in per route.
 * Security-sensitive HTTP endpoints (e.g. the filesystem upload/download routes) should
 * inject this and apply {@link validateRequest} as route middleware. Non-sensitive routes
 * (the initial HTML page, static assets) must not use it, as they legitimately have no
 * cookie yet on the very first page load.
 */
export interface HttpConnectionValidator {
    /**
     * Express middleware that calls `next()` when the request carries a valid connection-token
     * cookie (or when running in Electron) and responds with `403` otherwise.
     */
    validateRequest(req: express.Request, res: express.Response, next: express.NextFunction): void;
}

/**
 * Validates WebSocket and HTTP requests using a cookie-based connection token.
 *
 * In browser deployments, the server generates a random token at startup and sets it
 * as a `SameSite=Strict; HttpOnly` cookie on the first page load. Cross-origin pages
 * cannot obtain or send this cookie, so their requests are rejected.
 *
 * The cookie is *bootstrapped* for every HTTP request (via {@link expressMiddleware}) so that
 * browsers always receive it, but HTTP requests are only *rejected* on routes that opt in to
 * enforcement via {@link validateRequest} (see {@link HttpConnectionValidator}). WebSocket
 * upgrades are always validated (see {@link allowWsUpgrade}).
 *
 * This complements the origin validator: non-browser callers that omit the Origin
 * header (e.g. Node.js scripts) still cannot reach the backend without the cookie.
 *
 * Skipped in Electron deployments (which use their own `ElectronSecurityToken`).
 */
@injectable()
export class BrowserConnectionTokenBackendContribution implements BackendApplicationContribution, WsRequestValidatorContribution, HttpConnectionValidator {

    @inject(BrowserConnectionToken)
    protected readonly browserConnectionToken: BrowserConnectionToken;

    @inject(EarlyExpressMiddleware)
    protected readonly earlyMiddleware: EarlyExpressMiddleware;

    /**
     * Register the cookie middleware during `initialize()` via `EarlyExpressMiddleware`
     * so it runs before `express.static()` (which is registered later during `configure()`).
     * This ensures the browser receives the token cookie on the initial page load.
     */
    initialize(): void {
        if (environment.electron.is()) {
            return;
        }
        this.earlyMiddleware.handlers.push((req, res, next) => this.expressMiddleware(req, res, next));
    }

    /**
     * Validate the connection token cookie on WebSocket upgrade requests.
     * Non-browser callers that omit the Origin header (e.g. Node.js scripts)
     * cannot provide the `SameSite=Strict` cookie either, so they are rejected.
     */
    allowWsUpgrade(request: http.IncomingMessage): MaybePromise<boolean> {
        if (environment.electron.is()) {
            return true;
        }
        const token = this.getTokenFromCookie(request);
        if (token) {
            return this.isTokenValid(token);
        }
        // No cookie: reject. Legitimate browsers always have the cookie
        // because it is set on the initial page load.
        return false;
    }

    /**
     * Reject the request with `403` unless it carries a valid connection-token cookie.
     * Always allows the request in Electron deployments, consistent with {@link allowWsUpgrade}.
     */
    validateRequest(req: express.Request, res: express.Response, next: express.NextFunction): void {
        if (environment.electron.is()) {
            next();
            return;
        }
        const token = this.getTokenFromCookie(req);
        if (token && this.isTokenValid(token)) {
            next();
            return;
        }
        // No cookie or stale/invalid token: reject. Legitimate browsers always have the cookie
        // because it is set on the initial page load, and same-origin requests send it automatically.
        res.sendStatus(403);
    }

    protected expressMiddleware(req: express.Request, res: express.Response, next: express.NextFunction): void {
        const existing = this.getTokenFromCookie(req);
        if (!existing || !this.isTokenValid(existing)) {
            // No cookie or stale cookie (e.g. after server restart) so (re-)issue it.
            // The browser will use the fresh token on subsequent requests.
            res.cookie(BROWSER_TOKEN_COOKIE_NAME, this.browserConnectionToken.value, {
                httpOnly: true,
                sameSite: 'strict',
                path: '/'
            });
        }
        next();
    }

    protected getTokenFromCookie(req: http.IncomingMessage): string | undefined {
        const cookieHeader = req.headers.cookie;
        if (cookieHeader) {
            return cookie.parse(cookieHeader)[BROWSER_TOKEN_COOKIE_NAME];
        }
        return undefined;
    }

    protected isTokenValid(token: string): boolean {
        try {
            const received = Buffer.from(token, 'utf8');
            const expected = Buffer.from(this.browserConnectionToken.value, 'utf8');
            return received.byteLength === expected.byteLength && crypto.timingSafeEqual(received, expected);
        } catch (error) {
            console.error(error);
        }
        return false;
    }
}

/**
 * Creates a new browser connection token.
 */
export function createBrowserConnectionToken(): BrowserConnectionToken {
    return { value: generateUuid() };
}
