1<?php
2require_once __DIR__ . '/extensions.php';
3
4/**
5 * Plugins.
6 *
7 * A plugin is a folder under plugins/. It can add public pages, admin modules,
8 * database tables and behaviour, without a single edit to ZnoteX itself - which
9 * is the whole point: someone extending the site should never have to fork it,
10 * and should not lose their work on the next update.
11 *
12 * plugins/<name>/
13 * plugin.json name, version, author, description [required]
14 * plugin.php registers hooks [optional]
15 * pages/<page>.php public page at page.php?plugin=<name>&p=<page>
16 * admin/<mod>.php admin module, listed like the built-in ones
17 * install.sql run once when the plugin is enabled
18 * assets/ anything it needs to serve
19 *
20 * Enabled state lives in znote_config as "plugin:<name>:enabled", so it
21 * survives an update and needs no table of its own.
22 *
23 * See plugins/README.md for the full contract and the hook list.
24 */
25
26define('ZNOTE_PLUGIN_DIR', dirname(__DIR__, 2) . '/plugins');
27
28// ---------------------------------------------------------------------------
29// Hooks
30//
31// A hook is a named point where plugins may run. Three shapes:
32//
33// znote_hook('shop.purchased', $data) - notify, return value ignored
34// $html = znote_hook_collect('page.head') - gather markup from every plugin
35// $n = znote_hook_filter('shop.price', $n) - pass a value through, changed
36//
37// A hook that throws is caught and logged: one broken plugin must not take the
38// site down. That is the difference between an extension point and a landmine.
39// ---------------------------------------------------------------------------
40
41function znote_hook_register(string $hook, callable $callback, int $priority = 10): void {
42 $GLOBALS['znote_hooks'][$hook][] = array('fn' => $callback, 'priority' => $priority);
43}
44
45/** Callbacks for one hook, lowest priority first. */
46function znote_hook_callbacks(string $hook): array {
47 $list = $GLOBALS['znote_hooks'][$hook] ?? array();
48 if (!$list) {
49 return array();
50 }
51
52 usort($list, static fn(array $a, array $b): int => $a['priority'] <=> $b['priority']);
53
54 return array_column($list, 'fn');
55}
56
57/** Fire a hook. Return values are ignored; use it to notify. */
58function znote_hook(string $hook, array $data = array()): void {
59 foreach (znote_hook_callbacks($hook) as $callback) {
60 try {
61 $callback($data);
62 } catch (Throwable $e) {
63 error_log('[ZnoteX plugin] hook ' . $hook . ' failed: ' . $e->getMessage());
64 }
65 }
66}
67
68/** Fire a hook and concatenate what the callbacks return. For markup. */
69function znote_hook_collect(string $hook, array $data = array()): string {
70 $out = '';
71
72 foreach (znote_hook_callbacks($hook) as $callback) {
73 try {
74 $out .= (string)$callback($data);
75 } catch (Throwable $e) {
76 error_log('[ZnoteX plugin] hook ' . $hook . ' failed: ' . $e->getMessage());
77 }
78 }
79
80 return $out;
81}
82
83/**
84 * Pass a value through every callback and return what comes back.
85 *
86 * This is how a plugin changes something rather than merely reacting to it:
87 * a discount on a shop price, a modified welcome message. Each callback
88 * receives the current value and $data, and returns the new value; one that
89 * throws is skipped and the value it was given survives untouched.
90 */
91function znote_hook_filter(string $hook, $value, array $data = array()) {
92 foreach (znote_hook_callbacks($hook) as $callback) {
93 try {
94 $value = $callback($value, $data);
95 } catch (Throwable $e) {
96 error_log('[ZnoteX plugin] filter ' . $hook . ' failed: ' . $e->getMessage());
97 }
98 }
99
100 return $value;
101}
102
103/**
104 * Fire a hook that can veto. Any callback returning false stops the action.
105 * Used where a plugin must be able to say "no" - a purchase, a registration.
106 */
107function znote_hook_allows(string $hook, array $data = array()): bool {
108 foreach (znote_hook_callbacks($hook) as $callback) {
109 try {
110 if ($callback($data) === false) {
111 return false;
112 }
113 } catch (Throwable $e) {
114 error_log('[ZnoteX plugin] hook ' . $hook . ' failed: ' . $e->getMessage());
115 }
116 }
117
118 return true;
119}
120
121// ---------------------------------------------------------------------------
122// Registry
123// ---------------------------------------------------------------------------
124
125function znote_plugin_sanitize(string $name): string {
126 $name = strtolower(trim($name));
127 return preg_match('/^[a-z0-9_-]{1,64}$/', $name) === 1 ? $name : '';
128}
129
130/** Read plugin.json, tolerating a missing or malformed file. */
131function znote_plugin_manifest(string $name): array {
132 $defaults = array(
133 'key' => $name,
134 'name' => ucwords(str_replace(array('-', '_'), ' ', $name)),
135 'version' => '',
136 'author' => '',
137 'description' => '',
138 'url' => '',
139 'requires' => array(),
140 );
141
142 $file = ZNOTE_PLUGIN_DIR . '/' . $name . '/plugin.json';
143 if (!is_file($file)) {
144 return $defaults;
145 }
146
147 $data = json_decode((string)file_get_contents($file), true);
148
149 return is_array($data) ? array_merge($defaults, $data, array('key' => $name)) : $defaults;
150}
151
152/** Every plugin on disk, keyed by folder name. */
153function znote_plugins(bool $refresh = false): array {
154 static $plugins = null;
155 if ($plugins !== null && !$refresh) {
156 return $plugins;
157 }
158
159 $plugins = array();
160
161 foreach (glob(ZNOTE_PLUGIN_DIR . '/*', GLOB_ONLYDIR) ?: array() as $dir) {
162 $name = basename($dir);
163 if (znote_plugin_sanitize($name) === '' || $name[0] === '_') {
164 continue;
165 }
166
167 $manifest = znote_plugin_manifest($name);
168 $manifest['path'] = $dir;
169 $manifest['enabled'] = znote_plugin_enabled($name);
170 $manifest['installed_version'] = znote_plugin_installed_version($name);
171 $manifest['installed'] = ($manifest['installed_version'] !== '');
172 $manifest['update'] = znote_plugin_update_available($name, (string)$manifest['version']);
173 $compatibility = znote_extension_compatibility($manifest);
174 $manifest['compatible'] = $compatibility['compatible'];
175 $manifest['compatibility_errors'] = $compatibility['errors'];
176 $manifest['requirements'] = $compatibility['requires'];
177 $manifest['page_list'] = array_map(
178 static fn(string $f): string => basename($f, '.php'),
179 glob($dir . '/pages/*.php') ?: array()
180 );
181 $manifest['pages'] = count($manifest['page_list']);
182 $manifest['admin'] = count(glob($dir . '/admin/*.php') ?: array());
183 $manifest['sql'] = is_file($dir . '/install.sql');
184
185 $plugins[$name] = $manifest;
186 }
187
188 ksort($plugins);
189
190 return $plugins;
191}
192
193function znote_plugin_enabled(string $name): bool {
194 return function_exists('setting') && setting('plugin:' . $name . ':enabled', '0') === '1';
195}
196
197/**
198 * The version that was installed, or '' if this plugin has never been installed.
199 *
200 * This is what separates "a folder someone uploaded" from "a plugin whose
201 * tables exist". It is the version recorded at install time, not the one in
202 * plugin.json - comparing the two is how an update is noticed.
203 */
204function znote_plugin_installed_version(string $name): string {
205 return function_exists('setting') ? (string)setting('plugin:' . $name . ':version', '') : '';
206}
207
208/** True when the folder holds a newer version than the one installed. */
209function znote_plugin_update_available(string $name, string $folderVersion): bool {
210 $installed = znote_plugin_installed_version($name);
211
212 if ($installed === '' || $folderVersion === '') {
213 return false;
214 }
215
216 return version_compare($folderVersion, $installed, '>');
217}
218
219/**
220 * Install or update a plugin: run its install.sql and record its version.
221 *
222 * The same call does both. install.sql is required to be idempotent, so
223 * re-running it on an update creates whatever tables the new version has grown
224 * and leaves the existing ones alone. Returns '' on success, or the error.
225 */
226function znote_plugin_install(string $name): string {
227 $manifest = znote_plugin_manifest($name);
228 $compatibility = znote_extension_compatibility($manifest);
229 if (!$compatibility['compatible']) {
230 return implode(' ', $compatibility['errors']);
231 }
232
233 $error = znote_plugin_install_sql($name);
234
235 if ($error !== '') {
236 return $error;
237 }
238
239 // Recorded last: a failed install.sql must not leave the plugin looking
240 // installed, or the admin loses the button that would retry it.
241 setting_set('plugin:' . $name . ':version', (string)($manifest['version'] ?: '0'));
242
243 return '';
244}
245
246/** Forget that a plugin was installed. Its tables are deliberately left alone. */
247function znote_plugin_uninstall(string $name): void {
248 znote_plugin_set_enabled($name, false);
249 setting_set('plugin:' . $name . ':version', '');
250}
251
252function znote_plugin_set_enabled(string $name, bool $enabled): bool {
253 if ($enabled) {
254 $compatibility = znote_extension_compatibility(znote_plugin_manifest($name));
255 if (!$compatibility['compatible']) {
256 return false;
257 }
258 }
259
260 return setting_set('plugin:' . $name . ':enabled', $enabled ? '1' : '0');
261}
262
263/**
264 * Run a plugin's install.sql, once.
265 *
266 * Statements must be idempotent - CREATE TABLE IF NOT EXISTS and the like -
267 * because a plugin can be disabled and re-enabled, and we do not track which
268 * statements already ran. A plugin that needs real migrations should ship them
269 * under SQL/ and say so in its description.
270 */
271function znote_plugin_install_sql(string $name): string {
272 $file = ZNOTE_PLUGIN_DIR . '/' . $name . '/install.sql';
273 if (!is_file($file)) {
274 return '';
275 }
276
277 $sql = (string)file_get_contents($file);
278 $sql = preg_replace('/^\xEF\xBB\xBF/', '', $sql); // a leading UTF-8 BOM breaks the first statement
279 $sql = preg_replace('/^--.*$/m', '', $sql);
280 $failed = array();
281
282 foreach (array_filter(array_map('trim', explode(';', $sql))) as $statement) {
283 if ($statement === '') {
284 continue;
285 }
286 if (!db()->rawExecute($statement)) {
287 $dbError = trim((string)db()->connection()->error);
288 $failed[] = $statement . ($dbError !== '' ? ' -- ' . $dbError : '');
289 }
290 }
291
292 return $failed ? count($failed) . ' statement(s) failed, first: ' . $failed[0] : '';
293}
294
295// ---------------------------------------------------------------------------
296// Loading
297// ---------------------------------------------------------------------------
298
299/**
300 * Load every enabled plugin's plugin.php.
301 *
302 * Called from engine/init.php once the database and settings are up, because a
303 * plugin may want either. A plugin that throws on load is skipped and logged
304 * rather than allowed to break the request.
305 */
306function znote_plugins_load(): void {
307 foreach (znote_plugins() as $name => $plugin) {
308 // Enabled is not enough: a plugin that was never installed has no
309 // tables, and loading it would only produce SQL errors on every page.
310 if (!$plugin['enabled'] || !$plugin['installed'] || !$plugin['compatible']) {
311 continue;
312 }
313
314 $file = $plugin['path'] . '/plugin.php';
315 if (!is_file($file)) {
316 continue;
317 }
318
319 try {
320 require_once $file;
321 } catch (Throwable $e) {
322 error_log('[ZnoteX plugin] ' . $name . ' failed to load: ' . $e->getMessage());
323 }
324 }
325
326 znote_hook('plugins.loaded');
327}
328
329/** Installed and enabled. What every entry point actually checks. */
330function znote_plugin_active(string $name): bool {
331 if (!znote_plugin_enabled($name) || znote_plugin_installed_version($name) === '') {
332 return false;
333 }
334
335 return znote_extension_compatibility(znote_plugin_manifest($name))['compatible'];
336}
337
338/** Admin modules contributed by active plugins, as key => file path. */
339function znote_plugin_admin_modules(): array {
340 $modules = array();
341
342 foreach (znote_plugins() as $name => $plugin) {
343 if (!$plugin['enabled'] || !$plugin['installed'] || !$plugin['compatible']) {
344 continue;
345 }
346
347 foreach (glob($plugin['path'] . '/admin/*.php') ?: array() as $file) {
348 $module = basename($file, '.php');
349 if ($module === '' || $module[0] === '_') {
350 continue;
351 }
352 // Namespaced so a plugin cannot shadow a built-in module.
353 $modules[$name . '__' . $module] = $file;
354 }
355 }
356
357 return $modules;
358}
359
360/** Resolve a plugin page, or null. */
361function znote_plugin_page(string $plugin, string $page): ?string {
362 $plugin = znote_plugin_sanitize($plugin);
363 $page = znote_plugin_sanitize($page);
364
365 if ($plugin === '' || $page === '' || !znote_plugin_active($plugin)) {
366 return null;
367 }
368
369 $file = ZNOTE_PLUGIN_DIR . '/' . $plugin . '/pages/' . $page . '.php';
370
371 return is_file($file) ? $file : null;
372}
373
374/** URL of a plugin's public page. */
375function znote_plugin_url(string $plugin, string $page): string {
376 return 'page.php?' . http_build_query(array('plugin' => $plugin, 'p' => $page));
377}
378
379/** URL of a file in a plugin's assets/ folder. */
380function znote_plugin_asset(string $plugin, string $file): string {
381 $plugin = znote_plugin_sanitize($plugin);
382 $file = znote_extension_relative_path($file);
383 return $plugin !== '' && $file !== '' ? 'plugins/' . $plugin . '/assets/' . znote_extension_url_path($file) : '';
384}
385
386function plugin_repository_config(): array {
387 global $config;
388 $cfg = $config['plugin_repository'] ?? array();
389
390 return array(
391 'enabled' => !empty($cfg['enabled']),
392 'index' => trim((string)($cfg['index'] ?? '')),
393 'allowed_hosts' => array_map('strtolower', (array)($cfg['allowed_hosts'] ?? array())),
394 'cache_time' => max(60, (int)($cfg['cache_time'] ?? 3600)),
395 'max_size' => max(1, (int)($cfg['max_size_mb'] ?? 64)) * 1024 * 1024,
396 );
397}
398
399function plugin_repository_cache_path(): string {
400 return 'engine/cache/plugin_repository' . Cache::EXT;
401}
402
403function plugin_repository_clear_cache(): bool {
404 $file = plugin_repository_cache_path();
405
406 if (!is_file($file)) {
407 return true;
408 }
409
410 return @unlink($file);
411}
412
413function plugin_repository_url_allowed(string $url): bool {
414 $cfg = plugin_repository_config();
415 $parts = parse_url($url);
416
417 if (!is_array($parts) || ($parts['scheme'] ?? '') !== 'https' || empty($parts['host'])) {
418 return false;
419 }
420
421 return in_array(strtolower($parts['host']), $cfg['allowed_hosts'], true);
422}
423
424function plugin_repository_get(string $url, ?string $toFile = null, ?string &$error = null) {
425 $cfg = plugin_repository_config();
426
427 if (!plugin_repository_url_allowed($url)) {
428 $error = 'Refused: the URL must be https and its host must be listed in $config[\'plugin_repository\'][\'allowed_hosts\'].';
429 return false;
430 }
431 if (!function_exists('curl_init')) {
432 $error = 'The curl extension is not loaded.';
433 return false;
434 }
435
436 $ch = curl_init($url);
437 curl_setopt($ch, CURLOPT_RETURNTRANSFER, $toFile === null);
438 curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
439 curl_setopt($ch, CURLOPT_MAXREDIRS, 3);
440 curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 8);
441 curl_setopt($ch, CURLOPT_TIMEOUT, 120);
442 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
443 curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
444 curl_setopt($ch, CURLOPT_USERAGENT, 'ZnoteX/' . ($GLOBALS['version'] ?? '2.0.1'));
445
446 $ca = function_exists('znote_cainfo') ? znote_cainfo() : '';
447 if ($ca !== '') {
448 curl_setopt($ch, CURLOPT_CAINFO, $ca);
449 }
450
451 $handle = null;
452 if ($toFile !== null) {
453 $handle = @fopen($toFile, 'wb');
454 if ($handle === false) {
455 $error = 'Cannot write to ' . $toFile;
456 curl_close($ch);
457 return false;
458 }
459 curl_setopt($ch, CURLOPT_FILE, $handle);
460 curl_setopt($ch, CURLOPT_NOPROGRESS, false);
461 curl_setopt($ch, CURLOPT_PROGRESSFUNCTION, function ($res, $dlTotal, $dlNow) use ($cfg) {
462 return ($dlNow > $cfg['max_size'] || $dlTotal > $cfg['max_size']) ? 1 : 0;
463 });
464 }
465
466 $body = curl_exec($ch);
467 $status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
468 $errNo = curl_errno($ch);
469 $errStr = curl_error($ch);
470 curl_close($ch);
471
472 if ($handle !== null) {
473 fclose($handle);
474 }
475
476 if ($errNo !== 0) {
477 $error = ($errNo === 42 || $errNo === 23)
478 ? 'Download aborted: the file is larger than the configured limit.'
479 : 'Download failed (curl ' . $errNo . '): ' . $errStr;
480 return false;
481 }
482 if ($status < 200 || $status >= 300) {
483 $error = 'The server answered HTTP ' . $status . '.';
484 return false;
485 }
486
487 return $toFile === null ? $body : true;
488}
489
490function plugin_repository_notes($value): string {
491 return function_exists('theme_repository_notes')
492 ? theme_repository_notes($value)
493 : (is_string($value) || is_numeric($value) ? trim((string)$value) : '');
494}
495
496/** The on-disk cache, if it's still fresh (or $refresh forces past it). */
497function plugin_repository_cached(Cache $cache, bool $refresh): ?array {
498 if ($refresh || $cache->hasExpired()) {
499 return null;
500 }
501 $cached = $cache->load();
502 return is_array($cached) ? $cached : null;
503}
504
505/**
506 * Fetches and JSON-decodes the catalogue index. Accepts both a bare array
507 * and {"plugins": [...]}. Returns null (with $error set) on any failure.
508 */
509function plugin_repository_fetch_raw(string $indexUrl, bool $refresh, ?string &$error): ?array {
510 if ($refresh) {
511 $indexUrl .= (strpos($indexUrl, '?') === false ? '?' : '&') . 'nocache=' . time();
512 }
513
514 $body = plugin_repository_get($indexUrl, null, $error);
515 if ($body === false) {
516 return null;
517 }
518
519 $data = json_decode((string)$body, true);
520 if (!is_array($data)) {
521 $error = 'The catalogue is not valid JSON: ' . json_last_error_msg() . ' | Response: ' . substr((string)$body, 0, 200);
522 return null;
523 }
524
525 if (isset($data['plugins']) && is_array($data['plugins'])) {
526 $data = $data['plugins'];
527 }
528
529 return $data;
530}
531
532/** Raw catalogue entries, validated and reshaped into the plugin-list format the rest of the admin panel expects. */
533function plugin_repository_normalize_entries(array $data): array {
534 $plugins = array();
535
536 foreach ($data as $entry) {
537 if (!is_array($entry)) {
538 continue;
539 }
540 $key = znote_plugin_sanitize((string)($entry['key'] ?? ''));
541 if ($key === '') {
542 continue;
543 }
544
545 $download = trim((string)($entry['download'] ?? ''));
546 $screenshot = trim((string)($entry['screenshot'] ?? ''));
547 $changelog = '';
548 foreach (array('changelog', 'changes', 'release_notes', 'update') as $notesKey) {
549 if (array_key_exists($notesKey, $entry)) {
550 $changelog = plugin_repository_notes($entry[$notesKey]);
551 break;
552 }
553 }
554
555 $plugins[$key] = array(
556 'key' => $key,
557 'name' => (string)($entry['name'] ?? ucfirst($key)),
558 'author' => (string)($entry['author'] ?? ''),
559 'version' => (string)($entry['version'] ?? ''),
560 'requires' => $entry['requires'] ?? array(),
561 'description' => (string)($entry['description'] ?? ''),
562 'changelog' => $changelog,
563 'url' => (string)($entry['url'] ?? ''),
564 'screenshot' => plugin_repository_url_allowed($screenshot) ? $screenshot : '',
565 'download' => $download,
566 'installable' => plugin_repository_url_allowed($download),
567 );
568 }
569
570 ksort($plugins);
571
572 return $plugins;
573}
574
575function plugin_repository_list(bool $refresh = false): array {
576 $cfg = plugin_repository_config();
577
578 if (!$cfg['enabled'] || $cfg['index'] === '') {
579 return array('plugins' => array(), 'error' => '');
580 }
581
582 $cache = new Cache('engine/cache/plugin_repository');
583 $cache->useMemory(false);
584
585 $cached = plugin_repository_cached($cache, $refresh);
586 if ($cached !== null) {
587 return array('plugins' => $cached, 'error' => '', 'cached' => true);
588 }
589
590 $error = null;
591 $data = plugin_repository_fetch_raw($cfg['index'], $refresh, $error);
592 if ($data === null) {
593 return array('plugins' => array(), 'error' => (string)$error);
594 }
595
596 $plugins = plugin_repository_normalize_entries($data);
597
598 $cache->setContent($plugins);
599 $cache->save();
600
601 return array('plugins' => $plugins, 'error' => '');
602}
603
604function plugin_repository_install(string $key, bool $overwrite = false): string {
605 $key = znote_plugin_sanitize($key);
606 if ($key === '') {
607 return 'Invalid plugin name.';
608 }
609
610 $catalogue = plugin_repository_list();
611 if (!isset($catalogue['plugins'][$key])) {
612 return 'That plugin is not in the catalogue.';
613 }
614
615 $entry = $catalogue['plugins'][$key];
616 if (!$entry['installable']) {
617 return 'Its download URL is not https, or its host is not on the allow list.';
618 }
619
620 $target = ZNOTE_PLUGIN_DIR . '/' . $key;
621 if (is_dir($target) && !$overwrite) {
622 return 'already-installed';
623 }
624 if (!is_writable(ZNOTE_PLUGIN_DIR)) {
625 return 'The plugins/ directory is not writable by PHP.';
626 }
627
628 $tmp = ZNOTE_PLUGIN_DIR . '/.' . $key . '.download.zip';
629 $err = null;
630 if (plugin_repository_get($entry['download'], $tmp, $err) === false) {
631 @unlink($tmp);
632 return (string)$err;
633 }
634
635 $result = plugin_archive_install($key, $tmp, $overwrite);
636 @unlink($tmp);
637
638 return $result;
639}
640
641function plugin_archive_install(string $key, string $zipPath, bool $overwrite = false): string {
642 $key = znote_plugin_sanitize($key);
643 if ($key === '') {
644 return 'Invalid plugin name.';
645 }
646 $target = ZNOTE_PLUGIN_DIR . '/' . $key;
647 if (is_dir($target) && !$overwrite) {
648 return 'already-installed';
649 }
650 if (!function_exists('theme_archive_open')) {
651 return 'Archive support is unavailable (engine/function/theme.php not loaded).';
652 }
653
654 $archive = theme_archive_open($zipPath);
655 if (is_string($archive)) {
656 return $archive;
657 }
658
659 $files = array();
660 $prefix = null;
661
662 foreach ($archive['names'] as $name) {
663 if ($name === '') {
664 continue;
665 }
666 if ($name[0] === '/' || strpos($name, '../') !== false || strpos($name, ':') !== false) {
667 $archive['close']();
668 return 'Refused: the archive contains a path that would write outside plugins/ (' . $name . ').';
669 }
670
671 $files[] = $name;
672
673 $top = explode('/', $name)[0];
674 if ($prefix === null) {
675 $prefix = $top;
676 } elseif ($prefix !== $top) {
677 $prefix = '';
678 }
679 }
680
681 if (!$files) {
682 $archive['close']();
683 return 'The archive is empty.';
684 }
685
686 $strip = ($prefix !== null && $prefix !== '') ? strlen($prefix) + 1 : 0;
687
688 $hasManifest = false;
689 foreach ($files as $name) {
690 if (substr($name, $strip) === 'plugin.json') {
691 $hasManifest = true;
692 break;
693 }
694 }
695 if (!$hasManifest) {
696 $archive['close']();
697 return 'Refused: no plugin.json in the archive, so this is not a usable plugin.';
698 }
699
700 $staging = ZNOTE_PLUGIN_DIR . '/.' . $key . '.staging';
701 znote_rrmdir($staging);
702 if (!@mkdir($staging, 0775, true)) {
703 $archive['close']();
704 return 'Could not create a staging directory inside plugins/.';
705 }
706
707 foreach ($files as $name) {
708 $relative = substr($name, $strip);
709 if ($relative === '' || $relative === false) {
710 continue;
711 }
712
713 $dest = $staging . '/' . $relative;
714
715 if (substr($name, -1) === '/') {
716 @mkdir($dest, 0775, true);
717 continue;
718 }
719
720 $dir = dirname($dest);
721 if (!is_dir($dir) && !@mkdir($dir, 0775, true)) {
722 continue;
723 }
724
725 $stream = $archive['read']($name);
726 if ($stream === false) {
727 continue;
728 }
729 $out = @fopen($dest, 'wb');
730 if ($out !== false) {
731 stream_copy_to_stream($stream, $out);
732 fclose($out);
733 }
734 fclose($stream);
735 }
736
737 $archive['close']();
738
739 if (!is_file($staging . '/plugin.json')) {
740 znote_rrmdir($staging);
741 return 'The archive unpacked without a plugin.json. Nothing was installed.';
742 }
743
744 if (is_dir($target)) {
745 $backup = ZNOTE_PLUGIN_DIR . '/.' . $key . '.previous';
746 znote_rrmdir($backup);
747
748 if (!@rename($target, $backup)) {
749 znote_rrmdir($staging);
750 return 'Could not move the existing plugin aside. Check permissions on plugins/' . $key . '.';
751 }
752 if (!@rename($staging, $target)) {
753 @rename($backup, $target);
754 znote_rrmdir($staging);
755 return 'Could not put the new plugin in place. The previous one was restored.';
756 }
757 znote_rrmdir($backup);
758 } elseif (!@rename($staging, $target)) {
759 znote_rrmdir($staging);
760 return 'Could not create plugins/' . $key . '.';
761 }
762
763 return '';
764}
765