1: <?php
2: /**
3: * Extension management.
4: */
5: class extensions{
6: //Windows
7: /**
8: * WINDOWS ONLY - Gets an extensions zip url from pecl.php.net.
9: *
10: * @param string $extension The name of the extension on pecl.
11: * @return string|null The url on success or null on failure.
12: */
13: public static function windowsPeclUrl(string $extension):?string{
14: if(!preg_match('/^[a-z0-9_-]{1,64}$/', $extension)){
15: mklog0(3, "Invalid extension name $extension");
16: return false;
17: }
18:
19: if(!extension_loaded('openssl')){
20: mklog0(3, "OpenSSL extension not enabled, this is needed to access pecl.php.net");
21: return false;
22: }
23:
24: $version = trim(@file_get_contents("https://pecl.php.net/rest/r/$extension/stable.txt"));
25: if(!$version){
26: mklog0(3, "Failed to get latest version for $extension");
27: return null;
28: }
29:
30: $build = self::buildInfo();
31: $phpVer = self::phpVersion();
32: $arch = PHP_INT_SIZE === 8 ? 'x64' : 'x86';
33: $filename = "php_$extension-$version-$phpVer-" . $build['ts'] . "-" . $build['vs'] . "-$arch.zip";
34: $url = "https://windows.php.net/downloads/pecl/releases/$extension/$version/$filename";
35:
36: // HEAD request to confirm the file actually exists
37: $headers = @get_headers($url);
38: if(!$headers || !str_contains($headers[0], '200')){
39: mklog0(3, "Could not get compatible version for $extension");
40: return null;
41: }
42:
43: return $url;
44: }
45: /**
46: * WINDOWS ONLY - Downloads and unzips a pecl.php.net extension.
47: *
48: * @param string $extension the pecl extension name.
49: * @param string|null $dest_dir The directory to unzip the extension dll to, if null the current php.ini extension_dir is used.
50: * @return boolean Weather the extension was downloaded and unzipped successfully.
51: */
52: public static function windowsInstallPeclExtension(string $extension, ?string $dest_dir=null):bool{
53: $url = self::windowsPeclUrl($extension);
54: if(!$url){
55: mklog0(2, "No Windows build found for $extension matching your PHP config");
56: return false;
57: }
58:
59: $dest_dir = $dest_dir ?? ini_get('extension_dir');
60: if(!$dest_dir){
61: mklog0(3, 'Could not determine extension directory');
62: return false;
63: }
64:
65: $filename = basename($url);
66: $tmp_zip = sys_get_temp_dir() . DIRECTORY_SEPARATOR . $filename;
67:
68: // download zip to temp
69: mklog0(1, "Downloading $filename");
70: $zip_data = file_get_contents($url);
71: if(!$zip_data){
72: mklog0(3, "Failed to download $filename");
73: return false;
74: }
75: if(!file_put_contents($tmp_zip, $zip_data)){
76: mklog0(3, "Failed to save $filename");
77: return false;
78: }
79:
80: // extract just the dll
81: $zip = new ZipArchive();
82: if($zip->open($tmp_zip) !== true){
83: mklog0(3, "Failed to open zip $tmp_zip");
84: unlink($tmp_zip);
85: return false;
86: }
87:
88: $dll_name = "php_$extension.dll";
89: $dest = rtrim($dest_dir, '\\/') . "/" . $dll_name;
90:
91: if($zip->locateName($dll_name) === false){
92: mklog0(3, "Could not find $dll_name in downloaded zip");
93: $zip->close();
94: unlink($tmp_zip);
95: return false;
96: }
97:
98: if(file_put_contents($dest, $zip->getFromName($dll_name))){
99: mklog0(3, "Failed to save dll from downloaded zip to extension dir");
100: $zip->close();
101: unlink($tmp_zip);
102: return false;
103: }
104:
105: $zip->close();
106: unlink($tmp_zip);
107:
108: mklog0(1, "Installed $dll_name to $dest");
109: return true;
110: }
111: //Linux
112: /**
113: * UBUNTU/LINUX ONLY - Ensures the ondrej/php apt repository is enabled.
114: *
115: * @return boolean Weather the repository is enabled.
116: */
117: public static function linuxEnsurePhpRepo():bool{
118: // Check if the ondrej/php repo is already present
119: exec("grep -rq 'ondrej/php' /etc/apt/sources.list /etc/apt/sources.list.d/ 2>/dev/null", $_, $grepExit);
120: if($grepExit === 0){
121: //grep found the apt repo exists
122: return true;
123: }
124:
125: // Ensure add-apt-repository is available
126: exec("command -v add-apt-repository 2>/dev/null", $_, $cmdExit);
127: if($cmdExit !== 0){
128: mklog0(1, "add-apt-repository command not found. Installing software-properties-common");
129:
130: if(!linuxcmd::updateApt()){
131: mklog0(2, "Failed to run apt update, package versions may be out of date, continuing");
132: }
133:
134: if(linuxcmd::sudo("apt install software-properties-common -y -qq") !== 0) {
135: mklog0(3, "Failed to install software-properties-common");
136: return false;
137: }
138: }
139:
140: // Add the ondrej/php PPA
141: mklog(1, "Adding ondrej/php apt repository");
142: if(linuxcmd::sudo("LC_ALL=C.UTF-8 add-apt-repository ppa:ondrej/php -y") !== 0){
143: mklog0(3, "Failed to add ondrej/php apt repository");
144: return false;
145: }
146:
147: // Update apt after adding the new repo
148: if(!linuxcmd::updateApt(true)){
149: mklog0(3, "Failed to update apt after adding ondrej/php repository");
150: return false;
151: }
152:
153: return true;
154: }
155:
156: //Both
157: /**
158: * Checks weather the extension is loaded, if the extension is not loaded it will try to enable it so it is available after a restart of PHP-CLI.
159: *
160: * @param string $extension The extension name.
161: * @return boolean Weather the extension is enabled.
162: */
163: public static function ensure(string $extension):bool{
164: if(extension_loaded($extension)){
165: return true;
166: }
167:
168: self::setEnabled($extension, true);
169: return false;
170: }
171: /**
172: * Returns extension info, contains a list of builtin extensions and bundled extensions and install names for extensions where their install name is different to the extension name reported.
173: * Access the lists like this: $extensionInfo['builtin'][PHP_OS_FAMILY] or $extensionInfo['bundled'][PHP_OS_FAMILY] or $extensionInfo['installnames'][PHP_OS_FAMILY].
174: * The installnames is not a list, it is an array where the keys are the wanted extension name and the values are the extension to install that gets the original name.
175: * Only contains info for Windows (8.5 ts zip) and Linux (Ubuntu 24.04.4 LTS on php8.5 from ondrej/php), last updated on 2026-05-27.
176: *
177: * @return array The array of information about extensions.
178: */
179: public static function extensionInfo():array{
180: return [
181: 'builtin' => [
182: 'Linux' => [
183: 'core','date','filter','hash','json','lexbor','libxml','openssl','pcntl','pcre','random','reflextion','session','sodium','spl','standard','uri','Zend OPcache','zlib'
184: ],
185: 'Windows' => [
186: 'bcmath','calendar','core','ctype','date','dom','filter','hash','iconv','json','lexbor','libxml','mysqlnd','pcre','pdo','phar','random','readline','reflection','session','simplexml','spl','standard','tokenizer','uri','xml','xmlreader','xmlwriter','Zend OPcache','zlib'
187: ]
188: ],
189: 'bundled' => [
190: 'Linux' => [
191: 'calendar','ctype','exif','ffi','fileinfo','ftp','gettext','iconv','pdo','phar','posix','readline','shmop','sockets','sysvmsg','sysvsem','sysvshm','tokenizer'
192: ],
193: 'Windows' => [
194: 'bz2','com_dotnet','curl','dba','enchant','exif','ffi','fileinfo','ftp','gd','gettext','gmp','intl','ldap','mbstring','mysqli','odbc','openssl','pdo_firebird','pdo_mysql','pdo_odbc','pdo_pgsql','pdo_sqlite','pgsql','shmop','snmp','soap','sockets','sodium','sqlite3','sysvshm','tidy','xsl','zip'
195: ]
196: ],
197: 'installnames' => [
198: 'Linux' => [
199: 'mysqli' => 'mysql',
200: 'pdo_mysql' => 'mysql',
201: 'pdo_odbc' => 'odbc',
202: 'pdo_pgsql' => 'pgsql',
203: 'pdo_sqlite' => 'sqlite3',
204: 'pdo_dblib' => 'sybase',
205: 'dom' => 'xml',
206: 'simplexml' => 'xml',
207: 'xmlreader' => 'xml',
208: 'xmlwriter' => 'xml',
209: 'xsl' => 'xml',
210: ],
211: 'Windows' => []
212: ]
213: ];
214: }
215: /**
216: * Gets the build info for php, thread safety and vs version.
217: *
218: * @return array An array with build info, $buildInfo['ts'] will be "nts" or "ts" and $buildInfo['vs'] will be something like "vs17".
219: */
220: public static function buildInfo():array{
221: ob_start();
222: phpinfo(INFO_GENERAL);
223: $info = ob_get_clean();
224:
225: // "PHP Extension Build => API20250925,NTS,VS17"
226: if(preg_match('/PHP Extension Build\s*=>\s*API\d+,(\w+),(VS\d+)/i', $info, $m)){
227: return [
228: 'ts' => strtolower($m[1]), // "NTS" or "TS"
229: 'vs' => strtolower($m[2]), // "VS17" -> "vs17"
230: ];
231: }
232:
233: mklog0(1, "Using fallback build info");
234:
235: // fallback
236: return [
237: 'ts' => ZEND_THREAD_SAFE ? 'ts' : 'nts',
238: 'vs' => 'vs17',
239: ];
240: }
241: /**
242: * Checks weather some extensions are loaded.
243: *
244: * @param array $extensions A list of extension names.
245: * @return boolean Weather all the extensions are loaded.
246: */
247: public static function areLoaded(array $extensions):bool{
248: $areLoaded = true;
249:
250: foreach($extensions as $extension){
251: if(!extension_loaded($extension)){
252: $areLoaded = false;
253: break;
254: }
255: }
256:
257: return $areLoaded;
258: }
259: /**
260: * Returns the php major and minor version with a dot seperating them.
261: *
262: * @return string returns PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION.
263: */
264: public static function phpVersion():string{
265: return PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION;
266: }
267: /**
268: * Enables or disables an extension, uses phpenmod and phpdismod on ubuntu or edits the php.ini file on windows.
269: *
270: * @param string $extension The extension name.
271: * @param boolean $enabled Weather to enable or disable the extension.
272: * @param boolean $allowDownload Weather to allow downloading if enableing and not already installed.
273: * @return boolean Weather the operation was successful.
274: */
275: public static function setEnabled(string $extension, bool $enabled=true, bool $allowDownload=true):bool{
276: if(!preg_match('/^[a-z0-9_-]{1,64}$/', $extension)){
277: mklog0(2, "Invalid extension name $extension");
278: return false;
279: }
280:
281: if(extension_loaded($extension) && $enabled){
282: mklog0(1, "Extension $extension is already enabled");
283: return true;
284: }
285:
286: if(self::installed($extension)){
287: mklog0(1, "Enabling extension " . $extension . (function_exists('mklog') ? ", you will need to restart PHP-CLI for the extension to become loaded" : ""));
288: if(PHP_OS_FAMILY === 'Linux'){
289: $exit = linuxcmd::sudo(($enabled ? "phpenmod " : "phpdismod ") . $extension);
290: if($exit !== 0){
291: mklog0(3, ($enabled ? "phpenmod " : "phpdismod ") . " command reported error code " . $exit);
292: return false;
293: }
294: return true;
295: }
296: else{
297: return inimgmt::windowsEnableExtension($extension, $enabled);
298: }
299: }
300:
301: if(!$enabled){//extension that is not installed to be disabled
302: return true;
303: }
304:
305: if(!$allowDownload){
306: mklog0(3, "Extension $extension is not installed and allowDownload is false");
307: return false;
308: }
309:
310: mklog0(1, "Installing extension " . $extension);
311: if(!self::install($extension)){
312: mklog0(3, "Failed to install " . $extension);
313: return false;
314: }
315:
316: if(!self::setEnabled($extension, true, false)){
317: mklog0(3, "Failed to enable " . $extension . " after install");
318: return false;
319: }
320:
321: return true;
322: }
323: /**
324: * Installs an extension using platform specific methods.
325: *
326: * @param string $extension The name of the extension.
327: * @return boolean Weather the extension is now installed.
328: */
329: public static function install(string $extension):bool{
330: if(!preg_match('/^[a-z0-9_-]{1,64}$/', $extension)){
331: mklog0(2, "Invalid extension name $extension");
332: return false;
333: }
334:
335: $extensionName = $extension;
336: $nicknames = self::extensionInfo()['installnames'][PHP_OS_FAMILY];
337: if(isset($nicknames[$extension])){
338: $extensionName = $nicknames[$extension];
339: }
340:
341: if(PHP_OS_FAMILY === 'Linux'){
342: if(!self::linuxEnsurePhpRepo()){
343: mklog0(3, "Failed to ensure the ondrej/php apt repository was enabled");
344: return false;
345: }
346:
347: $command = "apt install php" . self::phpVersion() . "-" . $extensionName . " -y";
348: if(linuxcmd::sudo($command) !== 0){
349: mklog0(3, "Failed to run " . $command);
350: return false;
351: }
352: }
353: else{
354: if(!self::windowsInstallPeclExtension($extensionName)){
355: mklog0(3, "Failed to download pecl extension " . $extensionName);
356: return false;
357: }
358: }
359:
360: return true;
361: }
362: /**
363: * Checks weather an extension is in stalled.
364: *
365: * @param string $extension The name of the extension.
366: * @return boolean Weather it is installed.
367: */
368: public static function installed(string $extension):bool{
369: if(!preg_match('/^[a-z0-9_-]{1,64}$/', $extension)){
370: mklog0(2, "Invalid extension name $extension");
371: return false;
372: }
373:
374: $extensionDir = ini_get('extension_dir');
375: if(PHP_OS_FAMILY === 'Linux'){
376: $extensionFile = $extension . ".so";
377: }
378: else{
379: $extensionFile = "php_" . $extension . ".dll";
380: }
381:
382: return file_exists($extensionDir . "/" . $extensionFile);
383: }
384: }
385: /**
386: * php.ini management.
387: */
388: class inimgmt{
389: /**
390: * Gets a list of hard coded php.ini settings when in the cli sapi. Last updated on php 8.5.
391: *
392: * @return array
393: */
394: public static function cliHardCoded():array{
395: return [
396: 'html_errors',
397: 'implicit_flush',
398: 'max_execution_time',
399: 'memory_limit',
400: 'register_argc_argv',
401: 'output_buffering',
402: 'max_input_time',
403: ];
404: }
405: /**
406: * Goes over a list of lines and replaces the first line that starts with a specific string.
407: *
408: * @param array $lines The list of lines.
409: * @param string $starting What the line to replace starts with.
410: * @param string $replacement What to replace the line with.
411: * @return boolean Weather a line was found and replaced.
412: */
413: public static function replaceLineBeginingWith(array &$lines, string $starting, string $replacement):bool{
414: $count = strlen($starting);
415: $somethingHappened = false;
416: foreach($lines as $index => $line){
417: $line = trim($line);
418: if(empty($line)){
419: continue;
420: }
421:
422: if(substr($line,0,$count) === $starting){
423: $lines[$index] = $replacement . "\n";
424: $somethingHappened = true;
425: break;
426: }
427: }
428:
429: return $somethingHappened;
430: }
431: /**
432: * Checks weather some php.ini settings match expected values.
433: *
434: * @param array $expected The expected values, the keys are ini_get names and the values are compared to ini_get return.
435: * @return boolean Weather all the specified settings match.
436: */
437: public static function doesSettingsMatch(array $expected):bool{
438: $allMatch = true;
439: $cliHardCoded = inimgmt::cliHardCoded();
440:
441: foreach($expected as $settingName => $settingValue){
442: if(in_array($settingName, $cliHardCoded)){
443: continue;
444: }
445:
446: $iniSetting = ini_get($settingName);
447:
448: if($iniSetting !== $settingValue){
449: $allMatch = false;
450: break;
451: }
452: }
453:
454: return $allMatch;
455: }
456: /**
457: * Gets the loaded php.ini file, and checks existance.
458: *
459: * @return string|null The path on success or null on failure.
460: */
461: public static function iniPath():?string{
462: $iniPath = php_ini_loaded_file();
463: if(!is_string($iniPath) || !file_exists($iniPath)){
464: return null;
465: }
466: return $iniPath;
467: }
468: /**
469: * Backs up the loaded php.ini.
470: *
471: * @return boolean Weather the backup was successful.
472: */
473: public static function backupIni():bool{
474: $iniPath = self::iniPath();
475: if(!$iniPath){
476: return false;
477: }
478:
479: $backupPath = $iniPath . date('.Y-m-d_H-i-s') . '.bak';
480:
481: return copy($iniPath, $backupPath);
482: }
483: /**
484: * Reads the current php.ini using file() and returns the lines in a list.
485: *
486: * @param boolean $checkWriteable Weather to check if the file is writable.
487: * @return array|null The lines on success or null on failure.
488: */
489: public static function loadIni(bool $checkWriteable=true):?array{
490: $path = self::iniPath();
491: if(!$path){
492: return null;
493: }
494:
495: if($checkWriteable && !is_writable($path)){
496: return null;
497: }
498:
499: $lines = file($path);
500: if(!is_array($lines)){
501: return null;
502: }
503:
504: return $lines;
505: }
506: /**
507: * Sets php.ini settings given a list of lines.
508: *
509: * @param array $lines The list of lines that represents a php.ini.
510: * @param array $settings An array where the keys are setting names and values are setting values, the values will need to be escaped before passing here.
511: * @return boolean Weather all the settings could be set.
512: */
513: public static function setSettingsOnLines(array &$lines, array $settings):bool{
514: $success = true;
515: foreach($settings as $settingName => $settingValue){
516: $replacement = $settingName . " = " . $settingValue;
517: if(!self::replaceLineBeginingWith($lines, $settingName, $replacement)){
518: if(!self::replaceLineBeginingWith($lines, ';' . $settingName, $replacement)){
519: $success = false;
520: }
521: }
522: }
523: return $success;
524: }
525: /**
526: * Should only be used on windows - Edits the loaded php.ini to enable or disable extensions.
527: *
528: * @param string $extension The extension name.
529: * @param boolean $enabled Weather to enable or disable the extension.
530: * @param boolean $backupIni Weather to create a backup of the php.ini before editing.
531: * @return boolean Weather the edit was successful.
532: */
533: public static function windowsEnableExtension(string $extension, bool $enabled, bool $backupIni=true):bool{
534: if(!preg_match('/^[a-z0-9_-]{1,64}$/', $extension)){
535: mklog0(2, "Invalid extension name $extension");
536: return false;
537: }
538:
539: if(extension_loaded($extension) && $enabled){
540: mklog0(1, "Extension $extension is already enabled");
541: return true;
542: }
543:
544: $lines = self::loadIni();
545: if(!$lines){
546: mklog0(2,"Failed to load php.ini");
547: return false;
548: }
549:
550: $extensionLine = "extension=" . $extension;
551: $extensionLineIndex = null;
552: foreach($lines as $index => $line){
553: if(str_contains(str_replace(" ", "", $line), $extensionLine)){
554: $extensionLineIndex = $index;
555: break;
556: }
557: }
558:
559: if($extensionLineIndex === null){
560: $lastExtensionIndex = count($lines)-1;
561: foreach($lines as $index => $line){
562: if(str_contains($line, "extension=")){
563: $lastExtensionIndex = $index;
564: }
565: }
566:
567: array_splice($lines, $lastExtensionIndex, 0, ["\n"]);//Adds empty line for new extension line
568: $extensionLineIndex = $lastExtensionIndex +1;
569: }
570:
571: $lines[$extensionLineIndex] = ($enabled ? "" : ";") . $extensionLine . "\n";
572:
573: return self::saveIni($lines, $backupIni);
574: }
575: /**
576: * Set php.ini settings.
577: *
578: * @param array $settings An array of settings, the keys are setting names and the values are setting values, the values need to be escaped before passing here.
579: * @return boolean Weather the setting changes were successful.
580: */
581: public static function setIniSettings(array $settings):bool{
582: $lines = self::loadIni();
583: if(!$lines){
584: mklog0(3, "Failed to load php.ini");
585: return false;
586: }
587:
588: if(!self::setSettingsOnLines($lines, $settings)){
589: mklog0(3, "Failed to set ini settings");
590: return false;
591: }
592:
593: if(!self::saveIni($lines)){
594: mklog0(3, "Failed to save php.ini");
595: return false;
596: }
597:
598: return true;
599: }
600: /**
601: * Saves lines to the current php.ini.
602: *
603: * @param array $lines The lines to replace the php.ini file with.
604: * @param boolean $backup Weather to create a backup of the php.ini before editing.
605: * @return boolean Weather the operation was successful.
606: */
607: public static function saveIni(array $lines, bool $backup=true):bool{
608: if($backup){
609: if(!self::backupIni()){
610: mklog0(3, "Failed to create backup of php.ini before modification");
611: return false;
612: }
613: }
614:
615: $path = self::iniPath();
616: if(!$path){
617: return false;
618: }
619:
620: return file_put_contents($path, $lines) !== false;
621: }
622: }
623: /**
624: * Finds out if PHP-CLI was started with adminstrative privileges. (Ubuntu + Windows)
625: */
626: class is_admin{
627: private static ?bool $state = null;
628:
629: /**
630: * Checks weather the PHP-CLI process is running as an admin. Only checks on the first call, further calls get a cached result.
631: *
632: * @return boolean Weather the process was run as admin or not.
633: */
634: public static function check():bool{
635: if(!is_bool(self::$state)){
636: mklog(0,"Checking for Administrator privilages");
637:
638: if(PHP_OS_FAMILY === 'Linux'){
639: self::$state = (posix_geteuid() === 0);
640: }
641: else{
642: exec("net session >nul 2>&1", $_, $resultCode);
643: self::$state = ($resultCode === 0);
644: }
645: }
646:
647: return self::$state;
648: }
649: /**
650: * Cleares the check() cache and calls check again then returns its new value.
651: *
652: * @return boolean Weather the process was run as admin or not.
653: */
654: public static function refresh():bool{
655: self::$state = null;
656: return self::check();
657: }
658: }
659: /**
660: * Functions specifically for Ubuntu/Linux.
661: */
662: class linuxcmd{
663: /**
664: * @var integer The timestamp of the last apt update.
665: */
666: private static $lastAptUpdate = 0;
667:
668: /**
669: * Runs a command under sudo.
670: *
671: * @param string $command The command to be passed to sudo.
672: * @return integer The exit code of the command.
673: */
674: public static function sudo(string $command):int{
675: // sudo output bypasses stdout, it goes directly to tty.
676: $process = proc_open('sudo ' . $command, [
677: 0 => STDIN, // sudo can read password from terminal
678: 1 => ['file', '/dev/null', 'w'], // suppress stdout
679: 2 => ['file', '/dev/null', 'w'], // suppress stderr
680: ], $pipes);
681:
682: return proc_close($process); // blocks until done
683: }
684: /**
685: * Checks if an apt package is installed.
686: *
687: * @param string $package The package name.
688: * @return boolean Weather the package is installed.
689: */
690: public static function isAptPackageInstalled(string $package):bool{
691: exec('dpkg-query -W ' . escapeshellarg($package), $output, $exitCode);
692: return $exitCode === 0;
693: }
694: /**
695: * Runs apt update under sudo, has a 1 hour timeout.
696: *
697: * @param boolean $ignoreTimeout Weather to ignore the timeout.
698: * @return boolean Weather the update was a success or an update was done in the last hour and ignoretimeout is false.
699: */
700: public static function updateApt(bool $ignoreTimeout=false):bool{
701: if(time() - self::$lastAptUpdate > 3600 && !$ignoreTimeout){
702: return true;
703: }
704:
705: mklog0(1, "Running apt update");
706:
707: if(self::sudo("apt update") === 0){
708: self::$lastAptUpdate = time();
709: return true;
710: }
711: return false;
712: }
713: }
714: /**
715: * Similar to mklog() but if mklog is not defined it just echoes the message and type.
716: *
717: * @param integer $type The numerical message type, see mklog().
718: * @param string $message The message to display / log if possible.
719: * @return void
720: */
721: function mklog0(int $type, string $message):void{
722: if(function_exists('mklog')){
723: mklog($type, $message);
724: }
725: else{
726: $type = min(max($type,0),3);
727: if($type){
728: echo ["General","Warning","Error"][$type-1] . ": " . $message . "\n";
729: }
730: }
731: }