<?php

/**
 * Guest-capable AJAX endpoints for the "avatar during registration" feature (#5471).
 *
 * A visitor filling in the registration form has no user ID yet, so the avatar cannot
 * be attached to a user. Instead each visitor gets a random 32-hex `token` (emitted as a
 * hidden form field) that keys a temporary workspace under
 *   wp-content/peepso/tmp/registration-avatars/{token}/
 * holding the usual avatar-orig-tmp.jpg / avatar-full-tmp.jpg / avatar-tmp.jpg triple.
 * On successful registration PeepSoRegisterShortcode stages that triple into the new
 * user's directory and calls finalize_move_avatar_file(). Abandoned workspaces are purged
 * by PeepSoMaintenanceCore.
 *
 * These endpoints are unauthenticated (listed in ajax_auth_exceptions()), so every one of
 * them runs _guard() first: feature-enabled check, dedicated nonce, strict token format,
 * and realpath containment of the workspace path.
 */
class PeepSoRegisterAvatarAjax extends PeepSoAjaxCallback
{
	const NONCE = 'registration-avatar';
	const TOKEN_FIELD = 'registration_avatar_token';

	/**
	 * Register the endpoints that do not require a logged-in user.
	 * @return array
	 */
	public function ajax_auth_exceptions()
	{
		return array('upload', 'crop', 'rotate', 'remove');
	}

	/**************** static helpers (also used by shortcode + maintenance) ****************/

	/**
	 * Is the feature usable right now? Off entirely when WP-avatars-only is enforced.
	 * @return bool
	 */
	public static function is_enabled()
	{
		if (1 === (int) PeepSo::get_option('avatars_wordpress_only', 0)) {
			return (FALSE);
		}
		return ((int) PeepSo::get_option_new('registration_avatars_enable') >= 1);
	}

	/**
	 * @param string $token
	 * @return bool TRUE for exactly 32 lowercase hex characters
	 */
	public static function valid_token($token)
	{
		return (is_string($token) && 1 === preg_match('/^[a-f0-9]{32}$/', $token));
	}

	/**
	 * Base directory that holds all per-token workspaces (trailing slash).
	 * @return string
	 */
	public static function base_dir()
	{
		return (PeepSo::get_peepso_dir() . 'tmp' . DIRECTORY_SEPARATOR . 'registration-avatars' . DIRECTORY_SEPARATOR);
	}

	/**
	 * Absolute, realpath-contained workspace directory for a token, or FALSE.
	 * @param string $token
	 * @param bool   $create create + harden the directory if missing
	 * @return string|false trailing-slash path, or FALSE if invalid/uncontained
	 */
	public static function token_dir($token, $create = FALSE)
	{
		if (!self::valid_token($token)) {
			return (FALSE);
		}

		$base = self::base_dir();
		if ($create) {
			self::harden_base($base);
			wp_mkdir_p($base . $token);
		}

		$real_base = realpath($base);
		$real_dir  = realpath($base . $token);

		// Not created yet (read-only lookup) — synthesize the path but keep the regex guarantee.
		if (FALSE === $real_dir) {
			return ($create ? FALSE : $base . $token . DIRECTORY_SEPARATOR);
		}

		// Containment: the resolved path must sit inside the resolved base.
		if (FALSE === $real_base || 0 !== strpos($real_dir . DIRECTORY_SEPARATOR, $real_base . DIRECTORY_SEPARATOR)) {
			return (FALSE);
		}

		return (trailingslashit($real_dir));
	}

	/**
	 * Public URL of a file inside a token workspace (for the preview <img>).
	 * @param string $token
	 * @param string $file
	 * @return string
	 */
	public static function token_uri($token, $file)
	{
		return (PeepSo::get_peepso_uri() . 'tmp/registration-avatars/' . $token . '/' . $file);
	}

	/**
	 * Do all three temp images exist in a workspace?
	 * @param string $dir trailing-slash directory
	 * @return bool
	 */
	public static function triple_exists($dir)
	{
		return (
			file_exists($dir . 'avatar-orig-tmp.jpg') &&
			file_exists($dir . 'avatar-full-tmp.jpg') &&
			file_exists($dir . 'avatar-tmp.jpg')
		);
	}

