<?php

defined( 'ABSPATH' ) || exit;

/**
 * SVG Upload module.
 *
 * Lets WordPress accept .svg uploads, with two safety gates:
 *  1. The MIME type is only added for users in the configured "allowed
 *     uploaders" group (default: administrators only).
 *  2. Every uploaded SVG is sanitized on `wp_handle_upload_prefilter` —
 *     `<script>`, `<foreignObject>`, `<iframe>`, `<embed>`, `<object>`,
 *     `<animate*>`, and `<set>` elements are removed; all `on*` event
 *     attributes are stripped; any `href`/`xlink:href` with `javascript:`
 *     protocol is dropped.
 *
 * SVG sanitization uses libxml/DOMDocument — no Composer dependency. This is
 * a pragmatic implementation that covers the common attack vectors. For a
 * comprehensive sanitizer, ship `enshrined/svg-sanitize` via Composer; that
 * is intentionally out-of-scope for the v1 implementation.
 *
 * Per BW security policy: this module is disabled by default for new
 * installations. Activate it deliberately and only after confirming the
 * "allowed uploaders" setting is right for the site.
 *
 * @package BW_Dev
 */

class BW_Dev_Module_SVG_Upload implements BW_Dev_Module_Interface {

	/** Element tags removed unconditionally during sanitization. */
	private const FORBIDDEN_TAGS = array(
		'script',
		'foreignObject',
		'iframe',
		'embed',
		'object',
		'animate',
		'animateMotion',
		'animateTransform',
		'set',
		'handler',
		'a',  // SVG <a> links — strip to avoid javascript: URLs hidden in nested links.
	);

	public function slug(): string {
		return 'svg_upload';
	}

	public function label(): string {
		return __( 'SVG Upload', 'bw-dev' );
	}

	public function group(): string {
		return 'editor_admin';
	}

	public function default_settings(): array {
		return array( 'allowed_uploaders' => 'admin_only' );
	}

	public function sanitize( array $data ): array {
		$raw = isset( $data['allowed_uploaders'] ) ? sanitize_key( (string) $data['allowed_uploaders'] ) : 'admin_only';
		$ok  = in_array( $raw, array( 'admin_only', 'upload_capable' ), true );
		return array( 'allowed_uploaders' => $ok ? $raw : 'admin_only' );
	}

	public function register(): void {
		add_filter( 'upload_mimes',                array( $this, 'allow_svg_mime' ) );
		add_filter( 'wp_check_filetype_and_ext',   array( $this, 'fix_filetype_check' ), 10, 5 );
		add_filter( 'wp_handle_upload_prefilter',  array( $this, 'sanitize_uploaded_svg' ) );
		add_filter( 'wp_prepare_attachment_for_js', array( $this, 'fix_media_library_preview' ), 10, 3 );
	}

	/**
	 * Add svg to the allowed upload MIME map — but only if the current user
	 * is allowed to upload SVGs per the module setting.
	 */
	public function allow_svg_mime( $mimes ) {
		if ( ! is_array( $mimes ) ) {
			$mimes = array();
		}
		if ( $this->current_user_may_upload_svg() ) {
			$mimes['svg']  = 'image/svg+xml';
			$mimes['svgz'] = 'image/svg+xml';
		}
		return $mimes;
	}

