class-form-access-control.php
3 weeks ago
class-form-captcha-handler.php
2 weeks ago
class-form-controller.php
2 days ago
class-form-email-config-check.php
3 weeks ago
class-form-email-handler.php
2 days ago
class-form-encryption.php
3 weeks ago
class-form-exporter.php
2 days ago
class-form-field-validator.php
3 weeks ago
class-form-file-handler.php
2 days ago
class-form-google-auth.php
3 weeks ago
class-form-integration-handler.php
3 weeks ago
class-form-math-parser.php
3 weeks ago
class-form-permissions.php
3 weeks ago
class-form-registry.php
3 weeks ago
class-form-settings.php
3 weeks ago
class-form-submission-cpt.php
3 weeks ago
class-form-submission-handler.php
2 days ago
class-form-zip-exporter.php
2 days ago
class-form-zip-exporter.php
338 lines
| 1 | <?php |
| 2 | |
| 3 | namespace SuperbAddons\Gutenberg\Form; |
| 4 | |
| 5 | defined('ABSPATH') || exit(); |
| 6 | |
| 7 | /** |
| 8 | * Streams form submissions as a ZIP archive: the CSV export at the root plus |
| 9 | * one folder per submission holding its uploaded files under their original |
| 10 | * names, grouped by field label. |
| 11 | * |
| 12 | * The archive uses the ZIP "store" method and is written straight to the |
| 13 | * response. Uploads are photos and videos that do not compress, and storing |
| 14 | * means every file is copied with readfile(): no temp file on disk, nothing |
| 15 | * held in memory, no dependency on the zip extension, and the exact |
| 16 | * Content-Length is known before the first byte is sent. ZIP64 is not |
| 17 | * implemented, so archives are capped below the 4 GiB / 65535-entry limits. |
| 18 | */ |
| 19 | class FormZipExporter |
| 20 | { |
| 21 | /** Cap on archive size. Leaves headroom under 2^32 for the headers. */ |
| 22 | const MAX_TOTAL_BYTES = 4000000000; |
| 23 | /** Cap on entries, CSV included. The classic ZIP limit is 65535. */ |
| 24 | const MAX_ENTRIES = 60000; |
| 25 | const CSV_ENTRY_NAME = 'submissions.csv'; |
| 26 | /** Longest path segment kept in an entry name (ZIP name lengths are 16-bit). */ |
| 27 | const MAX_SEGMENT_BYTES = 200; |
| 28 | |
| 29 | /** |
| 30 | * Work out which files the archive will contain and what they are called. |
| 31 | * |
| 32 | * Every stored path is resolved and confined to the plugin's upload |
| 33 | * directory by FormFileHandler::ConfineUploadPath(); anything outside it, |
| 34 | * missing, or unreadable is counted as missing and skipped. |
| 35 | * |
| 36 | * @param array $collected Result of FormExporter::Collect(). |
| 37 | * @return array { |
| 38 | * 'entries' => list of ['name', 'path', 'size', 'time'], |
| 39 | * 'folders' => submission ID => archive folder name, |
| 40 | * 'file_count' => int, |
| 41 | * 'missing_count' => int, |
| 42 | * 'total_bytes' => int, |
| 43 | * } |
| 44 | */ |
| 45 | public static function Plan($collected) |
| 46 | { |
| 47 | $entries = array(); |
| 48 | $folders = array(); |
| 49 | $file_count = 0; |
| 50 | $missing_count = 0; |
| 51 | $total_bytes = 0; |
| 52 | $field_order = isset($collected['field_order']) ? $collected['field_order'] : array(); |
| 53 | $field_labels = isset($collected['field_labels']) ? $collected['field_labels'] : array(); |
| 54 | |
| 55 | foreach ($collected['submissions'] as $sub) { |
| 56 | $sub_id = isset($sub['id']) ? intval($sub['id']) : 0; |
| 57 | $local_time = self::LocalTimestamp(isset($sub['date']) ? $sub['date'] : ''); |
| 58 | // Sortable date prefix in the site's timezone; the ID keeps two |
| 59 | // submissions from the same minute apart. |
| 60 | $folder = gmdate('Y-m-d_H-i', $local_time) . '_submission-' . $sub_id; |
| 61 | $folders[$sub_id] = $folder; |
| 62 | $used_names = array(); |
| 63 | |
| 64 | $fields = isset($sub['fields']) && is_array($sub['fields']) ? $sub['fields'] : array(); |
| 65 | foreach (self::OrderedFieldIds($fields, $field_order) as $fid) { |
| 66 | $value = $fields[$fid]; |
| 67 | // File fields store a list of file metadata arrays. |
| 68 | if (!is_array($value)) { |
| 69 | continue; |
| 70 | } |
| 71 | $label = isset($field_labels[$fid]) ? $field_labels[$fid] : $fid; |
| 72 | foreach ($value as $file) { |
| 73 | if (!is_array($file) || !isset($file['path'])) { |
| 74 | continue; |
| 75 | } |
| 76 | $real_path = FormFileHandler::ConfineUploadPath($file['path']); |
| 77 | $size = $real_path !== '' ? filesize($real_path) : false; |
| 78 | if ($real_path === '' || $size === false) { |
| 79 | $missing_count++; |
| 80 | continue; |
| 81 | } |
| 82 | $original = isset($file['name']) ? $file['name'] : ''; |
| 83 | $entries[] = array( |
| 84 | 'name' => self::EntryName($folder, $label, $original, $real_path, $used_names), |
| 85 | 'path' => $real_path, |
| 86 | 'size' => $size, |
| 87 | 'time' => $local_time, |
| 88 | ); |
| 89 | $file_count++; |
| 90 | $total_bytes += $size; |
| 91 | } |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | return array( |
| 96 | 'entries' => $entries, |
| 97 | 'folders' => $folders, |
| 98 | 'file_count' => $file_count, |
| 99 | 'missing_count' => $missing_count, |
| 100 | 'total_bytes' => $total_bytes, |
| 101 | ); |
| 102 | } |
| 103 | |
| 104 | /** |
| 105 | * Counts and size for the confirmation step and the size cap. |
| 106 | * |
| 107 | * @param array $plan Result of Plan(). |
| 108 | * @param int $csv_size Byte length of the CSV entry. |
| 109 | * @param int $submission_count |
| 110 | * @return array |
| 111 | */ |
| 112 | public static function Summary($plan, $csv_size, $submission_count) |
| 113 | { |
| 114 | $total = $plan['total_bytes'] + $csv_size; |
| 115 | $too_large = $total > self::MAX_TOTAL_BYTES || (count($plan['entries']) + 1) > self::MAX_ENTRIES; |
| 116 | $message = ''; |
| 117 | if ($too_large) { |
| 118 | $message = sprintf( |
| 119 | /* translators: 1: archive size, 2: size limit */ |
| 120 | __('This download would be %1$s, above the %2$s limit for a single archive. Narrow the selection or date range and try again.', 'superb-blocks'), |
| 121 | size_format($total, 1), |
| 122 | size_format(self::MAX_TOTAL_BYTES) |
| 123 | ); |
| 124 | } |
| 125 | return array( |
| 126 | 'submission_count' => intval($submission_count), |
| 127 | 'file_count' => $plan['file_count'], |
| 128 | 'missing_count' => $plan['missing_count'], |
| 129 | 'total_bytes' => $total, |
| 130 | 'total_human' => size_format($total, 1), |
| 131 | 'too_large' => $too_large, |
| 132 | 'message' => $message, |
| 133 | ); |
| 134 | } |
| 135 | |
| 136 | /** |
| 137 | * Stream the archive and exit. Callers must have checked Summary()['too_large']. |
| 138 | * |
| 139 | * @param string $filename Download filename (already sanitized). |
| 140 | * @param array $entries From Plan(). |
| 141 | * @param string $csv CSV contents for the root entry. |
| 142 | */ |
| 143 | public static function Stream($filename, $entries, $csv) |
| 144 | { |
| 145 | $items = array( |
| 146 | array( |
| 147 | 'name' => self::CSV_ENTRY_NAME, |
| 148 | 'size' => strlen($csv), |
| 149 | 'time' => self::LocalTimestamp(''), |
| 150 | 'data' => $csv, |
| 151 | ), |
| 152 | ); |
| 153 | foreach ($entries as $entry) { |
| 154 | $items[] = $entry; |
| 155 | } |
| 156 | |
| 157 | // Every size is known, so the exact length can be sent: browsers show |
| 158 | // real progress and flag a download that was cut short. Per entry: a |
| 159 | // 30-byte local header and a 46-byte central record, each carrying the |
| 160 | // name, plus the data; then the 22-byte end record. |
| 161 | $content_length = 22; |
| 162 | foreach ($items as $item) { |
| 163 | $content_length += 30 + 46 + 2 * strlen($item['name']) + $item['size']; |
| 164 | } |
| 165 | |
| 166 | while (ob_get_level()) { |
| 167 | ob_end_clean(); |
| 168 | } |
| 169 | // A large export streams every uploaded file through PHP, so the default |
| 170 | // max_execution_time could cut the download short. Before PHP 8 a function |
| 171 | // listed in disable_functions still passes function_exists() and calling it |
| 172 | // emits a warning, which would corrupt the ZIP, hence the ini check. |
| 173 | if (function_exists('set_time_limit') && strpos((string) ini_get('disable_functions'), 'set_time_limit') === false) { |
| 174 | // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- Lifting the limit for a streamed download, see above. |
| 175 | set_time_limit(0); |
| 176 | } |
| 177 | |
| 178 | nocache_headers(); |
| 179 | header('Content-Type: application/zip'); |
| 180 | header('X-Content-Type-Options: nosniff'); |
| 181 | // Same filename hardening as ServeFile(): no quotes or line breaks in the header value. |
| 182 | $safe_filename = str_replace(array('"', "\r", "\n"), '', $filename); |
| 183 | header('Content-Disposition: attachment; filename="' . $safe_filename . '"'); |
| 184 | // With zlib output compression on, PHP recompresses the body and the |
| 185 | // declared length would no longer match what the browser receives. |
| 186 | if (!ini_get('zlib.output_compression')) { |
| 187 | header('Content-Length: ' . $content_length); |
| 188 | } |
| 189 | |
| 190 | $offset = 0; |
| 191 | $central_directory = ''; |
| 192 | foreach ($items as $item) { |
| 193 | $name = $item['name']; |
| 194 | $name_length = strlen($name); |
| 195 | $crc = isset($item['data']) ? hexdec(hash('crc32b', $item['data'])) : hexdec(hash_file('crc32b', $item['path'])); |
| 196 | list($dos_time, $dos_date) = self::DosDateTime($item['time']); |
| 197 | |
| 198 | // Local file header: signature, version needed, flags (bit 11: |
| 199 | // UTF-8 names), method 0 (store), time, date, CRC-32, compressed |
| 200 | // and uncompressed size, name length, extra length. |
| 201 | self::Emit(pack('VvvvvvVVVvv', 0x04034b50, 20, 0x0800, 0, $dos_time, $dos_date, $crc, $item['size'], $item['size'], $name_length, 0) . $name); |
| 202 | |
| 203 | if (isset($item['data'])) { |
| 204 | self::Emit($item['data']); |
| 205 | } else { |
| 206 | // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile |
| 207 | $sent = readfile($item['path']); |
| 208 | if ($sent !== $item['size']) { |
| 209 | // The file changed underneath us. Stop rather than write a |
| 210 | // central directory that no longer matches; the short body |
| 211 | // makes the browser report a failed download. |
| 212 | exit; |
| 213 | } |
| 214 | } |
| 215 | |
| 216 | // Central directory record: signature, version made by, version |
| 217 | // needed, flags, method, time, date, CRC-32, sizes, name length, |
| 218 | // extra length, comment length, disk number, internal and external |
| 219 | // attributes, offset of the local header. |
| 220 | $central_directory .= pack('VvvvvvvVVVvvvvvVV', 0x02014b50, 20, 20, 0x0800, 0, $dos_time, $dos_date, $crc, $item['size'], $item['size'], $name_length, 0, 0, 0, 0, 0, $offset) . $name; |
| 221 | $offset += 30 + $name_length + $item['size']; |
| 222 | } |
| 223 | |
| 224 | self::Emit($central_directory); |
| 225 | // End of central directory: signature, disk numbers, entry counts, |
| 226 | // central directory size and offset, comment length. |
| 227 | self::Emit(pack('VvvvvVVv', 0x06054b50, 0, 0, count($items), count($items), strlen($central_directory), $offset, 0)); |
| 228 | // Plain exit, not wp_die(): the die handler would append HTML after the archive bytes. |
| 229 | exit; |
| 230 | } |
| 231 | |
| 232 | private static function Emit($bytes) |
| 233 | { |
| 234 | // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- raw ZIP bytes; escaping would corrupt the archive. |
| 235 | echo $bytes; |
| 236 | } |
| 237 | |
| 238 | /** |
| 239 | * Field IDs in form order, then any stored IDs the config no longer has. |
| 240 | */ |
| 241 | private static function OrderedFieldIds($fields, $field_order) |
| 242 | { |
| 243 | $ids = array(); |
| 244 | foreach ($field_order as $fid) { |
| 245 | if (array_key_exists($fid, $fields)) { |
| 246 | $ids[] = $fid; |
| 247 | } |
| 248 | } |
| 249 | foreach (array_keys($fields) as $fid) { |
| 250 | if (!in_array($fid, $ids, true)) { |
| 251 | $ids[] = $fid; |
| 252 | } |
| 253 | } |
| 254 | return $ids; |
| 255 | } |
| 256 | |
| 257 | /** |
| 258 | * Archive path for one file: <submission folder>/<field label>/<original name>, |
| 259 | * with " (2)", " (3)", ... appended when a name repeats within the folder. |
| 260 | * |
| 261 | * @param array $used Names already taken in this submission's folder (by reference). |
| 262 | */ |
| 263 | private static function EntryName($folder, $label, $original_name, $real_path, &$used) |
| 264 | { |
| 265 | $label_segment = self::SafeSegment($label, 'files'); |
| 266 | $file_segment = self::SafeSegment($original_name, ''); |
| 267 | if ($file_segment === '') { |
| 268 | $file_segment = self::SafeSegment(wp_basename($real_path), 'file'); |
| 269 | } |
| 270 | $base = $folder . '/' . $label_segment . '/'; |
| 271 | $candidate = $base . $file_segment; |
| 272 | if (isset($used[$candidate])) { |
| 273 | $extension = pathinfo($file_segment, PATHINFO_EXTENSION); |
| 274 | $stem = $extension !== '' ? substr($file_segment, 0, -(strlen($extension) + 1)) : $file_segment; |
| 275 | $n = 2; |
| 276 | do { |
| 277 | $candidate = $base . $stem . ' (' . $n . ')' . ($extension !== '' ? '.' . $extension : ''); |
| 278 | $n++; |
| 279 | } while (isset($used[$candidate])); |
| 280 | } |
| 281 | $used[$candidate] = true; |
| 282 | return $candidate; |
| 283 | } |
| 284 | |
| 285 | /** |
| 286 | * One archive path segment. sanitize_file_name() strips slashes, |
| 287 | * backslashes, control characters and leading/trailing dots, so neither a |
| 288 | * field label nor a submitted filename can climb out of its folder when |
| 289 | * the archive is extracted. |
| 290 | */ |
| 291 | private static function SafeSegment($name, $fallback) |
| 292 | { |
| 293 | $segment = sanitize_file_name(wp_strip_all_tags((string) $name)); |
| 294 | if (strlen($segment) > self::MAX_SEGMENT_BYTES) { |
| 295 | $extension = pathinfo($segment, PATHINFO_EXTENSION); |
| 296 | $keep_extension = $extension !== '' && strlen($extension) <= 16; |
| 297 | $stem = $keep_extension ? substr($segment, 0, -(strlen($extension) + 1)) : $segment; |
| 298 | $segment = mb_substr($stem, 0, 120) . ($keep_extension ? '.' . $extension : ''); |
| 299 | } |
| 300 | return $segment === '' ? $fallback : $segment; |
| 301 | } |
| 302 | |
| 303 | /** |
| 304 | * Wall-clock timestamp in the site's timezone: ZIP timestamps and the |
| 305 | * folder names are local time by convention. Empty input means "now". |
| 306 | */ |
| 307 | private static function LocalTimestamp($gmt_date) |
| 308 | { |
| 309 | $gmt = ''; |
| 310 | if (is_string($gmt_date) && $gmt_date !== '') { |
| 311 | $ts = strtotime($gmt_date); |
| 312 | if ($ts !== false) { |
| 313 | $gmt = gmdate('Y-m-d H:i:s', $ts); |
| 314 | } |
| 315 | } |
| 316 | if ($gmt === '') { |
| 317 | $gmt = gmdate('Y-m-d H:i:s'); |
| 318 | } |
| 319 | $local = strtotime(get_date_from_gmt($gmt, 'Y-m-d H:i:s') . ' UTC'); |
| 320 | return $local !== false ? $local : time(); |
| 321 | } |
| 322 | |
| 323 | /** |
| 324 | * MS-DOS time and date words used by ZIP headers (2-second resolution, |
| 325 | * years counted from 1980). |
| 326 | */ |
| 327 | private static function DosDateTime($timestamp) |
| 328 | { |
| 329 | $year = intval(gmdate('Y', $timestamp)); |
| 330 | if ($year < 1980) { |
| 331 | return array(0, (1 << 5) | 1); |
| 332 | } |
| 333 | $time = (intval(gmdate('G', $timestamp)) << 11) | (intval(gmdate('i', $timestamp)) << 5) | (intval(gmdate('s', $timestamp)) >> 1); |
| 334 | $date = (($year - 1980) << 9) | (intval(gmdate('n', $timestamp)) << 5) | intval(gmdate('j', $timestamp)); |
| 335 | return array($time, $date); |
| 336 | } |
| 337 | } |
| 338 |