	/**
	 * Plant hardening files so the anonymous-write workspace cannot serve/execute code
	 * or be directory-listed. wp-content/peepso itself ships no such protection, but this
	 * subtree takes uploads from logged-out visitors, so it must.
	 * @param string $base trailing-slash base directory
	 */
	public static function harden_base($base)
	{
		wp_mkdir_p($base);

		$htaccess = $base . '.htaccess';
		if (!file_exists($htaccess)) {
			$rules = "# PeepSo #5471 — deny execution/listing of registration avatar uploads\n"
				. "<IfModule mod_php.c>\nphp_flag engine off\n</IfModule>\n"
				. "<IfModule mod_php7.c>\nphp_flag engine off\n</IfModule>\n"
				. "<IfModule mod_php8.c>\nphp_flag engine off\n</IfModule>\n"
				. "<FilesMatch \"(?i)\\.(php|phtml|phar|php[0-9])$\">\n"
				. "\t<IfModule mod_authz_core.c>\n\t\tRequire all denied\n\t</IfModule>\n"
				. "\t<IfModule !mod_authz_core.c>\n\t\tDeny from all\n\t</IfModule>\n"
				. "</FilesMatch>\n"
				. "Options -Indexes\n";
			@file_put_contents($htaccess, $rules);
		}

		$index = $base . 'index.html';
		if (!file_exists($index)) {
			@file_put_contents($index, '');
		}
	}

	/**
	 * Delete a single token workspace and its contents.
	 * @param string $token
	 */
	public static function cleanup_token($token)
	{
		$dir = self::token_dir($token);
		if (FALSE === $dir || !is_dir($dir)) {
			return;
		}
		self::rrmdir($dir);
	}

	/**
	 * Purge token workspaces older than $ttl seconds. Used by the maintenance cron.
	 * @param int $ttl
	 * @return int number of workspaces removed
	 */
	public static function purge_old($ttl)
	{
		$base = self::base_dir();
		if (!is_dir($base)) {
			return (0);
		}

		$removed = 0;
		$now = time();
		$entries = @scandir($base);
		if (FALSE === $entries) {
			return (0);
		}

		foreach ($entries as $entry) {
			if (!self::valid_token($entry)) {
				continue; // skips '.', '..', '.htaccess', 'index.html'
			}
			$dir = $base . $entry;
			if (is_dir($dir) && ($now - (int) filemtime($dir)) > $ttl) {
				self::rrmdir($dir . DIRECTORY_SEPARATOR);
				$removed++;
			}
		}

		return ($removed);
	}

	/**
	 * Recursively remove a directory of avatar temp files (one level of flat files).
	 * @param string $dir trailing-slash directory
	 */
	private static function rrmdir($dir)
	{
		$files = @glob($dir . '*');
		if (is_array($files)) {
			foreach ($files as $file) {
				if (is_file($file)) {
					@unlink($file);
				}
			}
		}
		@rmdir($dir);
	}

	/**************** endpoints ****************/

	/**
	 * Shared preamble for every endpoint: feature gate + nonce + token + workspace.
	 * @param PeepSoAjaxResponse $resp
	 * @param string             $token by-ref, validated token
	 * @param string             $dir   by-ref, created + contained workspace dir
	 * @return bool
	 */
	private function _guard(PeepSoAjaxResponse $resp, &$token, &$dir)
	{
		if (!self::is_enabled()) {
			$resp->error(__('Avatar upload is not available.', 'peepso-core'));
			$resp->success(FALSE);
			return (FALSE);
		}

		if (!wp_verify_nonce($this->_input->value('_wpnonce', '', FALSE), self::NONCE)) {
			$resp->error(__('Invalid security challenge code.', 'peepso-core'));
			$resp->success(FALSE);
			return (FALSE);
		}

		$token = $this->_input->value(self::TOKEN_FIELD, '', FALSE);
		if (!self::valid_token($token)) {
			$resp->error(__('Invalid request.', 'peepso-core'));
			$resp->success(FALSE);
			return (FALSE);
		}

		$dir = self::token_dir($token, TRUE);
		if (FALSE === $dir) {
			$resp->error(__('Invalid request.', 'peepso-core'));
			$resp->success(FALSE);
			return (FALSE);
		}

		return (TRUE);
	}

