'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'gif' => 'image/gif', 'webp' => 'image/webp', 'pdf' => 'application/pdf', 'doc' => 'application/msword', 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'txt' => 'text/plain', 'csv' => 'text/csv', ); private LoggerInterface $logger; public function __construct( LoggerInterface $logger ) { $this->logger = $logger; } /** * Move uploaded files into the pending dir under random hex names. * * Each file is MIME-validated against the allowlist. Invalid files * are skipped + logged at warning level. The returned array has * exactly the paths that were actually stored — caller persists * these in OptIn::files. * * Accepts either a flat `[path, path, ...]` array or a nested * `[fieldName => path-or-paths]` map (Avada/CF7 fields can carry * multiple files). * * @param array $files Either flat list of source paths, or * [fieldName => path|paths] map. * * @return array Final paths in pending/. Empty if none stored. */ public function store( array $files ): array { $stored = array(); if ( empty( $files ) ) { return $stored; } $pendingDir = $this->ensureSecureDir(); if ( $pendingDir === '' ) { $this->logger->error( 'Aborting file storage: pending dir not writable', array( 'plugin' => 'double-opt-in', ) ); return $stored; } $allowedMimes = apply_filters( 'f12_cf7_doubleoptin_allowed_mime_types', self::ALLOWED_MIME_TYPES ); foreach ( $files as $entry ) { // Tolerant of nested (fieldName => path|paths) shapes. $paths = is_array( $entry ) ? $entry : array( $entry ); foreach ( $paths as $sourcePath ) { if ( ! is_string( $sourcePath ) || $sourcePath === '' || ! is_file( $sourcePath ) ) { continue; } $movedPath = $this->moveOneFile( $sourcePath, $pendingDir, $allowedMimes ); if ( $movedPath !== null ) { $stored[] = $movedPath; } } } return $stored; } /** * Unlink every path that lives under the pending dir. Paths * outside it are silently rejected — defence-in-depth against * a future caller passing tainted strings into a delete primitive. * * Idempotent: missing files are not an error (could already have * been deleted by hand-off, or removed by an admin manually). * * @param array $paths Paths to unlink. */ public function deletePaths( array $paths ): void { if ( empty( $paths ) ) { return; } $pendingDir = $this->getPendingDirRealPath(); if ( $pendingDir === '' ) { return; // dir doesn't exist yet → nothing to delete } foreach ( $paths as $path ) { if ( ! is_string( $path ) || $path === '' ) { continue; } // Path-traversal defense. realpath() returns the canonical // path with all `..` resolved. If the result doesn't begin // with our pending dir, the path is rejected — no matter // what the caller passed in. $realPath = @realpath( $path ); if ( $realPath === false ) { continue; // file already gone — idempotent } if ( ! str_starts_with( $realPath, $pendingDir ) ) { $this->logger->error( 'Refusing to unlink path outside pending dir', array( 'plugin' => 'double-opt-in', 'path' => $path, 'real_path' => $realPath, 'pending_dir' => $pendingDir, ) ); continue; } if ( @unlink( $realPath ) ) { $this->logger->debug( 'Unlinked file from pending', array( 'plugin' => 'double-opt-in', 'path' => $realPath, ) ); } else { $this->logger->warning( 'Failed to unlink file (will retry on next cleanup)', array( 'plugin' => 'double-opt-in', 'path' => $realPath, ) ); } } } /** * Ensure the pending dir exists and is locked down against direct * web access. Returns the absolute path on success, '' on failure. * * Idempotent — safe to call on every store() invocation. */ public function ensureSecureDir(): string { $uploads = wp_upload_dir(); if ( empty( $uploads['basedir'] ) ) { return ''; } $base = trailingslashit( $uploads['basedir'] ) . self::SUBDIR; $pending = trailingslashit( $base ) . self::PENDING; // Two nested dirs to secure. wp_mkdir_p is recursive + // idempotent — safe to call repeatedly. if ( ! file_exists( $pending ) ) { if ( ! wp_mkdir_p( $pending ) ) { return ''; } } // Drop blockers in BOTH dirs (base + pending). Belt and braces: // shared hosts vary in which file-types the webserver serves. $this->writeBlockers( $base ); $this->writeBlockers( $pending ); // Return canonical path — used by deletePaths() for the // path-traversal check. $real = @realpath( $pending ); return $real === false ? '' : $real; } /** * Single-file move with MIME validation + non-guessable rename. * Returns the new path or null on rejection/failure. * * @param string $source Source path (from $_FILES tmp). * @param string $pendingDir Absolute pending dir path. * @param array $allowedMimes ext => mime allowlist. */ private function moveOneFile( string $source, string $pendingDir, array $allowedMimes ): ?string { $check = wp_check_filetype_and_ext( $source, wp_basename( $source ), $allowedMimes ); if ( empty( $check['type'] ) || empty( $check['ext'] ) ) { $this->logger->warning( 'File rejected: MIME type not on allowlist', array( 'plugin' => 'double-opt-in', 'source' => $source, ) ); return null; } $newName = bin2hex( random_bytes( 16 ) ) . '.' . $check['ext']; $dest = trailingslashit( $pendingDir ) . $newName; // Prefer move (rename) over copy — rename is atomic + faster + // doesn't double-store. Falls back to copy+unlink for cross- // device sources (PHP's tmp dir on a different filesystem // than uploads/ — common in containerised hosts). if ( @rename( $source, $dest ) ) { return $dest; } if ( @copy( $source, $dest ) ) { @unlink( $source ); return $dest; } $this->logger->error( 'Failed to move file into pending dir', array( 'plugin' => 'double-opt-in', 'source' => $source, 'dest' => $dest, ) ); return null; } /** * Write .htaccess + index.php into the given dir. Idempotent. */ private function writeBlockers( string $dir ): void { $htaccess = trailingslashit( $dir ) . '.htaccess'; $index = trailingslashit( $dir ) . 'index.php'; if ( ! file_exists( $htaccess ) ) { @file_put_contents( $htaccess, "# Forge12 DOI — never serve from this dir directly\n" . "\nRequire all denied\n\n" . "\nDeny from all\n\n" ); } if ( ! file_exists( $index ) ) { // Standard WP-style empty PHP file — silent in case the // host's webserver serves index.php for directory requests. @file_put_contents( $index, "