referenced_paths(); /* * An unreadable reference set is not an empty one. `referenced_files()` * answers null for a failed query — a lost connection, a renamed table, a * half-applied migration — and continuing from that would read as "nothing * on this site is in use" and delete up to BATCH live attachments per run. */ if ( null === $referenced ) { return 0; } $cutoff = time() - $this->retention(); $deleted = 0; foreach ( $this->files( $base ) as $file ) { if ( $deleted >= self::BATCH ) { break; } // Young files may belong to a submission that is still being written. if ( $file->getMTime() > $cutoff ) { continue; } if ( ! $this->is_orphan( $file, $referenced ) ) { continue; } $path = wp_normalize_path( $file->getPathname() ); /* * Containment rests on the iterator's root, which is one bug away from * being wrong. Asking the manager whether it owns the resolved path * makes that explicit and survives a symlink inside the tree. */ if ( ! UploadManager::owns( $path ) ) { continue; } wp_delete_file( $path ); ++$deleted; } /** * Fires after a cleanup run. * * @since 1.0.0 * * @param int $deleted Number of deleted files. */ do_action( 'formpilot_uploads_cleaned', $deleted ); return $deleted; } /** * Counts the orphaned files without deleting anything. */ public function count_orphans(): int { $base = UploadManager::base_dir(); if ( '' === $base || ! is_dir( $base ) ) { return 0; } $referenced = $this->referenced_paths(); if ( null === $referenced ) { return 0; } $cutoff = time() - $this->retention(); $orphans = 0; foreach ( $this->files( $base ) as $file ) { /* * Capped like the deletion run. This is reached from an admin page, and * an uncapped walk of a directory holding a million files is a request * that never returns. */ if ( $orphans >= self::BATCH ) { break; } if ( $file->getMTime() > $cutoff || ! $this->is_orphan( $file, $referenced ) ) { continue; } ++$orphans; } return $orphans; } /** * Determines whether a file on disk is no longer referenced. * * The name test is the safety net for a moved site. Stored paths are absolute, * so changing `basedir` — a migration, a new `UPLOADS` constant, a different * drive letter's casing — makes every live file's path stop matching while the * files themselves are untouched. Storage names are 24 random characters, so a * basename match is strong evidence the file is the one being referenced, and * treating it as in use costs a stale file where the alternative costs the * whole directory. * * @param SplFileInfo $file File being judged. * @param array{paths: array, names: array, stems: array} $referenced Reference maps. */ private function is_orphan( SplFileInfo $file, array $referenced ): bool { if ( in_array( $file->getFilename(), self::PROTECTED_FILES, true ) ) { return false; } if ( isset( $referenced['paths'][ wp_normalize_path( $file->getPathname() ) ] ) ) { return false; } /* * The sizes WordPress generates for a media library upload - * `formpilot-…-150x150.jpg`, `formpilot-…-scaled.jpg`, an edited `formpilot-…-e1712345678.jpg` - * are recorded nowhere this can read, and were deleted as orphans once the * retention period passed, breaking every thumbnail of the attachment. */ $stem = (string) preg_replace( '/(?:-(?:\d+x\d+|scaled|rotated|e\d{10,}))+$/', '', pathinfo( $file->getFilename(), PATHINFO_FILENAME ) ); if ( isset( $referenced['stems'][ $stem ] ) ) { return false; } return ! isset( $referenced['names'][ $file->getFilename() ] ); } /** * Walks every file in the upload directory. * * @param string $base Absolute directory path. * * @return iterable The files. */ private function files( string $base ): iterable { $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $base, FilesystemIterator::SKIP_DOTS ) ); foreach ( $iterator as $file ) { if ( $file instanceof SplFileInfo && $file->isFile() ) { yield $file; } } } /** * Returns every path that is still referenced, as a lookup map. * * Both sources matter: a file is in use when a submission stores it, and * also when it lives in the media library, where deleting it from disk would * leave a broken attachment behind. * * @return array{paths: array, names: array, stems: array}|null * Reference maps, or null when the set could not be established. */ private function referenced_paths(): ?array { $stored = $this->submissions->referenced_files(); if ( null === $stored ) { return null; } $attached = $this->attached_files(); if ( null === $attached ) { return null; } $paths = array(); $names = array(); $stems = array(); foreach ( array_merge( $stored, $attached ) as $path ) { $normalised = wp_normalize_path( (string) $path ); $paths[ $normalised ] = true; $names[ basename( $normalised ) ] = true; } foreach ( $attached as $path ) { $stems[ pathinfo( (string) $path, PATHINFO_FILENAME ) ] = true; } return array( 'paths' => $paths, 'names' => $names, 'stems' => $stems, ); } /** * Returns the plugin uploads that belong to a media library entry. * * @return array|null Absolute paths, or null when the query failed. */ private function attached_files(): ?array { global $wpdb; $uploads = wp_get_upload_dir(); $basedir = trailingslashit( (string) ( $uploads['basedir'] ?? '' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Maintenance scan over core meta, not cacheable. $rows = $wpdb->get_col( $wpdb->prepare( "SELECT meta_value FROM {$wpdb->postmeta} WHERE meta_key = '_wp_attached_file' AND meta_value LIKE %s", $wpdb->esc_like( UploadManager::SUBDIR . '/' ) . '%' ) ); /* * Same reasoning as `referenced_files()`, and the same test: `get_col()` * always returns an array, so the failure has to be read from `last_error` * or it cannot be read at all. */ if ( '' !== trim( (string) $wpdb->last_error ) || ! is_array( $rows ) ) { return null; } return array_map( static fn( $relative ): string => $basedir . (string) $relative, $rows ); } /** * Returns how long a file is kept before it counts as orphaned. */ private function retention(): int { $hours = max( 1, Settings::number( 'upload_retention' ) ); return $hours * HOUR_IN_SECONDS; } }