	/**
	 * Build the cache-busted preview URL for the full-size temp avatar.
	 * @param string $token
	 * @param string $dir
	 * @return string
	 */
	private function _preview_url($token, $dir)
	{
		$file = 'avatar-full-tmp.jpg';
		$mt = file_exists($dir . $file) ? filemtime($dir . $file) : time();
		return (self::token_uri($token, $file) . '?mt=' . $mt);
	}

	/**
	 * Endpoint: upload a fresh image into the workspace (overwrites any prior one).
	 * @param PeepSoAjaxResponse $resp
	 */
	public function upload(PeepSoAjaxResponse $resp)
	{
		$token = $dir = NULL;
		if (!$this->_guard($resp, $token, $dir)) {
			return;
		}

		if (!isset($_FILES['filedata']) || empty($_FILES['filedata']['tmp_name']) || !is_uploaded_file($_FILES['filedata']['tmp_name'])) {
			$resp->error(__('The file you uploaded is either missing or too large.', 'peepso-core'));
			$resp->success(FALSE);
			return;
		}

		$tmp_name = $_FILES['filedata']['tmp_name'];

		// Server-side size cap — never trust the client's check (avatar.js only warns).
		$max = wp_max_upload_size();
		if ($max > 0 && filesize($tmp_name) > $max) {
			$resp->error(__('The file you uploaded is either missing or too large.', 'peepso-core'));
			$resp->success(FALSE);
			return;
		}

		// Validate by CONTENT, not by the attacker-controlled Content-Type header.
		// getimagesize() only succeeds on real raster images, so SVG (stored-XSS vector)
		// and non-images are rejected here. GIF is decodable but intentionally disallowed.
		$info = @getimagesize($tmp_name);
		$allowed_types = apply_filters(
			'peepso_registration_avatar_image_types',
			array(IMAGETYPE_JPEG, IMAGETYPE_PNG, IMAGETYPE_WEBP)
		);
		if (FALSE === $info || !isset($info[2]) || !in_array((int) $info[2], $allowed_types, TRUE)) {
			$resp->error(__('The file type you uploaded is not allowed.', 'peepso-core'));
			$resp->success(FALSE);
			return;
		}

		// One avatar per token: replace whatever was there.
		self::rrmdir($dir);
		wp_mkdir_p($dir);

		PeepSoUser::write_avatar_triple($tmp_name, $dir, FALSE);

		if (!self::triple_exists($dir)) {
			$resp->error(__('The file type you uploaded is not allowed.', 'peepso-core'));
			$resp->success(FALSE);
			return;
		}

		// Minimum resolution parity with the logged-in flow.
		$si = new PeepSoSimpleImage();
		$si->load($dir . 'avatar-orig-tmp.jpg');
		$avatar_size = (int) PeepSo::get_option('avatar_size', 250);
		if ($si->getWidth() < $avatar_size || $si->getHeight() < $avatar_size) {
			self::rrmdir($dir);
			$resp->set('width', $si->getWidth());
			$resp->set('height', $si->getHeight());
			$resp->error(sprintf(__('Minimum avatar resolution is %d x %d pixels.', 'peepso-core'), $avatar_size, $avatar_size));
			$resp->success(FALSE);
			return;
		}

		$resp->set('image_url', $this->_preview_url($token, $dir));
		$resp->set('orig_image_url', self::token_uri($token, 'avatar-orig-tmp.jpg') . '?mt=' . time());
		$resp->success(TRUE);
	}

