<?php

/*
 * Textpattern Content Management System
 * https://textpattern.com/
 *
 * Copyright (C) 2025 The Textpattern Development Team
 *
 * This file is part of Textpattern.
 *
 * Textpattern is free software; you can redistribute it and/or
 * modify it under the terms of the GNU General Public License
 * as published by the Free Software Foundation, version 2.
 *
 * Textpattern is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with Textpattern. If not, see <https://www.gnu.org/licenses/>.
 */

/**
 * Manages security tokens.
 *
 * @since   4.9.0
 * @package Security
 */

namespace Textpattern\Security;

class Token implements \Textpattern\Container\ReusableInterface
{
    /**
     * Generate a ciphered token.
     *
     * The token is reproducible, unique among sites and users, expires later.
     *
     * @see  form_token()
     * @param  null|string $salt  A bit of salt
     * @return string CSRF token
     */

    public function csrf($salt = null)
    {
        static $token = array(), $blog_uid = null, $nonce = null;
        global $txp_user;

        // Generate a ciphered token from the current user's nonce (thus valid for
        // login time plus 30 days) and a pinch of salt from the blog UID.

        if (!isset($blog_uid)) {
            $blog_uid = get_pref('blog_uid');
        }

        if (!isset($salt)) {
            $salt = $blog_uid;
        }

        if (!isset($nonce)) {
            $nonce = $txp_user ? safe_field("nonce", 'txp_users', "name = '".doSlash($txp_user)."'") : '';
        }

        if (!isset($token[$salt])) {
            $token[$salt] = sha1($nonce.$salt);
        }

        return $token[$salt];
    }

    /**
     * Validates admin steps and protects against CSRF attempts using tokens.
     *
     * Takes an admin step and validates it against an array of valid steps.
     * The valid steps array indicates the step's token based session riding
     * protection needs.
     *
     * If the step requires CSRF token protection, and the request doesn't come with
     * a valid token, the request is terminated, defeating any CSRF attempts.
     *
     * If the $step isn't in valid $steps, it returns FALSE, but the request
     * isn't terminated. If the $step is valid and passes CSRF validation,
     * returns TRUE.
     *
     * @param   string $step  Requested admin step
     * @param   array  $steps An array of valid steps with flag indicating CSRF needs,
     *                        e.g. array('savething' => true, 'listthings' => false)
     * @return  bool          If the $step is valid, proceeds and returns TRUE. Dies on CSRF attempt.
     * @see     $this->csrf()
     * @example
     * global $step;
     * if (Txp::get('\Textpattern\Security\Token')->bouncer($step, array(
     *     'browse'     => false,
     *     'edit'       => false,
     *     'save'       => true,
     *     'multi_edit' => true,
     * )))
     * {
     *     echo "The '{$step}' is valid.";
     * }
     */

    public function bouncer($step, $steps)
    {
        global $event;

        if (empty($step)) {
            return true;
        }

        // Validate step.
        if (!array_key_exists($step, $steps)) {
            return false;
        }

        // Does this step require a token?
        if (!$steps[$step]) {
            return true;
        }

        if (is_array($steps[$step]) && gpsa(array_keys($steps[$step])) != $steps[$step]) {
            return true;
        }

        // Validate token.
        if (gps('_txp_token') === $this->csrf()) {
            return true;
        }

        die(gTxt('get_off_my_lawn', array(
            '{event}' => $event,
            '{step}'  => $step,
        )));
    }

    /**
     * Create a secure token hash in the database from the passed information.
     *
     * @param  int|null $ref             Reference to the user's account (user_id) or some other id
     * @param  string   $type            Flavour of token to create
     * @param  int      $expiryTimestamp UNIX timestamp of when the token will expire
     * @param  string   $pass            Password, used as part of the token generation
     * @param  string   $nonce           Random nonce associated with the token
     * @return string                    Secure token suitable for emailing as part of a link
     */

    public function generate($ref, $type, $expiryTimestamp, $pass, $nonce)
    {
        $expiry = safe_strftime('%Y-%m-%d %H:%M:%S', $expiryTimestamp);

        // The selector becomes an indirect reference to the user row id,
        // and thus does not leak information when publicly displayed.
        $selector = \Txp::get('\Textpattern\Password\Random')->generate(12);

        // Use a hash of the nonce, selector and password.
        // This ensures that requests expire automatically when:
        //  a) The person logs in, or
        //  b) They successfully set/change their password
        // Using the selector in the hash just injects randomness, otherwise two requests
        // back-to-back would generate the same code.
        // Old requests for the same user id are purged when password is set.
        $token = $this->constructHash($selector, $pass, $nonce);
        $user_token = $token.$selector;

        if (isset($ref)) {
        // Remove any previous activation tokens and insert the new one.
            $ref = assert_int($ref);
            $safe_type = doSlash($type);
            $this->remove($safe_type, $ref);
            safe_insert("txp_token",
                "reference_id = '$ref',
                type = '$safe_type',
                selector = '".doSlash($selector)."',
                token = '".doSlash($token)."',
                expires = '".doSlash($expiry)."'
            ");
        }

        return $user_token;
    }

    /**
     * Construct a hash value from the cryptographic combination of the passed params.
     *
     * @param  string $selector The stretch
     * @param  string $pass     The secret
     * @param  string $nonce    The salt
     * @return string           Token
     */

    public function constructHash($selector, $pass, $nonce)
    {
        return bin2hex(pack('H*', substr(hash(HASHING_ALGORITHM, $nonce.$selector.$pass), 0, SALT_LENGTH)));
    }

    /**
     * Return the given token by its type and selector.
     *
     * @param  string       $type  The type of token
     * @param  string|array $match The selector/ref to locate the token row
     * @return array               The relevant fields from the found row, or empty array if not found
     */

    public function fetch($type, $match)
    {
        if (is_array($match)) {
            $selector = !empty($match['selector']) ? $match['selector'] : '';
            $ref = !empty($match['ref']) ? $match['ref'] : '';
        } else {
            $selector = $match;
            $ref = '';
        }

        $set = array();

        if ($selector) {
            $set[] = "selector = '".doSlash($selector)."'";
        }

        if ($ref) {
            $set[] = "reference_id = '".doSlash($ref)."'";
        }

        if (count($set) > 0) {
            return safe_row(
                "reference_id, selector, token, expires",
                'txp_token',
                (join(' AND ', $set)) . " AND type='".doSlash($type)."'"
            );
        }

        return array();
    }

    /**
     * Remove used/unnecessary/expired tokens. Chainable.
     *
     * @param  string $type     Token type
     * @param  string $ref      Reference to a particular row
     * @param  string $interval Remove other rows that are outside this time range
     * @example
     * Txp::get('\Textpattern\Security\Token')->remove('password_reset', 42, '4 HOUR');
     */

    public function remove($type, $ref = null, $interval = null)
    {
        $where = array();

        if ($ref) {
            $where[] = 'reference_id = '.doSlash($ref);
        }

        if ($interval) {
            $where[] = 'expires < DATE_SUB(NOW(), INTERVAL '.doSlash($interval).')';
        }

        $whereStr = implode(' OR ', $where);

        safe_delete("txp_token", "type = '".doSlash($type). "'" . ($whereStr ? " AND (".$whereStr.")" : ''));

        return $this;
    }
}
