Add Archive ability (#5)
Jeremy D

Jeremy D commited on 2023-03-29 17:46:45
Showing 13 changed files, with 1248 additions and 34 deletions.


* Add ability to build archives
Fixes #2

* EOL

* Hide dev tools in mobile menu
... ...
@@ -13,7 +13,7 @@ jobs:
13 13
     strategy:
14 14
       matrix:
15 15
         operating-system: [ ubuntu-latest ]
16
-        php: [ '8.0', '8.1' ]
16
+        php: [ '8.0', '8.1', '8.2' ]
17 17
     name: PHP ${{ matrix.php }} Syntax Check
18 18
     steps:
19 19
       - uses: actions/checkout@master
... ...
@@ -6,7 +6,7 @@
6 6
  * @author SleePy <sleepy @ simplemachines (dot) org>
7 7
  * @copyright 2022
8 8
  * @license 3-Clause BSD https://opensource.org/licenses/BSD-3-Clause
9
- * @version 1.0
9
+ * @version 1.1
10 10
 */
11 11
 class DevTools
12 12
 {
... ...
@@ -42,7 +42,7 @@ class DevTools
42 42
 			$this->{$f} = &$GLOBALS[$f];
43 43
 
44 44
 		$this->loadLanguage(['DevTools', 'Admin']);
45
-		$this->loadSources(['DevToolsPackages', 'DevToolsHooks', 'Subs-Menu']);
45
+		$this->loadSources(['DevToolsPackages', 'DevToolsHooks', 'DevToolsFile', 'Subs-Menu']);
46 46
 	}
47 47
 
48 48
 	/*
... ...
@@ -63,6 +63,9 @@ class DevTools
63 63
 		// Fixes a minor bug where the content isn't sized right.
64 64
 		addInlineCss('
65 65
 			div#devtools_menu .half_content { width: 49%;}
66
+			@media (max-width: 855px) {
67
+				li a#devtools_menu_top {display: none;}
68
+			}
66 69
 		');
67 70
 	}
68 71
 
... ...
@@ -179,6 +182,7 @@ class DevTools
179 182
 			'index' => 'action_index',
180 183
 			'packages' => 'action_packages',
181 184
 			'hooks' => 'action_hooks',
185
+			'files' => 'action_files',
182 186
 		];
183 187
 
184 188
 		$this->{$this->getAreaAction($areas, 'packages')}();
... ...
@@ -221,6 +225,23 @@ class DevTools
221 225
 		$this->setSubTemplate($this->getSubAction($subActions, 'list'));
222 226
 	}
223 227
 
228
+	/*
229
+	 * When the area=files, this chooses the sub action we want to work with.
230
+	*/
231
+	private function action_files(): void
232
+	{
233
+		$subActions = [
234
+			'list' => 'filesIndex',
235
+			'archive' => 'downloadArchive',
236
+		];
237
+
238
+		if (!isset($this->context['instances']['DevToolsFiles']))
239
+			$this->context['instances']['DevToolsFiles'] = new DevToolsFiles;
240
+
241
+		$this->context['instances']['DevToolsFiles']->{$this->getSubAction($subActions, 'list')}();
242
+		$this->setSubTemplate($this->getSubAction($subActions, 'list'));
243
+	}
244
+
224 245
 	/*
225 246
 	 * Loads a sub template.  If we specify the second parameter, we will also load the template file.
226 247
 	 *
... ...
@@ -363,6 +384,10 @@ class DevTools
363 384
 				'text' => 'hooks_title_list',
364 385
 				'url' => $this->scripturl . '?action=devtools;area=hooks',
365 386
 			],
387
+			'files' => [
388
+				'text' => 'files_title_list',
389
+				'url' => $this->scripturl . '?action=devtools;area=files',
390
+			],
366 391
 		];
367 392
 
368 393
 		$this->context['devtools_buttons'][$active]['active'] ?? null;
... ...
@@ -387,7 +412,7 @@ class DevTools
387 412
 	public function loadSources(array $sources): void
388 413
 	{
389 414
 		array_map(function($rs) {
390
-			require_once($GLOBALS['sourcedir'] . '/' . strtr($rs, ['DevTools' => 'DevTools-']) . '.php');
415
+			require_once($GLOBALS['sourcedir'] . '/' . strtr($rs, ['DevTools' => 'DevTools/DevTools-']) . '.php');
391 416
 		}, $sources);
392 417
 	}
393 418
 
... ...
@@ -56,3 +56,9 @@ function template_hooksIndex()
56 56
 {
57 57
 	template_show_list('hooks_list');
58 58
 }
59
+
60
+/* This just calls the template for showing a list on our hooks */
61
+function template_filesIndex()
62
+{
63
+	template_show_list('packages_lists_modification');
64
+}
59 65
\ No newline at end of file
... ...
@@ -0,0 +1,334 @@
1
+<?php
2
+
3
+/**
4
+ * The class for DevTools Hooks.
5
+ * @package DevTools
6
+ * @author SleePy <sleepy @ simplemachines (dot) org>
7
+ * @copyright 2023
8
+ * @license 3-Clause BSD https://opensource.org/licenses/BSD-3-Clause
9
+ * @version 1.1
10
+*/
11
+abstract class DevToolsFileBase
12
+{
13
+	/*
14
+	 * The filename we will use upon downloading.
15
+	*/
16
+	protected string $fileName;
17
+
18
+	/*
19
+	 * The physical path to the download file.
20
+	*/
21
+	protected string $physicalDownloadFile;
22
+
23
+	/*
24
+	 * The directory will be compressing.
25
+	*/
26
+	protected string $directory;
27
+
28
+	/*
29
+	 * Anything we are excluding.
30
+	*/
31
+	protected array $exclusions = [];
32
+
33
+	/*
34
+	 * Our temp working directory.
35
+	*/
36
+	private ?string $temp_dir = null;
37
+
38
+	/*
39
+	 * The data file we are looking for inside packages.
40
+	*/
41
+	protected string $packageInfoName = 'package-info.xml';
42
+
43
+	/*
44
+	 * Sets the file name to use on download.
45
+	 *
46
+	 * @param string $fileName Filename to use.
47
+	 * @return self return our own class
48
+	*/
49
+	public function setFileName(string $fileName): self
50
+	{
51
+		$this->fileName = $fileName;
52
+
53
+		return $this;
54
+	}
55
+
56
+	/*
57
+	 * Sets the directory we will be compressing.
58
+	 *
59
+	 * @param string $directory directory to use.
60
+	 * @return self return our own class
61
+	*/
62
+	public function setDirectory(string $directory): self
63
+	{
64
+		$this->directory = $directory;
65
+
66
+		return $this;
67
+	}
68
+
69
+	/*
70
+	 * Set a single exclusion.
71
+	 *
72
+	 * @param string $exclusion What we will be excluding.  Can use * wildcards
73
+	 * @return self return our own class
74
+	*/
75
+	public function setExclusion(string $exclusion): self
76
+	{
77
+		$this->exclusions[] = $exclusion;
78
+
79
+		return $this;
80
+	}
81
+
82
+	/*
83
+	 * Sets multiple exclusions.
84
+	 *
85
+	 * @param array $exclusions What we will be excluding.  Can use * wildcards
86
+	 * @return self return our own class
87
+	*/
88
+	public function setExclusions(array $exclusions): self
89
+	{
90
+		$this->exclusions += $exclusions;
91
+
92
+		return $this;
93
+	}
94
+
95
+	/*
96
+	 * Retreive the working file name.
97
+	 * This searches for a valid temp directory we can work with.
98
+	 *
99
+	 * @return string the working file name
100
+	*/
101
+	public function GetWorkingFile(): string
102
+	{
103
+		$tempdir = $this->FindWorkingTempDirectory();
104
+		
105
+		return $tempdir . 'DevToolsTempArchive';
106
+	}
107
+
108
+	/*
109
+	 * Find a working temp directory.
110
+	 * Most of this is borrowed from Subs-Admin.php sm_temp_dir.
111
+	 *
112
+	 * @return string A valid temp directory
113
+	*/
114
+	private function FindWorkingTempDirectory(): string
115
+	{
116
+		global $cachedir;
117
+
118
+		// Already did this.
119
+		if (!empty($this->temp_dir))
120
+			return $this->temp_dir;
121
+
122
+		// Temp Directory options order.
123
+		$temp_dir_options = array(
124
+			0 => 'sys_get_temp_dir',
125
+			1 => 'upload_tmp_dir',
126
+			2 => 'session.save_path',
127
+			3 => 'cachedir'
128
+		);
129
+
130
+		// Determine if we should detect a restriction and what restrictions that may be.
131
+		$open_base_dir = ini_get('open_basedir');
132
+		$restriction = !empty($open_base_dir) ? explode(':', $open_base_dir) : false;
133
+
134
+		// Prevent any errors as we search.
135
+		$old_error_reporting = error_reporting(0);
136
+
137
+		// Search for a working temp directory.
138
+		foreach ($temp_dir_options as $id_temp => $temp_option)
139
+		{
140
+			switch ($temp_option) {
141
+				case 'cachedir':
142
+					$possible_temp = rtrim($cachedir, '/');
143
+					break;
144
+
145
+				case 'session.save_path':
146
+					$possible_temp = rtrim(ini_get('session.save_path'), '/');
147
+					break;
148
+
149
+				case 'upload_tmp_dir':
150
+					$possible_temp = rtrim(ini_get('upload_tmp_dir'), '/');
151
+					break;
152
+
153
+				default:
154
+					$possible_temp = sys_get_temp_dir();
155
+					break;
156
+			}
157
+
158
+			// Check if we have a restriction preventing this from working.
159
+			if ($restriction)
160
+			{
161
+				foreach ($restriction as $dir)
162
+				{
163
+					if (strpos($possible_temp, $dir) !== false && is_writable($possible_temp))
164
+					{
165
+						$this->temp_dir = $possible_temp;
166
+						break;
167
+					}
168
+				}
169
+			}
170
+			// No restrictions, but need to check for writable status.
171
+			elseif (is_writable($possible_temp))
172
+			{
173
+				$this->temp_dir = $possible_temp;
174
+				break;
175
+			}
176
+		}
177
+
178
+		// Fall back to sys_get_temp_dir even though it won't work, so we have something.
179
+		if (empty($this->temp_dir))
180
+			$this->temp_dir = sys_get_temp_dir();
181
+
182
+		// Fix the path.
183
+		$this->temp_dir = substr($this->temp_dir, -1) === '/' ? $this->temp_dir : $this->temp_dir . '/';
184
+
185
+		// Put things back.
186
+		error_reporting($old_error_reporting);
187
+
188
+		return $this->temp_dir;
189
+	}
190
+
191
+	/*
192
+	 * Delete the working file.
193
+	 *
194
+	 * @param ?string $file If provided we will delete this file, otherwise we get the working directory file.
195
+	 * @return bool If we can't unlink the file or a error occurs, return false.
196
+	*/
197
+	protected function DeleteWorkingFile(?string $file): bool
198
+	{
199
+		try
200
+		{
201
+			return unlink($file ?? $this->GetWorkingFile());
202
+		}
203
+		catch (Exception $e)
204
+		{
205
+			return false;
206
+		}
207
+	}
208
+
209
+	/*
210
+	 * Actually downloads a file.  At this point we output the binary data and exit.
211
+	 * Parts of this is borrowed from SMF's showAttachment function.
212
+	 *
213
+	 * @calls: $sourcedir/Load.php:isBrowser
214
+	 * @return void Output is generated.
215
+	*/
216
+	public function downloadArchive(): void
217
+	{
218
+		$filesize = filesize($this->physicalDownloadFile);
219
+
220
+		header('pragma: ');
221
+		if (!isBrowser('gecko'))
222
+			header('content-transfer-encoding: binary');
223
+		header('expires: ' . gmdate('D, d M Y H:i:s', time() * 60));
224
+		header('last-modified: ' . gmdate('D, d M Y H:i:s', time()));
225
+		header('accept-ranges: bytes');
226
+		header('connection: close');
227
+		header('content-type: ' . (isBrowser('ie') || isBrowser('opera') ? 'application/octetstream' : 'application/octet-stream'));
228
+		header('content-disposition: attachment; filename="' . $this->fileName . '"');
229
+		header('cache-control: max-age=' . (60) . ', private');
230
+
231
+		header("content-length: " . $filesize);
232
+
233
+		if ($filesize > 4194304)
234
+		{
235
+			// Forcibly end any output buffering going on.
236
+			while (@ob_get_level() > 0)
237
+				@ob_end_clean();
238
+
239
+			header_remove('content-encoding');
240
+
241
+			$fp = fopen($this->GetWorkingFile(), 'rb');
242
+			while (!feof($fp))
243
+			{
244
+				echo fread($fp, 8192);
245
+				flush();
246
+			}
247
+			fclose($fp);
248
+		}
249
+
250
+		// On some of the less-bright hosts, readfile() is disabled.  It's just a faster, more byte safe, version of what's in the if.
251
+		elseif (@readfile($this->physicalDownloadFile) === null)
252
+			echo file_get_contents($this->physicalDownloadFile);
253
+
254
+		$this->cleanupArchives();
255
+		die();
256
+	}
257
+
258
+	/*
259
+	 * Searches our working directory for any additional temp files and delete them.
260
+	 *
261
+	 * @return void Nothing is returned
262
+	*/
263
+	public function cleanupArchives(): void
264
+	{
265
+		$files = scandir($this->FindWorkingTempDirectory());
266
+
267
+		if (!empty($files))
268
+			foreach ($files as $f)
269
+				if (strpos($f, 'DevToolsTempArchive') === 0)
270
+					unlink($this->FindWorkingTempDirectory() . '/' . $f);
271
+	}
272
+}
273
+
274
+/*
275
+ * The interface for the file handler.
276
+*/
277
+interface DevToolsFileInterface
278
+{
279
+	public static function IsSupported(): bool;
280
+
281
+	/*
282
+	 * Sets the file name to use on download.
283
+	 *
284
+	 * @param string $fileName Filename to use.
285
+	 * @return self return our own class
286
+	*/
287
+	public function setFileName(string $fileName);
288
+
289
+	/*
290
+	 * Sets the directory we will be compressing.
291
+	 *
292
+	 * @param string $directory directory to use.
293
+	 * @return self return our own class
294
+	*/
295
+	public function setDirectory(string $directory);
296
+
297
+	/*
298
+	 * Set a single exclusion.
299
+	 *
300
+	 * @param string $exclusion What we will be excluding.  Can use * wildcards
301
+	 * @return self return our own class
302
+	*/
303
+	public function setExclusion(string $exclusion);
304
+
305
+	/*
306
+	 * Sets multiple exclusions.
307
+	 *
308
+	 * @param array $exclusions What we will be excluding.  Can use * wildcards
309
+	 * @return self return our own class
310
+	*/
311
+	public function setExclusions(array $exclusions);
312
+
313
+	/*
314
+	 * Actually generate our archive for downloading.
315
+	 *
316
+	 * @return self return our own class
317
+	*/
318
+	public function generateArchive();
319
+
320
+	/*
321
+	 * Actually downloads a file.  At this point we output the binary data and exit.
322
+	 * Parts of this is borrowed from SMF's showAttachment function.
323
+	 *
324
+	 * @return void Output is generated.
325
+	*/
326
+	public function downloadArchive();
327
+
328
+	/*
329
+	 * Searches our working directory for any additional temp files and delete them.
330
+	 *
331
+	 * @return void Nothing is returned
332
+	*/
333
+	public function cleanupArchives(): void;
334
+}
0 335
\ No newline at end of file
... ...
@@ -0,0 +1,242 @@
1
+<?php
2
+//namespace SMF\DevTools;
3
+
4
+/**
5
+ * The class for DevTools File Phar Base.
6
+ * @package DevTools
7
+ * @author SleePy <sleepy @ simplemachines (dot) org>
8
+ * @copyright 2023
9
+ * @license 3-Clause BSD https://opensource.org/licenses/BSD-3-Clause
10
+ * @version 1.1
11
+*/
12
+class DevToolsFilePharBase Extends DevToolsFileBase
13
+{
14
+	/*
15
+	 * PharData handler.
16
+	*/
17
+	protected $pd;
18
+
19
+	/*
20
+	 * Extension from PharData we will be creating.
21
+	*/
22
+	protected $extension = Phar::ZIP;
23
+
24
+	/*
25
+	 * Compression method we are using.
26
+	*/
27
+	protected $compression = Phar::NONE;
28
+
29
+	/*
30
+	 * Our file we are working with.
31
+	*/
32
+	protected $workingFile = null;
33
+
34
+	/*
35
+	 * Temp directory we are working with.
36
+	*/
37
+	protected $workingDir = null;
38
+
39
+	/*
40
+	 * The extension we are using for temp files.
41
+	*/
42
+	protected $tmpExtension = 'tmp';
43
+
44
+	/*
45
+	 * file Extensions for earch phar extesnion.
46
+	*/
47
+	protected $extensionMap = [
48
+		Phar::PHAR => 'tmp',
49
+		Phar::TAR => 'tar',
50
+		Phar::ZIP => 'zip'
51
+	];
52
+
53
+	/*
54
+	 * Is this file export method supported?
55
+	 * This is currently not used, but exists for future expansion.
56
+	 *
57
+	 * @return bool True if this file export method appears to have all the support needed.
58
+	*/
59
+	public static function IsSupported(): bool
60
+	{
61
+		return class_exists('PharData');
62
+	}
63
+
64
+	/*
65
+	 * Set what extension we are exporting.
66
+	 * This should come in one of 3 options. Phar::PHAR, Phar::TAR, Phar::ZIP
67
+	 *
68
+	 * @param int $extension The extension we are exporting.
69
+	 * @return self return our own class
70
+	*/
71
+	public function setExtension(int $extension): self
72
+	{
73
+		if (in_array($extension, [Phar::PHAR, Phar::TAR, Phar::ZIP]))
74
+			$this->extension = $extension;
75
+
76
+		return $this;
77
+	}
78
+
79
+	/*
80
+	 * Actually generate our archive for downloading.
81
+	 * This logic handles setting up PharData, adding the files, compressing (if needed) and sets the physical download file.
82
+	 *
83
+	 * @return self return our own class
84
+	*/
85
+	public function generateArchive(): self
86
+	{
87
+		// Change to the working directory.
88
+		$this->workingDir = dirname($this->GetWorkingFile());
89
+		chdir($this->workingDir);
90
+
91
+		// Set our working info
92
+		$this->workingFile = basename($this->GetWorkingFile()) . '.' . $this->tmpExtension;
93
+
94
+		// Start our phar file.
95
+		$this->pd = $this->PharData(
96
+			$this->workingFile,
97
+			FilesystemIterator::SKIP_DOTS | FilesystemIterator::UNIX_PATHS,
98
+			null,
99
+			$this->extension
100
+		);
101
+
102
+		// Run a iterator of another iterator with a filter that has a iterator.
103
+		$this->buildFromIterator($this->directory);
104
+
105
+		// No files made it into the archive.
106
+		if ($this->pd->count() === 0)
107
+			throw new \ErrorException($this->dt->txt('attachment_transfer_no_find'), 0, E_ERROR, __FILE__, __LINE__);
108
+
109
+		$this->convertToData();
110
+		$this->physicalDownloadFile = $this->GetWorkingFile() . '.' . $this->getRealExtension();
111
+
112
+		return $this;
113
+	}
114
+
115
+	/*
116
+	 * Wrapper for PharData to handle errors with open_basedir.
117
+	 *
118
+	 * @param mixed ...$args All the standard arguments you can pass to phardata
119
+	 * @return ?PharData A valid PharData object is returned if valid, null is returned otherwise.
120
+	*/
121
+	protected function PharData(...$args): ?PharData
122
+	{
123
+		// Safely build our phar, but handle a safe error with open_basedir restrictions.
124
+		try
125
+		{
126
+			set_error_handler(static function ($severity, $message, $file, $line) {
127
+				throw new \ErrorException($message, 0, $severity, $file, $line);
128
+			});
129
+
130
+			// Start our phar file.
131
+			$this->pd = new PharData(...$args);
132
+			return $this->pd;
133
+		}
134
+		catch (Exception $e)
135
+		{
136
+			if (strpos($e->getMessage(), 'open_basedir') == false)
137
+				throw new \ErrorException($e->getMessage(), 0, $e->getSeverity(), $e->getFile(), $e->getLine());
138
+		}
139
+		finally
140
+		{
141
+			restore_error_handler();
142
+		}
143
+
144
+		return null;
145
+	}
146
+
147
+	/*
148
+	 * Usinga a iterator, we build a list of files we will compress, skipping directories.
149
+	 * This logic does skip empty directories.
150
+	 * This will attempt to exclude files matching a direct name match and wildcards.
151
+	 * Upon a successful match, matches are automatically added to the phardata file.
152
+	 *
153
+	 * @param string $directory Directory we will scan for all matching files.
154
+	 * @return void No data is returned, howerver our PharData object is updated.
155
+	*/
156
+	protected function buildFromIterator(string $directory): void
157
+	{
158
+		$filter = function ($file, $key, $iterator) use ($directory) {
159
+			// Simple is directory or exact matches.
160
+			if ($iterator->hasChildren() && !in_array($file->getFilename(), $this->exclusions))
161
+				return true;
162
+
163
+			// More complex wildcard matches or sub directories. Get a base directory, then run through all excludes to see if any more complex patterns match.
164
+			$workingDirectory = $file->getPath();
165
+			if (0 === strpos($workingDirectory, $directory . DIRECTORY_SEPARATOR))
166
+				$workingDirectory = substr($workingDirectory, strlen($directory . DIRECTORY_SEPARATOR));
167
+			foreach ($this->exclusions as $e)
168
+				if (fnmatch($e, $workingDirectory . DIRECTORY_SEPARATOR . $file->getFilename()))
169
+					return false;
170
+
171
+			// Otherwise, only include this if its a file.
172
+			return $file->isFile();
173
+		};
174
+
175
+		$this->pd->buildFromIterator(
176
+			new RecursiveIteratorIterator(
177
+				new RecursiveCallbackFilterIterator(
178
+					new RecursiveDirectoryIterator(
179
+						$directory,
180
+						RecursiveDirectoryIterator::SKIP_DOTS
181
+					),
182
+					$filter
183
+				)
184
+			),
185
+			$directory
186
+		);
187
+	}
188
+	
189
+	/*
190
+	 * Convert the phar archive to a valid archive file.
191
+	 *
192
+	 * @return ?PharData PharData object is returned if successful, null if a error occurs.
193
+	*/
194
+	protected function convertToData(): ?PharData
195
+	{
196
+		// One more sanity check, Phar doesn't do overwrite
197
+		if (file_exists($this->GetWorkingFile() . '.' . $this->getRealExtension()))
198
+			unlink($this->GetWorkingFile() . '.' . $this->getRealExtension());
199
+
200
+		// Safely build our file, but handle a safe error with open_basedir restrictions.
201
+		try
202
+		{
203
+			set_error_handler(static function ($severity, $message, $file, $line) {
204
+				throw new \ErrorException($message, 0, $severity, $file, $line);
205
+			});
206
+
207
+			return $this->pd->convertToData(
208
+				$this->extension,
209
+				$this->compression,
210
+				$this->getRealExtension()
211
+			);
212
+		}
213
+		catch (BadMethodCallException $e)
214
+		{
215
+				throw new \ErrorException($e->getMessage(), 0, E_ERROR, $e->getFile(), $e->getLine());
216
+		}
217
+		catch (Exception $e)
218
+		{
219
+			if (strpos($e->getMessage(), 'open_basedir') == false)
220
+			{
221
+				$this->cleanupArchives();
222
+				throw new \ErrorException($e->getMessage(), 0, $e->getSeverity(), $e->getFile(), $e->getLine());
223
+			}
224
+		}
225
+		finally
226
+		{
227
+			restore_error_handler();
228
+		}
229
+
230
+		return null;
231
+	}
232
+
233
+	/*
234
+	 * Get the real extension we are wanting.
235
+	 *
236
+	 * @return string Our extension we are using.
237
+	*/
238
+	protected function getRealExtension(): string
239
+	{
240
+		return $this->extensionMap[$this->extension] ?? 'tar';
241
+	}
242
+}
0 243
\ No newline at end of file
... ...
@@ -0,0 +1,18 @@
1
+<?php
2
+
3
+/**
4
+ * The class for DevTools File Phar Tgz.
5
+ * @package DevTools
6
+ * @author SleePy <sleepy @ simplemachines (dot) org>
7
+ * @copyright 2023
8
+ * @license 3-Clause BSD https://opensource.org/licenses/BSD-3-Clause
9
+ * @version 1.1
10
+*/
11
+final class DevToolsFilePharTgz Extends DevToolsFilePharBase implements DevToolsFileInterface
12
+{
13
+	// Set our extension to Tar
14
+	protected $extension = Phar::TAR;
15
+
16
+	// Compress our tar with GZ
17
+	protected $compression = Phar::GZ;
18
+}
0 19
\ No newline at end of file
... ...
@@ -0,0 +1,14 @@
1
+<?php
2
+
3
+/**
4
+ * The class for DevTools File Phar Tgz.
5
+ * @package DevTools
6
+ * @author SleePy <sleepy @ simplemachines (dot) org>
7
+ * @copyright 2023
8
+ * @license 3-Clause BSD https://opensource.org/licenses/BSD-3-Clause
9
+ * @version 1.1
10
+*/
11
+final class DevToolsFilePharZip Extends DevToolsFilePharBase implements DevToolsFileInterface
12
+{
13
+	/* Note, no additional logic is currently needed here. This howerver does setup the final class.*/
14
+}
0 15
\ No newline at end of file
... ...
@@ -0,0 +1,531 @@
1
+<?php
2
+
3
+/**
4
+ * The class for DevTools Hooks.
5
+ * @package DevTools
6
+ * @author SleePy <sleepy @ simplemachines (dot) org>
7
+ * @copyright 2023
8
+ * @license 3-Clause BSD https://opensource.org/licenses/BSD-3-Clause
9
+ * @version 1.1
10
+*/
11
+class DevToolsFiles
12
+{
13
+	/*
14
+	 * Handler for our Developer tools main object.
15
+	*/
16
+	private DevTools $dt;
17
+
18
+	/*
19
+	 * SMF variables we will load into here for easy reference later.
20
+	*/
21
+	private string $scripturl;
22
+	private string $packagesdir;
23
+	private string $boarddir;
24
+	private string $sourcedir;
25
+	private array $context;
26
+	private array $smcFunc;
27
+	private array $modSettings;
28
+	private array $settings;
29
+	/* Sometimes in SMF, this is null, which is unusal for a boolean */
30
+	private ?bool $db_show_debug;
31
+
32
+	/* 
33
+	 * SMF has this both as an array and a bool, no type delcartion.
34
+	*/
35
+	private $package_cache;
36
+
37
+	/*
38
+	 * The data file we are looking for inside packages.
39
+	*/
40
+	private string $packageInfoName = 'package-info.xml';
41
+
42
+	/*
43
+	 * The extensions we support.
44
+	*/
45
+	private array $extensions = ['tgz', 'zip'];
46
+
47
+	/*
48
+	 * The providers we support.
49
+	*/
50
+	private array $providers = ['phar'];
51
+
52
+	/*
53
+	 * Builds the DevTools Packages object.  This also loads a few globals into easy to access properties, some by reference so we can update them
54
+	*/
55
+	public function __construct()
56
+	{
57
+		foreach (['scripturl', 'packagesdir', 'settings', 'boarddir', 'sourcedir', 'db_show_debug'] as $f)
58
+			$this->{$f} = $GLOBALS[$f];
59
+		foreach (['context', 'smcFunc', 'package_cache', 'modSettings'] as $f)
60
+			$this->{$f} = &$GLOBALS[$f];
61
+
62
+		$this->dt = &$this->context['instances']['DevTools'];
63
+		$this->dt->loadSources([
64
+			'DevToolsFile-Base',
65
+			'DevToolsFile-PharBase',
66
+			'DevToolsFile-PharTgz',
67
+			'DevToolsFile-PharZip',
68
+			'Packages',
69
+			'Subs-Package',
70
+			'Subs-List',
71
+			'Class-Package'
72
+		]);
73
+		$this->dt->loadLanguage(['Admin', 'Packages']);
74
+	}
75
+
76
+	/*
77
+	 * Loads the main package listing.
78
+	 *
79
+	 * @calls: $sourcedir/Subs-List.php:createList
80
+	*/
81
+	public function filesIndex(): void
82
+	{
83
+		$this->context['available_packages'] = 0;
84
+		createList($this->context['packages'] = $this->buildPackagesList());
85
+
86
+		// An action was successful.
87
+		if (isset($_REQUEST['success']))
88
+			$this->dt->showSuccessDialog($this->successMsg((string) $_REQUEST['success']));
89
+	}
90
+
91
+	/*
92
+	 * Returns an array that will be passed into SMF's createList logic to build a packages listing.
93
+	*
94
+	 * @calls: $sourcedir/Subs.php:timeformat
95
+	*/
96
+	private function buildPackagesList(): array
97
+	{
98
+		return [
99
+			'id' => 'packages_lists_modification',
100
+			'no_items_label' => $this->dt->txt('no_packages'),
101
+			'get_items' => [
102
+				'function' => [$this, 'listGetPackages'],
103
+				'params' => ['modification'],
104
+			],
105
+			'base_href' => $this->scripturl . '?action=devtools;area=files',
106
+			'default_sort_col' => 'idmodification',
107
+			'columns' => [
108
+				'idmodification' => [
109
+					'header' => [
110
+						'value' => $this->dt->txt('package_id'),
111
+						'style' => 'width: 52px;',
112
+					],
113
+					'data' => [
114
+						'db' => 'sort_id',
115
+					],
116
+					'sort' => [
117
+						'default' => 'sort_id',
118
+						'reverse' => 'sort_id'
119
+					],
120
+				],
121
+				'mod_namemodification' => [
122
+					'header' => [
123
+						'value' => $this->dt->txt('mod_name'),
124
+						'style' => 'width: 25%;',
125
+					],
126
+					'data' => [
127
+						'db' => 'name',
128
+					],
129
+					'sort' => [
130
+						'default' => 'name',
131
+						'reverse' => 'name',
132
+					],
133
+				],
134
+				'versionmodification' => [
135
+					'header' => [
136
+						'value' => $this->dt->txt('mod_version'),
137
+					],
138
+					'data' => [
139
+						'db' => 'version',
140
+					],
141
+					'sort' => [
142
+						'default' => 'version',
143
+						'reverse' => 'version',
144
+					],
145
+				],
146
+				'time_installedmodification' => [
147
+					'header' => [
148
+						'value' => $this->dt->txt('mod_installed_time'),
149
+					],
150
+					'data' => [
151
+						'function' => function($package)
152
+						{
153
+							return !empty($package['time_installed'])
154
+								? timeformat($package['time_installed'])
155
+								: $this->dt->txt('not_applicable');
156
+						},
157
+						'class' => 'smalltext',
158
+					],
159
+					'sort' => [
160
+						'default' => 'time_installed',
161
+						'reverse' => 'time_installed',
162
+					],
163
+				],
164
+				'operationsmodification' => [
165
+					'header' => [
166
+						'value' => '',
167
+					],
168
+					'data' => [
169
+						'function' => [$this, 'listColOperations'],
170
+						'class' => 'righttext',
171
+					],
172
+				],
173
+			],
174
+		];
175
+	}
176
+
177
+	/*
178
+	 * Get a listing of packages from SMF, then run through a filter to remove any compressed files.
179
+	 * This also will exclude our own package.
180
+	 *
181
+	 * @param ...$args all params that will just be passed directly into SMF's native list_getPackages
182
+	 * @See: $sourcedir/Packages.php:list_getPackages
183
+	 * @return array List of filtered packages we can work with.
184
+	*/
185
+	public function listGetPackages(...$args): array
186
+	{
187
+		// Filter out anything with an extension, we don't support working with compressed files.
188
+		// list_getPackages is from SMF in Packages.php
189
+		return array_filter(list_getPackages(...$args), function($p) {
190
+			return $this->isValidPackage($p['filename']) && (!empty($this->modSettings['dt_showAllPackages']) || strpos($p['id'], $this->devToolsPackageID) === false);
191
+		});
192
+	}
193
+
194
+	/*
195
+	 * All possible operations we can perform on a package.
196
+	 * If a package can not be uninstalled, we remove the uninstall/reinstall actions.
197
+	 *
198
+	 * @param array $packagethe package data
199
+	 * @return string The actions we can perform.
200
+	*/
201
+	public function listColOperations(array $package): string
202
+	{
203
+		$actions = [];
204
+
205
+		foreach ($this->providers as $provider)
206
+			foreach ($this->extensions as $ext)
207
+				$actions[$ext . $provider] = '<a href="' . $this->scripturl . '?action=devtools;area=files;sa=archive;package=' . $package['filename'] . ';extension=' . $ext . ';provider=' . $provider . '" class="button floatnone">' . $this->dt->txt('devtools_extension_' . $ext) . '</a>';
208
+
209
+		return implode('', $actions);
210
+	}
211
+
212
+	/*
213
+	 * Download Archive.  Will issue a failure if we can't do any step in this process.
214
+	 * Upon success, this will redirect back to package listing.
215
+	 *
216
+	 * @calls: $sourcedir/Errors.php:fatal_lang_error
217
+	 * @calls: $sourcedir/Errors.php:fatal_error
218
+	 * @calls: $sourcedir/Subs.php:redirectexit
219
+	*/
220
+	public function downloadArchive(): void
221
+	{
222
+		// Ensure the file is valid.
223
+		if (($package = $this->getRequestedPackage()) == '' || !$this->isValidPackage($package))
224
+			fatal_lang_error('package_no_file', false);
225
+		else if (($basedir = $this->getPackageBasedir($package)) == '')
226
+			fatal_lang_error('package_get_error_not_found', false);
227
+
228
+		$infoFile = $this->getPackageInfo($basedir . DIRECTORY_SEPARATOR . $this->packageInfoName);
229
+		if (!is_a($infoFile, 'xmlArray'))
230
+			fatal_lang_error('package_get_error_missing_xml', false);
231
+
232
+		$devtools = $this->findDevTools($infoFile);
233
+
234
+		// If we can't find some data in our package info, just do defaults.
235
+		$packageName = null;
236
+		$exclusions = [];
237
+		if (is_a($devtools, 'xmlArray'))
238
+		{
239
+			$packageName = $this->findPackageName($devtools) ?? null;
240
+			$exclusions = $this->findExclusions($devtools) ?? [];
241
+		}
242
+
243
+		if (empty($packageName))
244
+			$packageName = $this->defaultPackageName($package);
245
+
246
+		// Handle some substitutions.
247
+		$infoVersion = $this->findPackageInfoVersion($infoFile);
248
+		$infoName = $this->findPackageInfoName($infoFile);
249
+		$packageName = strtr($packageName, [
250
+			'{VERSION}' => $infoVersion,
251
+			'{VERSION-}' => str_replace('.', '-', $infoVersion),
252
+			'{VERSION_}' => str_replace('.', '_', $infoVersion),
253
+			'{CUSTOMIZATION-NAME}' => preg_replace('~\s~i', '-', $packageName),
254
+			'{CUSTOMIZATION_NAME}' => preg_replace('~\s~i', '_', $packageName),
255
+			'{CUSTOMIZATION NAME}' => $packageName,
256
+		]);
257
+
258
+		$className = 'DevToolsFile' . mb_convert_case($this->getRequestProvider(), MB_CASE_TITLE, 'UTF-8') . mb_convert_case($this->getRequestedExtension(), MB_CASE_TITLE, 'UTF-8');
259
+		$handler = new $className;
260
+
261
+		//Set our file directory and exclusions.  Also cleanup before we do anything else.
262
+		$handler
263
+			->setFileName($packageName . '.' . $this->getRequestedExtension())
264
+			->setDirectory($this->getPackageBasedir($package))
265
+			->setExclusions($exclusions)
266
+			->cleanupArchives()
267
+		;
268
+
269
+		// Catch any error during generation and just show a standard error.
270
+		try
271
+		{
272
+			$handler->generateArchive();
273
+		}
274
+		catch (Exception $e)
275
+		{
276
+			$handler->cleanupArchives();
277
+			
278
+			if (empty($this->db_show_debug))
279
+				fatal_lang_error('devtools_error_archive_generation', false);
280
+			else
281
+				fatal_error($this->dt->txt('devtools_error_archive_generation') . "<br>" . $e->getMessage() . '<br>' . $e->getFile() . ':' . $e->getLine(), false);
282
+		}
283
+
284
+		$handler->downloadArchive();
285
+	}
286
+
287
+	/*
288
+	 * Get the requested extension, filtering the data in the reuqest for santity checks.
289
+	 *
290
+	 * @return string The provider.
291
+	*/
292
+	private function getRequestProvider(): string
293
+	{
294
+		return isset($_REQUEST['provider']) && in_array($_REQUEST['provider'], $this->providers) ? $_REQUEST['provider'] : $this->providers[0];
295
+	}
296
+
297
+	/*
298
+	 * Get the requested extension, filtering the data in the reuqest for santity checks.
299
+	 *
300
+	 * @return string The extension.
301
+	*/
302
+	private function getRequestedExtension(): string
303
+	{
304
+		return isset($_REQUEST['extension']) && in_array($_REQUEST['extension'], $this->extensions) ? $_REQUEST['extension'] : $this->extensions[0];
305
+	}
306
+
307
+	/*
308
+	 * Get the requested package, filtering the data in the reuqest for santity checks.
309
+	 *
310
+	 * @return string The cleaned package.
311
+	*/
312
+	private function getRequestedPackage(): string
313
+	{
314
+		return (string) preg_replace('~[^a-z0-9\-_\.]+~i', '-', $_REQUEST['package'] ?? '');
315
+	}
316
+	
317
+	/*
318
+	 * Tests whether this package is valid.  Looks for the directory to exist in the packages folder.
319
+	 *
320
+	 * @param string $package A package name.
321
+	 * @return bool True if the directory exists, false otherwise.
322
+	*/
323
+	private function isValidPackage(string $package): bool
324
+	{
325
+		return is_dir($this->packagesdir . DIRECTORY_SEPARATOR . $package);
326
+	}
327
+
328
+	/*
329
+	 * This looks in a package and attempts to get the info file.  SMF only normally supports it in the root directory.
330
+	 * This attempts to do a bit more work to find it.  As such, SMF may not actually install and the sync logic may not work.
331
+	 *
332
+	 * @param string $package The package we are looking at.
333
+	 * @return string The path to the directory inside the package that contains the info file.
334
+	*/
335
+	private function getPackageBasedir(string $package): string
336
+	{
337
+		// Simple, its at the file root
338
+		if (file_exists($this->packagesdir . DIRECTORY_SEPARATOR . $package . DIRECTORY_SEPARATOR . $this->packageInfoName))
339
+			return $this->packagesdir . DIRECTORY_SEPARATOR . $package;
340
+
341
+		$files = new RecursiveIteratorIterator(
342
+			new RecursiveDirectoryIterator(
343
+				$this->packagesdir . DIRECTORY_SEPARATOR . $package,
344
+				RecursiveDirectoryIterator::SKIP_DOTS
345
+			)
346
+		);
347
+
348
+		// Someday we could simplify this?
349
+		foreach ($files as $f)
350
+		{
351
+			if ($f->getFilename() == $this->packageInfoName)
352
+			{
353
+				return dirname($f->getPathName());
354
+				break;
355
+			}
356
+		}
357
+
358
+		return '';
359
+	}
360
+
361
+	/*
362
+	 * This will pass the info file through SMF's xmlArray object and returns a valid xmlArray we will use to parse it.
363
+	 * This uses SMF's xmlArray rather than the built in xml tools in PHP as it is what package manager is using.
364
+	 *
365
+	 * @calls: $sourcedir/Class-Package.php:xmlArray
366
+	 * @param string $packageInfoFile The info we are looking at.
367
+	 * @return xmlArray A valid object of xml data from the info file.
368
+	*/
369
+	private function getPackageInfo(string $packageInfoFile): xmlArray
370
+	{
371
+		return new xmlArray(file_get_contents($packageInfoFile));
372
+	}
373
+
374
+	/*
375
+	 * Finds the valid devtools action for a customization.
376
+	 * Note: This will match <devtools>.
377
+	 *
378
+	 * @calls: $sourcedir/Class-Package.php:xmlArray
379
+	 * @calls: $sourcedir/Sub-Package.php:matchPackageVersion
380
+	 * @param xmlArray $packageXML A valid xmlArray object.
381
+	 * @return xmlArray A valid object of xml data from the info file, limited to the matched install actions.
382
+	*/
383
+	private function findDevTools(xmlArray $packageXML): ?xmlArray
384
+	{
385
+		return $packageXML->path('package-info[0]')->exists('devtools') ? $packageXML->path('package-info[0]')->set('devtools')[0] ?? null : null;
386
+	}
387
+
388
+	/*
389
+	 * Finds the valid package name for download.
390
+	 * Note: This will match <devtools>.
391
+	 *
392
+	 * @calls: $sourcedir/Class-Package.php:xmlArray
393
+	 * @calls: $sourcedir/Sub-Package.php:matchPackageVersion
394
+	 * @param xmlArray $devtoolsXML A valid xmlArray object.
395
+	 * @return xmlArray A valid object of xml data from the info file, limited to the matched install actions.
396
+	*/
397
+	private function findPackageName(xmlArray $devtoolsXML): ?string
398
+	{
399
+		$packageName = $devtoolsXML->fetch('packagename');
400
+
401
+		if (!empty($packageName))			
402
+			return $packageName;
403
+
404
+		return null;
405
+	}
406
+
407
+	private function defaultPackageName(string $package): string
408
+	{
409
+		return filter_var($package, FILTER_SANITIZE_URL);
410
+	}
411
+
412
+	/*
413
+	 * Finds the version.
414
+	 * Note: This will match <version>.
415
+	 *
416
+	 * @calls: $sourcedir/Class-Package.php:xmlArray
417
+	 * @calls: $sourcedir/Sub-Package.php:matchPackageVersion
418
+	 * @param xmlArray $packageXML A valid xmlArray object.
419
+	 * @return string The version we found.
420
+	*/
421
+	private function findPackageInfoVersion(xmlArray $packageXML): string
422
+	{
423
+		return $packageXML->path('package-info[0]')->exists('version') ? $packageXML->path('package-info[0]')->fetch('version') ?? '' : '';
424
+	}
425
+
426
+	/*
427
+	 * Finds the name.
428
+	 * Note: This will match <name>.
429
+	 *
430
+	 * @calls: $sourcedir/Class-Package.php:xmlArray
431
+	 * @calls: $sourcedir/Sub-Package.php:matchPackageVersion
432
+	 * @param xmlArray $packageXML A valid xmlArray object.
433
+	 * @return string The Package Name
434
+	*/
435
+	private function findPackageInfoName(xmlArray $packageXML): string
436
+	{
437
+		return $packageXML->path('package-info[0]')->exists('name') ? $packageXML->path('package-info[0]')->fetch('name') ?? '' : '';
438
+	}
439
+
440
+	/*
441
+	 * Finds the valid exclusions for packaging.
442
+	 * Note: This will match <devtools>.
443
+	 *
444
+	 * @calls: $sourcedir/Class-Package.php:xmlArray
445
+	 * @calls: $sourcedir/Sub-Package.php:matchPackageVersion
446
+	 * @param xmlArray $devtoolsXML A valid xmlArray object.
447
+	 * @return xmlArray A valid object of xml data from the info file, limited to the matched install actions.
448
+	*/
449
+	private function findExclusions(xmlArray $devtoolsXML): array
450
+	{
451
+		$excludes = [];
452
+		
453
+		if ($devtoolsXML->exists('exclusion'))
454
+		{
455
+			$exs = $devtoolsXML->set('exclusion');
456
+			foreach ($exs as $ex)
457
+				$excludes[] = $ex->fetch('');
458
+		}
459
+
460
+		return $excludes;
461
+	}
462
+
463
+	/*
464
+	 * This checks if our success message is valid, if so we can use that text string, otherwise we use a generic message.
465
+	 *
466
+	 * @param string $action The success action we took
467
+	 * @return string The language string we will use on our succcess message.
468
+	*/
469
+	private function successMsg(string $action): string
470
+	{
471
+		return in_array($action, ['package']) ? 'devtools_success_' . $action : 'settings_saved';
472
+	}
473
+
474
+	/*
475
+	 * ParsePath from SMF, but wrap it incase we need to do cleanup.
476
+	 *
477
+	 * @calls: $sourcedir/Subs-Package.php:parse_path
478
+	 * @param string $p The current path.
479
+	 * @return string A parsed parse with a valid directory.
480
+	*/
481
+	private function parsePath(string $p): string
482
+	{
483
+		return parse_path($p);
484
+	}
485
+
486
+	/*
487
+	 * SMF will cache package directory information.  This disables it so we can work with the data without delays.
488
+	*/
489
+	private function disablePackageCache(): void
490
+	{
491
+		$this->package_cache = false;
492
+		$this->modSettings['package_disable_cache'] = true;
493
+	}
494
+
495
+	/*
496
+	 * This is currently unused and a place holder for possible expansion to using the operating systems
497
+	 *	built in zip/tar utilties to comrpess files.
498
+	*/
499
+	private function haveSystemSupport(): bool
500
+	{
501
+		if (isset($_SESSION['devToolsFile-haveSystemSupport']))
502
+			return (bool) $_SESSION['devToolsFile-haveSystemSupport'];
503
+
504
+		$hasSystemSupport = true;
505
+
506
+		// We need shell exec.
507
+		if (!function_exists('exec'))
508
+			$hasSystemSupport = false;
509
+
510
+		$output = null;
511
+		$result_code = null;
512
+		if ($hasSystemSupport)
513
+		{
514
+			exec('command -v zip', $output, $result_code);
515
+			if ($result_code != 0 || empty($output))
516
+				$hasSystemSupport = false;
517
+		}
518
+
519
+		$output = null;
520
+		$result_code = null;
521
+		if ($hasSystemSupport)
522
+		{
523
+			exec('command -v tar', $output, $result_code);
524
+			if ($result_code != 0 || empty($output))
525
+				$hasSystemSupport = false;
526
+		}
527
+
528
+		$_SESSION['devToolsFile-haveSystemSupport'] = $hasSystemSupport;
529
+		return $hasSystemSupport;
530
+	}
531
+}
0 532
\ No newline at end of file
... ...
@@ -4,7 +4,7 @@
4 4
  * The class for DevTools Hooks.
5 5
  * @package DevTools
6 6
  * @author SleePy <sleepy @ simplemachines (dot) org>
7
- * @copyright 2022
7
+ * @copyright 2023
8 8
  * @license 3-Clause BSD https://opensource.org/licenses/BSD-3-Clause
9 9
  * @version 1.0
10 10
 */
... ...
@@ -4,9 +4,9 @@
4 4
  * The class for DevTools Packages.
5 5
  * @package DevTools
6 6
  * @author SleePy <sleepy @ simplemachines (dot) org>
7
- * @copyright 2022
7
+ * @copyright 2023
8 8
  * @license 3-Clause BSD https://opensource.org/licenses/BSD-3-Clause
9
- * @version 1.0
9
+ * @version 1.1
10 10
 */
11 11
 class DevToolsPackages
12 12
 {
... ...
@@ -88,7 +88,7 @@ class DevToolsPackages
88 88
 		else if (($basedir = $this->getPackageBasedir($package)) == '')
89 89
 			fatal_lang_error('package_get_error_not_found', false);
90 90
 
91
-		$infoFile = $this->getPackageInfo($basedir . '/' . $this->packageInfoName);
91
+		$infoFile = $this->getPackageInfo($basedir . DIRECTORY_SEPARATOR . $this->packageInfoName);
92 92
 		if (!is_a($infoFile, 'xmlArray'))
93 93
 			fatal_lang_error('package_get_error_missing_xml', false);
94 94
 
... ...
@@ -120,7 +120,7 @@ class DevToolsPackages
120 120
 		else if (($basedir = $this->getPackageBasedir($package)) == '')
121 121
 			fatal_lang_error('package_get_error_not_found', false);
122 122
 
123
-		$infoFile = $this->getPackageInfo($basedir . '/' . $this->packageInfoName);
123
+		$infoFile = $this->getPackageInfo($basedir . DIRECTORY_SEPARATOR . $this->packageInfoName);
124 124
 		if (!is_a($infoFile, 'xmlArray'))
125 125
 			fatal_lang_error('package_get_error_missing_xml', false);
126 126
 
... ...
@@ -153,7 +153,7 @@ class DevToolsPackages
153 153
 		else if (($basedir = $this->getPackageBasedir($package)) == '')
154 154
 			fatal_lang_error('package_get_error_not_found', false);
155 155
 
156
-		$infoFile = $this->getPackageInfo($basedir . '/' . $this->packageInfoName);
156
+		$infoFile = $this->getPackageInfo($basedir . DIRECTORY_SEPARATOR . $this->packageInfoName);
157 157
 		if (!is_a($infoFile, 'xmlArray'))
158 158
 			fatal_lang_error('package_get_error_missing_xml', false);
159 159
 
... ...
@@ -195,7 +195,7 @@ class DevToolsPackages
195 195
 		else if (($basedir = $this->getPackageBasedir($package)) == '')
196 196
 			fatal_lang_error('package_get_error_not_found', false);
197 197
 
198
-		$infoFile = $this->getPackageInfo($basedir . '/' . $this->packageInfoName);
198
+		$infoFile = $this->getPackageInfo($basedir . DIRECTORY_SEPARATOR . $this->packageInfoName);
199 199
 		if (!is_a($infoFile, 'xmlArray'))
200 200
 			fatal_lang_error('package_get_error_missing_xml', false);
201 201
 
... ...
@@ -223,6 +223,8 @@ class DevToolsPackages
223 223
 
224 224
 	/*
225 225
 	 * Returns an array that will be passed into SMF's createList logic to build a packages listing.
226
+	*
227
+	 * @calls: $sourcedir/Subs.php:timeformat
226 228
 	*/
227 229
 	private function buildPackagesList(): array
228 230
 	{
... ...
@@ -432,7 +434,7 @@ class DevToolsPackages
432 434
 	*/
433 435
 	private function getRequestedPackage(): string
434 436
 	{
435
-		return (string) preg_replace('~[^a-z0-9\-_\.]+~i', '-', $_REQUEST['package']);
437
+		return (string) preg_replace('~[^a-z0-9\-_\.]+~i', '-', $_REQUEST['package'] ?? '');
436 438
 	}
437 439
 	
438 440
 	/*
... ...
@@ -443,7 +445,7 @@ class DevToolsPackages
443 445
 	*/
444 446
 	private function isValidPackage(string $package): bool
445 447
 	{
446
-		return is_dir($this->packagesdir . '/' . $package);
448
+		return is_dir($this->packagesdir . DIRECTORY_SEPARATOR . $package);
447 449
 	}
448 450
 
449 451
 	/*
... ...
@@ -456,12 +458,12 @@ class DevToolsPackages
456 458
 	private function getPackageBasedir(string $package): string
457 459
 	{
458 460
 		// Simple, its at the file root
459
-		if (file_exists($this->packagesdir . '/' . $package . '/' . $this->packageInfoName))
460
-			return $this->packagesdir . '/' . $package;
461
+		if (file_exists($this->packagesdir . DIRECTORY_SEPARATOR . $package . DIRECTORY_SEPARATOR . $this->packageInfoName))
462
+			return $this->packagesdir . DIRECTORY_SEPARATOR . $package;
461 463
 
462 464
 		$files = new RecursiveIteratorIterator(
463 465
 			new RecursiveDirectoryIterator(
464
-				$this->packagesdir . '/' . $package,
466
+				$this->packagesdir . DIRECTORY_SEPARATOR . $package,
465 467
 				RecursiveDirectoryIterator::SKIP_DOTS
466 468
 			)
467 469
 		);
... ...
@@ -567,8 +569,8 @@ class DevToolsPackages
567 569
 				continue;
568 570
 
569 571
 			$hooks[] = [
570
-				'pkg' => $action->exists('@from') ? $this->parsePath($action->fetch('@from')) : $basedir . '/' . $action->fetch('@name'),
571
-				'smf' => $this->parsePath($action->fetch('@destination')) . '/' . basename($action->fetch('@name'))
572
+				'pkg' => $action->exists('@from') ? $this->parsePath($action->fetch('@from')) : $basedir . DIRECTORY_SEPARATOR . $action->fetch('@name'),
573
+				'smf' => $this->parsePath($action->fetch('@destination')) . DIRECTORY_SEPARATOR . basename($action->fetch('@name'))
572 574
 			];
573 575
 		}
574 576
 
... ...
@@ -607,6 +609,8 @@ class DevToolsPackages
607 609
 			// Do a empty file check.
608 610
 			if (!$op['res'] && is_file($op[$dst]) && package_get_contents($op[$src]) == package_get_contents($op[$dst]))
609 611
 				$op['res'] = true;
612
+			elseif (is_dir($op[$src]) && is_dir($op[$dst]))
613
+				$op['res'] = $this->validateDirectoriesAreEqual($op[$src], $op[$dst]);
610 614
 				
611 615
 			return $op;
612 616
 		}, $ops);
... ...
@@ -692,7 +696,7 @@ class DevToolsPackages
692 696
 	private function cleanPath(string $path): string
693 697
 	{
694 698
 		return strtr($path, [
695
-			$this->settings['default_theme_dir'] . '/' . basename($GLOBALS['settings']['default_images_url']) => '$imagesdir',
699
+			$this->settings['default_theme_dir'] . DIRECTORY_SEPARATOR . basename($GLOBALS['settings']['default_images_url']) => '$imagesdir',
696 700
 			$this->settings['default_theme_dir'] . '/languages' => '$languagedir',
697 701
 			$this->settings['default_theme_dir'] => '$themedir',
698 702
 			$this->modSettings['avatar_directory'] => '$avatardir',
... ...
@@ -703,4 +707,39 @@ class DevToolsPackages
703 707
 			$this->boarddir => '$boarddir',
704 708
 		]);
705 709
 	}
710
+
711
+	/*
712
+	 * Compare two directories to see if they appear consistent.
713
+	 * We do this by reading them, finding their sha1_file, json_encode the array and then sha1 that string.
714
+	 * By comparing two directories this way, we should end up with the same sha1 hash.
715
+	 *
716
+	 * @param string $src Source directory to compare.
717
+	 * @param string $dst Destination directory to compare.
718
+	 * @return bool True if they match, false otherwise.
719
+	*/
720
+	private function validateDirectoriesAreEqual(string $src, string $dst): bool
721
+	{
722
+		$srcFiles = $dstFiles = [];
723
+
724
+		// Get our files.
725
+		foreach (['src', 'dst'] as $op)
726
+		{
727
+			$s = new RecursiveIteratorIterator(
728
+				new RecursiveDirectoryIterator(
729
+					$$op,
730
+					RecursiveDirectoryIterator::SKIP_DOTS
731
+				),
732
+			);
733
+
734
+			foreach ($s as $file)
735
+			{
736
+				if ($file->isDir())
737
+					return true;
738
+				$basePath = substr($file->getPathname(), strlen($$op . DIRECTORY_SEPARATOR), null);
739
+				${$op . 'Files'}[$basePath] = sha1_file($file->getPathname());		
740
+			}
741
+		}
742
+
743
+		return sha1(json_encode($srcFiles)) == sha1(json_encode($dstFiles));
744
+	}
706 745
 }
707 746
\ No newline at end of file
... ...
@@ -5,13 +5,10 @@ This gives a popup window for you to work with a package to do actions such as:
5 5
 	- Remove hooks (removes hooks as defined in the packages uninstall action
6 6
 	- Pushes files out as per the packages install action
7 7
 	- Pulls files in as per the packages install action
8
+	- Compress customization into tgz (tar with gzip) and zip
8 9
 
9 10
 This is intended for development purposes, not production uses.
10 11
 This customization is intended to only be used with customizations that do not modify SMF sources (boardmod or xml) and are hook only.
11 12
 To use this, your customization must be in the folder format, not in a compressed archive (.tar.gz or .zip) inside the Packages folder.
12 13
 Extended information on how to use this tool can be found here: https://github.com/jdarwood007/smfmod_devtools/wiki
13 14
 
14
-Want some additional developer tools?
15
-	- Error Log Popup: https://custom.simplemachines.org/index.php?mod=4323
16
-	- Dev Center: https://custom.simplemachines.org/index.php?mod=3481
17
-	- Dev tools category: https://custom.simplemachines.org/index.php?action=mods;id_type=25
18 15
\ No newline at end of file
... ...
@@ -1,5 +1,6 @@
1 1
 <?php
2 2
 $txt['devtools_menu'] = 'Developer Tools';
3
+$txt['files_title_list'] = 'Archives';
3 4
 
4 5
 /* Packages stuff */
5 6
 $txt['devtools_packages_uninstall'] = 'Uninstall Hooks';
... ...
@@ -21,3 +20,7 @@ $txt['devtools_success_toggle'] = 'Succesfully toggled hook';
21 20
 $txt['devtools_success_addhook'] = 'Succesfully added hook';
22 21
 $txt['devtools_success_modifyhook'] = 'Succesfully modified hook';
23 22
 $txt['devtools_success_deletehook'] = 'Succesfully deleted hook';
23
+
24
+$txt['devtools_error_archive_generation'] = 'Unable to generate archive';
25
+$txt['devtools_extension_zip'] = 'ZIP';
26
+$txt['devtools_extension_tgz'] = 'TGZ';
24 27
\ No newline at end of file
... ...
@@ -2,11 +2,19 @@
2 2
 <package-info xmlns="http://www.simplemachines.org/xml/package-info" xmlns:smf="http://www.simplemachines.org/">
3 3
 	<id>sleepy:devtools</id>
4 4
 	<name>Developer Tools</name>
5
-	<version>1.0.3</version>
5
+	<version>1.1</version>
6 6
 	<type>modification</type>
7 7
 
8
-	<upgrade from="1.0.2" for="2.1.*">
9
-		<require-file name="DevTools-Packages.php" destination="$sourcedir" />
8
+	<upgrade from="1.0.*" for="2.1.*">
9
+		<require-file name="DevTools.php" destination="$sourcedir" />
10
+		<require-dir name="DevTools" destination="$sourcedir" />
11
+		<remove-file name="$sourcedir/DevTools-Packages.php" error="skip" />
12
+		<remove-file name="$sourcedir/DevTools-Hooks.php" error="skip" />
13
+
14
+		<require-file name="DevTools.template.php" destination="$themedir" />
15
+		<require-file name="DevTools.js" destination="$themes_dir/default/scripts" />
16
+
17
+		<require-file name="languages/DevTools.english.php" destination="$themes_dir/default/languages" />
10 18
 		<require-file name="languages/DevTools.spanish_es.php" destination="$themes_dir/default/languages" />
11 19
 		<require-file name="languages/DevTools.spanish_latin.php" destination="$themes_dir/default/languages" />
12 20
 		<require-file name="languages/DevTools.russian.php" destination="$themes_dir/default/languages" />
... ...
@@ -15,11 +23,9 @@
15 23
 	<install for="SMF 2.1.*">
16 24
 		<readme>README.txt</readme>
17 25
 		<require-file name="DevTools.php" destination="$sourcedir" />
18
-		<require-file name="DevTools-Packages.php" destination="$sourcedir" />
19
-		<require-file name="DevTools-Hooks.php" destination="$sourcedir" />
26
+		<require-dir name="DevTools" destination="$sourcedir" />
20 27
 
21 28
 		<require-file name="DevTools.template.php" destination="$themedir" />
22
-
23 29
 		<require-file name="DevTools.js" destination="$themes_dir/default/scripts" />
24 30
 
25 31
 		<require-file name="languages/DevTools.english.php" destination="$themes_dir/default/languages" />
... ...
@@ -46,13 +52,10 @@
46 52
 		<remove-file name="$themes_dir/default/languages/DevTools.spanish_latin.php" />
47 53
 		<remove-file name="$themes_dir/default/languages/DevTools.russian.php" />
48 54
 
49
-		<remove-file name="$themes_dir/default/scripts/DevTools.js" />
50
-
51 55
 		<remove-file name="$themedir/DevTools.template.php" />
56
+		<remove-file name="$themes_dir/default/scripts/DevTools.js" />
52 57
 
53 58
 		<remove-file name="$sourcedir/DevTools.php" />
54
-		<remove-file name="$sourcedir/DevTools-Packages.php" />
55
-		<remove-file name="$sourcedir/DevTools-Hooks.php" />
59
+		<remove-dir destination="$sourcedir/DevTools" />
56 60
 	</uninstall>
57
-
58 61
 </package-info>
59 62
\ No newline at end of file
60 63