	/**
	 * Endpoint: crop the workspace avatar (always operates on the temp triple).
	 * @param PeepSoAjaxResponse $resp
	 */
	public function crop(PeepSoAjaxResponse $resp)
	{
		$token = $dir = NULL;
		if (!$this->_guard($resp, $token, $dir)) {
			return;
		}

		$src_orig = $dir . 'avatar-orig-tmp.jpg';
		$src_full = $dir . 'avatar-full-tmp.jpg';
		$src_thumb = $dir . 'avatar-tmp.jpg';

		if (!file_exists($src_orig)) {
			$resp->error(__('Invalid request.', 'peepso-core'));
			$resp->success(FALSE);
			return;
		}

		$si = new PeepSoSimpleImage();
		$si->load($src_orig);

		$width = $this->_input->int('width');
		$height = $this->_input->int('height');

		// The dialog reports coordinates against its on-screen (resized) image; mirror the
		// logged-in flow and resize the source to that space first.
		if ($width > 0 && $height > 0) {
			$si->resize($width, $height);
		}

		$img_w = imagesx($si->image);
		$img_h = imagesy($si->image);

		// Clamp all coordinates into the (possibly resized) image bounds. On an anonymous
		// endpoint, hostile values could otherwise drive huge GD allocations.
		$x  = max(0, min($this->_input->int('x'), $img_w));
		$y  = max(0, min($this->_input->int('y'), $img_h));
		$x2 = max($x, min($this->_input->int('x2'), $img_w));
		$y2 = max($y, min($this->_input->int('y2'), $img_h));
		$crop_w = max(1, $x2 - $x);
		$crop_h = max(1, $y2 - $y);

		$avatar_size = (int) PeepSo::get_option('avatar_size', 250);

		$new_image = imagecreatetruecolor($avatar_size, $avatar_size);
		imagecopyresampled($new_image, $si->image, 0, 0, $x, $y, $avatar_size, $avatar_size, $crop_w, $crop_h);
		imagejpeg($new_image, $src_full, 100);

		$new_image = imagecreatetruecolor(PeepSoUser::THUMB_WIDTH, PeepSoUser::THUMB_WIDTH);
		imagecopyresampled($new_image, $si->image, 0, 0, $x, $y, PeepSoUser::THUMB_WIDTH, PeepSoUser::THUMB_WIDTH, $crop_w, $crop_h);
		imagejpeg($new_image, $src_thumb, 75);

		$resp->set('image_url', $this->_preview_url($token, $dir));
		$resp->success(TRUE);
	}

	/**
	 * Endpoint: rotate the workspace avatar 90° cw/ccw (temp triple).
	 * @param PeepSoAjaxResponse $resp
	 */
	public function rotate(PeepSoAjaxResponse $resp)
	{
		$token = $dir = NULL;
		if (!$this->_guard($resp, $token, $dir)) {
			return;
		}

		if (!self::triple_exists($dir)) {
			$resp->error(__('Invalid request.', 'peepso-core'));
			$resp->success(FALSE);
			return;
		}

		$direction = $this->_input->value('direction', 'cw', FALSE);
		$angle = ('ccw' === $direction) ? 90 : 270;

		foreach (array('avatar-orig-tmp.jpg', 'avatar-full-tmp.jpg', 'avatar-tmp.jpg') as $file) {
			$si = new PeepSoSimpleImage();
			$si->load($dir . $file);
			$si->rotate($angle);
			$si->save($dir . $file, $si->image_type, 100);
		}

		$resp->set('image_url', $this->_preview_url($token, $dir));
		$resp->set('orig_image_url', self::token_uri($token, 'avatar-orig-tmp.jpg') . '?mt=' . time());
		$resp->success(TRUE);
	}

	/**
	 * Endpoint: discard the workspace avatar.
	 * @param PeepSoAjaxResponse $resp
	 */
	public function remove(PeepSoAjaxResponse $resp)
	{
		$token = $dir = NULL;
		if (!$this->_guard($resp, $token, $dir)) {
			return;
		}

		self::cleanup_token($token);
		$resp->set('image_url', PeepSo::get_asset('images/avatar/user-neutral.png'));
		$resp->success(TRUE);
	}
}

// EOF