	/**
	 * WordPress's `wp_check_filetype_and_ext` sniffs the file with finfo and
	 * sometimes returns text/plain or image/svg for SVGs depending on the
	 * server's libmagic. Normalize to image/svg+xml when the extension is
	 * .svg and the user is allowed to upload SVGs.
	 *
	 * This filter runs for EVERY upload, so the handler must return $data
	 * unchanged for anything that isn't actually an SVG — both non-svg
	 * extensions and .svg-named files whose libmagic-detected MIME isn't
	 * SVG-like (e.g. a PDF renamed to foo.svg). Narrowing to SVG MIMEs keeps
	 * this from clobbering WordPress's type detection for unrelated uploads
	 * and avoids re-labeling misnamed payloads as image/svg+xml.
	 */
	public function fix_filetype_check( $data, $file, $filename, $mimes, $real_mime = '' ) {
		unset( $file, $mimes );

		$ext = strtolower( pathinfo( (string) $filename, PATHINFO_EXTENSION ) );
		if ( 'svg' !== $ext && 'svgz' !== $ext ) {
			return $data;
		}
		if ( ! $this->current_user_may_upload_svg() ) {
			return $data;
		}

		// Narrow to SVG-like MIMEs. libmagic emits image/svg+xml, image/svg,
		// text/xml, application/xml, or text/plain for real SVGs (or nothing
		// at all on some configs). For .svgz it emits application/gzip. If
		// libmagic detected anything else, the .svg extension is misleading
		// — leave $data alone so WordPress rejects the upload normally.
		$detected = strtolower( (string) $real_mime );
		$svg_like = 'svgz' === $ext
			? array( '', 'application/gzip', 'application/x-gzip', 'image/svg+xml' )
			: array( '', 'image/svg+xml', 'image/svg', 'text/xml', 'application/xml', 'text/plain', 'text/html' );
		if ( ! in_array( $detected, $svg_like, true ) ) {
			return $data;
		}

		return array(
			'ext'             => $ext,
			'type'            => 'image/svg+xml',
			'proper_filename' => is_array( $data ) && isset( $data['proper_filename'] ) ? $data['proper_filename'] : false,
		);
	}

	/**
	 * Sanitize the SVG file's contents BEFORE WordPress moves it into the
	 * uploads dir. If sanitization fails, reject the upload entirely.
	 *
	 * @param array $file $_FILES element passed through the prefilter.
	 */
	public function sanitize_uploaded_svg( $file ) {
		if ( ! is_array( $file ) || empty( $file['tmp_name'] ) || ! file_exists( $file['tmp_name'] ) ) {
			return $file;
		}
		$ext = strtolower( pathinfo( (string) ( $file['name'] ?? '' ), PATHINFO_EXTENSION ) );
		if ( 'svg' !== $ext && 'svgz' !== $ext ) {
			return $file;
		}

		$raw = (string) @file_get_contents( $file['tmp_name'] );
		if ( 'svgz' === $ext ) {
			$decoded = @gzdecode( $raw );
			if ( false === $decoded ) {
				$file['error'] = __( 'SVGZ file could not be decompressed.', 'bw-dev' );
				return $file;
			}
			$raw = $decoded;
		}

		$clean = self::sanitize_svg_string( $raw );
		if ( '' === $clean ) {
			$file['error'] = __( 'SVG file rejected by the sanitizer (could not parse, or contained only forbidden content).', 'bw-dev' );
			return $file;
		}

		// Re-compress if the user uploaded .svgz; otherwise plain XML.
		$out = 'svgz' === $ext ? gzencode( $clean ) : $clean;
		if ( false === @file_put_contents( $file['tmp_name'], $out ) ) {
			$file['error'] = __( 'Could not write sanitized SVG.', 'bw-dev' );
		}
		return $file;
	}

	/**
	 * Ensure the media library shows SVGs in the grid view by giving them
	 * a sensible `sizes` block. Without this WP shows a broken-image
	 * thumbnail for any SVG.
	 */
	public function fix_media_library_preview( $response, $attachment, $meta ) {
		unset( $meta );
		if ( ! is_array( $response ) || empty( $response['mime'] ) || 'image/svg+xml' !== $response['mime'] ) {
			return $response;
		}
		$src = wp_get_attachment_url( $attachment->ID );
		if ( ! $src ) {
			return $response;
		}
		$response['sizes']  = array(
			'full' => array(
				'url'         => $src,
				'orientation' => 'landscape',
			),
		);
		$response['icon']   = $src;
		return $response;
	}

