PluginProbe ʕ •ᴥ•ʔ
Superb Addons: Blocks, Patterns, Pre-built Pages, Sliders, Popups, Free Forms, Animations & More / 4.2.0
Superb Addons: Blocks, Patterns, Pre-built Pages, Sliders, Popups, Free Forms, Animations & More v4.2.0
4.2.0 4.1.0 4.0.9 4.0.8 4.0.7 4.0.6 4.0.5 4.0.4 4.0.3 4.0.2 4.0.1 4.0.0 trunk 1.0.0 2.0.0 2.0.1 2.0.2 2.0.3 3.0 3.0.1 3.0.2 3.0.5 3.0.6 3.0.7 3.0.8 3.0.9 3.1.0 3.1.2 3.1.3 3.2.0 3.2.1 3.2.2 3.2.4 3.2.5 3.2.7 3.2.8 3.2.9 3.3.0 3.3.1 3.3.2 3.4.0 3.4.1 3.4.2 3.4.5 3.4.6 3.5.0 3.5.1 3.5.2 3.5.3 3.5.4 3.5.6 3.5.7 3.5.8 3.5.9 3.6.0 3.6.1 3.6.2 3.7.0 3.7.1
superb-blocks / src / gutenberg / form / class-form-zip-exporter.php
superb-blocks / src / gutenberg / form Last commit date
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