	/* ---------------------------------------------------------------------
	 * Capability gate
	 * ------------------------------------------------------------------- */

	private function current_user_may_upload_svg(): bool {
		$mode = (string) bw_dev()->settings()->get( $this->slug(), 'allowed_uploaders', 'admin_only' );
		if ( 'admin_only' === $mode ) {
			return current_user_can( 'manage_options' );
		}
		// 'upload_capable': anyone WordPress already lets upload files.
		return current_user_can( 'upload_files' );
	}

	/* ---------------------------------------------------------------------
	 * Sanitizer
	 * ------------------------------------------------------------------- */

	/**
	 * Strip dangerous elements + attributes from an SVG string. Returns the
	 * sanitized XML, or '' on parse failure.
	 *
	 * NOTE: this is intentionally a pragmatic, blocklist-based sanitizer.
	 * It blocks the well-known XSS vectors (scripts, event handlers,
	 * javascript: URLs, foreignObject, etc.) but does not enforce an
	 * allowlist of SVG elements/attributes. For high-risk environments,
	 * ship a real library (enshrined/svg-sanitize) and replace this.
	 */
	public static function sanitize_svg_string( string $svg ): string {
		$svg = trim( $svg );
		if ( '' === $svg ) {
			return '';
		}

		// Strip XML processing instructions and external DTDs before parsing
		// to avoid XXE surface. loadXML with LIBXML_NONET also blocks it.
		$svg = preg_replace( '/<\?xml-stylesheet[^>]*\?>/i', '', $svg );
		$svg = preg_replace( '/<!ENTITY[^>]*>/i', '', $svg );

		$prev = libxml_use_internal_errors( true );

		$dom = new DOMDocument();
		// LIBXML_NONET disables network access (no XXE via external entity).
		$loaded = @$dom->loadXML( $svg, LIBXML_NONET );

		libxml_clear_errors();
		libxml_use_internal_errors( $prev );

		if ( ! $loaded || ! $dom->documentElement ) {
			return '';
		}

		// Root must be <svg>.
		if ( 'svg' !== strtolower( $dom->documentElement->nodeName ) ) {
			return '';
		}

		self::remove_forbidden_elements( $dom );
		self::strip_dangerous_attributes( $dom );

		$out = $dom->saveXML( $dom->documentElement );
		return is_string( $out ) ? $out : '';
	}

	private static function remove_forbidden_elements( DOMDocument $dom ): void {
		foreach ( self::FORBIDDEN_TAGS as $tag ) {
			$nodes = $dom->getElementsByTagName( $tag );
			// Iterate in reverse so removal doesn't shift the live NodeList.
			for ( $i = $nodes->length - 1; $i >= 0; $i-- ) {
				$node = $nodes->item( $i );
				if ( $node && $node->parentNode ) {
					$node->parentNode->removeChild( $node );
				}
			}
		}
	}

	private static function strip_dangerous_attributes( DOMDocument $dom ): void {
		$xpath = new DOMXPath( $dom );
		$nodes = $xpath->query( '//*' );
		if ( ! $nodes ) {
			return;
		}
		foreach ( $nodes as $node ) {
			if ( ! ( $node instanceof DOMElement ) ) {
				continue;
			}
			$to_remove = array();
			foreach ( $node->attributes as $attr ) {
				$name  = strtolower( $attr->nodeName );
				$value = (string) $attr->nodeValue;
				// All `on*` event handlers.
				if ( 0 === strpos( $name, 'on' ) ) {
					$to_remove[] = $attr->nodeName;
					continue;
				}
				// href / xlink:href with javascript: (also data: which can be a CSP-bypassing iframe trick).
				if ( in_array( $name, array( 'href', 'xlink:href' ), true ) ) {
					$trimmed = ltrim( $value );
					if ( 0 === stripos( $trimmed, 'javascript:' ) || 0 === stripos( $trimmed, 'data:' ) ) {
						$to_remove[] = $attr->nodeName;
					}
				}
				// `style` with expression()/url(javascript:).
				if ( 'style' === $name && ( stripos( $value, 'expression(' ) !== false || stripos( $value, 'javascript:' ) !== false ) ) {
					$to_remove[] = $attr->nodeName;
				}
			}
			foreach ( $to_remove as $remove ) {
				$node->removeAttribute( $remove );
			}
		}
	}

	/* ---------------------------------------------------------------------
	 * Settings tab
	 * ------------------------------------------------------------------- */

	public function render_tab(): void {
		$mode    = (string) bw_dev()->settings()->get( $this->slug(), 'allowed_uploaders', 'admin_only' );
		$name    = BW_Dev_Settings::OPTION . '[' . $this->slug() . '][allowed_uploaders]';
		?>
		<p class="description">
			<?php esc_html_e( 'Lets WordPress accept .svg (and .svgz) uploads. Every upload is sanitized — scripts, event handlers, foreign objects, and javascript: URLs are stripped before the file is saved. This module is module-toggleable on the Modules tab and additionally gated by role here.', 'bw-dev' ); ?>
		</p>

		<div style="background:#fff;border-left:4px solid #d63638;padding:12px 16px;margin:14px 0;max-width:720px;">
			<strong><?php esc_html_e( 'Security note', 'bw-dev' ); ?></strong>
			<p style="margin:6px 0 0;">
				<?php esc_html_e( 'SVGs are XML, not images — they can contain executable JavaScript. The sanitizer in this module strips the well-known attack vectors but is intentionally simpler than a dedicated library. Only enable this module on sites where you control which roles can upload files.', 'bw-dev' ); ?>
			</p>
		</div>

		<table class="form-table" role="presentation">
			<tbody>
				<tr>
					<th scope="row"><?php esc_html_e( 'Allowed uploaders', 'bw-dev' ); ?></th>
					<td>
						<fieldset>
							<label>
								<input type="radio" name="<?php echo esc_attr( $name ); ?>" value="admin_only" <?php checked( $mode, 'admin_only' ); ?> />
								<?php esc_html_e( 'Administrators only', 'bw-dev' ); ?>
								<span class="description">(<?php esc_html_e( 'recommended', 'bw-dev' ); ?>)</span>
							</label>
							<br />
							<label>
								<input type="radio" name="<?php echo esc_attr( $name ); ?>" value="upload_capable" <?php checked( $mode, 'upload_capable' ); ?> />
								<?php esc_html_e( 'Anyone who can upload files (editors, authors, etc.)', 'bw-dev' ); ?>
							</label>
						</fieldset>
						<p class="description">
							<?php esc_html_e( 'When set to "Administrators only", non-admin uploaders see the normal "Sorry, this file type is not permitted" error for .svg files.', 'bw-dev' ); ?>
						</p>
					</td>
				</tr>
			</tbody>
		</table>

		<h3><?php esc_html_e( 'What gets stripped', 'bw-dev' ); ?></h3>
		<ul style="list-style:disc; margin-left:20px;">
			<li><?php esc_html_e( 'Elements: <script>, <foreignObject>, <iframe>, <embed>, <object>, <animate>, <animateMotion>, <animateTransform>, <set>, <handler>, <a>.', 'bw-dev' ); ?></li>
			<li><?php esc_html_e( 'All "on*" attributes (onclick, onload, onerror, …).', 'bw-dev' ); ?></li>
			<li><?php esc_html_e( 'href / xlink:href values starting with javascript: or data:.', 'bw-dev' ); ?></li>
			<li><?php esc_html_e( 'style attributes containing expression() or javascript:.', 'bw-dev' ); ?></li>
			<li><?php esc_html_e( 'XML processing-instructions and external DTDs (XXE).', 'bw-dev' ); ?></li>
		</ul>
		<?php
	}

	public function uninstall(): void {
		// No-op.
	}
}
