1: <?php
2: /**
3: * +------------------------------------------------------------+
4: * | apnscp |
5: * +------------------------------------------------------------+
6: * | Copyright (c) Apis Networks |
7: * +------------------------------------------------------------+
8: * | Licensed under Artistic License 2.0 |
9: * +------------------------------------------------------------+
10: * | Author: Matt Saladna (msaladna@apisnetworks.com) |
11: * +------------------------------------------------------------+
12: */
13:
14: use Module\Support\Webapps\App\Type\Nextcloud\Handler;
15: use Module\Support\Webapps\App\Type\Nextcloud\TreeWalker;
16: use Module\Support\Webapps\Composer;
17: use Module\Support\Webapps\DatabaseGenerator;
18: use Module\Support\Webapps\VersionFetcher\Github;
19:
20: /**
21: * Nextcloud management
22: *
23: * @package core
24: */
25: class Nextcloud_Module extends Composer
26: {
27: const APP_NAME = 'Nextcloud';
28: // @todo pull from github.com/nextcloud/server ?
29: const PACKAGIST_NAME = 'christophwurst/nextcloud';
30:
31: protected $aclList = array(
32: 'min' => array(
33: 'apps',
34: 'config',
35: 'data'
36: ),
37: 'max' => array(
38: 'config',
39: 'data'
40: )
41: );
42:
43: /**
44: * Install Nextcloud into a pre-existing location
45: *
46: * @param string $hostname domain or subdomain to install Laravel
47: * @param string $path optional path under hostname
48: * @param array $opts additional install options
49: * @return bool
50: */
51: public function install(string $hostname, string $path = '', array $opts = array()): bool
52: {
53: if (!$this->mysql_enabled()) {
54: return error('%(what)s must be enabled to install %(app)s',
55: ['what' => 'MySQL', 'app' => static::APP_NAME]);
56: }
57: if (!version_compare($this->php_version(), '7.2', '>=')) {
58: return error('%(app)s requires PHP %(minver).1f',
59: ['app' => static::APP_NAME, 'minver' => 7.2]
60: );
61: }
62:
63: if (!$this->php_jailed()) {
64: return error("%s requires PHP-FPM by setting apache,jail=1 in service configuration", static::APP_NAME);
65: }
66:
67: if (!$this->hasMemoryAllowance(512, $available)) {
68: return error("%(app)s requires at least %(min)d MB memory, `%(found)d' MB provided for account",
69: ['app' => 'Nextcloud', 'min' => 512, 'found' => $available]);
70: }
71:
72: if (!$this->parseInstallOptions($opts, $hostname, $path)) {
73: return false;
74: }
75:
76: $docroot = $this->getDocumentRoot($hostname, $path);
77:
78: if (isset($opts['datadir']) && !$this->checkDataDirectory($opts['datadir'])) {
79: return false;
80: }
81:
82: $dlUrl = 'https://download.nextcloud.com/server/releases/nextcloud-' . $opts['version'] . '.tar.bz2';
83: $oldex = \Error_Reporter::exception_upgrade();
84: $approot = $this->getAppRoot($hostname, $path);
85: try {
86: $this->download($dlUrl, "$docroot/nextcloud.tar.bz2");
87: $this->file_move("$docroot/nextcloud/", $docroot) && $this->file_delete("$docroot/nextcloud", true);
88: if (($user = $this->getDocrootUser($docroot)) !== $this->username) {
89: $this->file_chown($docroot, $user, true);
90: }
91: $db = DatabaseGenerator::mysql($this->getAuthContext(), $hostname);
92: $db->connectionLimit = max($db->connectionLimit, 15);
93: $db->dbArgs = [
94: 'utf8mb4',
95: 'utf8mb4_general_ci'
96: ];
97: if (!$db->create()) {
98: return false;
99: }
100: if (!isset($opts['password'])) {
101: $password = \Opcenter\Auth\Password::generate();
102: info("autogenerated password `%s'", $password);
103: }
104: $ret = $this->execPhp($docroot, 'occ maintenance:install --database=%(dbtype)s ' .
105: '--database-name=%(dbname)s --database-pass=%(dbpassword)s --database-user=%(dbuser)s ' .
106: '--admin-user=%(admin)s --admin-pass=%(passwd)s %(datadir)s', [
107: 'dbtype' => $db->kind,
108: 'dbname' => $db->database,
109: 'dbpassword' => $db->password,
110: 'dbuser' => $db->username,
111: 'admin' => $opts['user'] ?? $this->username,
112: 'passwd' => $password ?? $opts['password'],
113: 'datadir' => !empty($opts['datadir']) ? '--data-dir=' . escapeshellarg($opts['datadir']) : null
114: ]);
115:
116: if (!$ret['success']) {
117: return error("Failed to run occ maintenance:install: %s", coalesce($ret['stderr'], $ret['stdout']));
118: }
119: $this->reconfigure($hostname, $path, 'migrate', $hostname);
120: $acls = [
121: $this->web_get_user($docroot, $path) => 'r'
122: ];
123: $this->file_chmod($approot . '/config/config.php', 604);
124: $this->file_set_acls($docroot . '/config/config.php', $acls, null, [File_Module::ACL_NO_RECALC_MASK => false]);
125: if ($opts['user'] !== $this->username) {
126: $this->file_chown($docroot, $opts['user'], true);
127: $user = $this->getDocrootUser($docroot . '/config/config.php');
128: if ($user !== $opts['user']) {
129: // reserved system user, e.g. "apache"
130: $this->file_chown($docroot . '/config/config.php', $user);
131: }
132: }
133: } catch (\apnscpException $e) {
134: $this->file_delete($approot, true);
135: return error('Failed to install %s: %s', static::APP_NAME, $e->getMessage());
136: } finally {
137: \Error_Reporter::exception_upgrade($oldex);
138: }
139:
140: // by default, let's only open up ACLs to the bare minimum
141:
142: $this->writeConfiguration($approot, 'htaccess.RewriteBase', '/' . trim($path, '/'));
143: if (version_compare($opts['version'], '24.0.0', '>=')) {
144: $this->execOcc($approot, 'maintenance:update:htaccess');
145: }
146: $this->fixRewriteBase($docroot);
147:
148: $this->notifyInstalled($hostname, $path, ['password' => $password ?? ''] + $opts);
149:
150: return info('%(app)s installed - confirmation email with login info sent to %(email)s',
151: ['app' => static::APP_NAME, 'email' => $opts['email']]);
152: }
153:
154: /**
155: * Validate datadir parameter
156: *
157: * @param string $directory
158: * @return bool
159: */
160: private function checkDataDirectory(string $directory): bool
161: {
162: if (!$this->file_file_exists($directory)) {
163: return $this->file_create_directory($directory, 0711);
164: }
165:
166: return !count($this->file_get_directory_contents($directory)) ?:
167: error("Data directory `%s' is not empty", $directory);
168: }
169:
170: protected function checkVersion(array &$options): bool
171: {
172: if (!parent::checkVersion($options)) {
173: return false;
174: }
175: $phpversion = $this->php_version();
176:
177: $cap = null;
178:
179: if ($cap && version_compare($options['version'], $cap, '>=')) {
180: info("PHP version `%s' detected, capping Nextcloud to %s", $phpversion, $cap);
181: $options['version'] = $cap;
182: }
183:
184: return true;
185: }
186:
187: /**
188: * Restrict write-access by the app
189: *
190: * @param string $hostname
191: * @param string $path
192: * @param string $mode
193: * @param array $args
194: * @return bool
195: */
196: public function fortify(string $hostname, string $path = '', string $mode = 'max', $args = []): bool
197: {
198: $this->writeConfiguration($this->getAppRoot($hostname, $path), 'config_is_read_only', false);
199: return parent::fortify($hostname, $path, $mode, $args) &&
200: $this->setLockdown($hostname, $path, $mode === 'max');
201: }
202:
203: /**
204: * Get installed version
205: *
206: * @param string $hostname
207: * @param string $path
208: * @return string version number
209: */
210: public function get_version(string $hostname, string $path = ''): ?string
211: {
212: $approot = $this->getAppRoot($hostname, $path);
213: $ret = $this->execOcc($approot, '--no-warnings -V');
214: if (!$ret['success']) {
215: return null;
216: }
217: $pos = strrpos($ret['stdout'], ' ');
218: return trim(substr($ret['stdout'], $pos));
219: }
220:
221: /**
222: * Location is a valid Laravel install
223: *
224: * @param string $hostname or $docroot
225: * @param string $path
226: * @return bool
227: */
228: public function valid(string $hostname, string $path = ''): bool
229: {
230: if ($hostname[0] === '/') {
231: if (!($path = realpath($this->domain_fs_path($hostname)))) {
232: return false;
233: }
234: } else {
235: $approot = $this->getAppRoot($hostname, $path);
236: if (!$approot) {
237: return false;
238: }
239: $path = $this->domain_fs_path($approot);
240: }
241: return file_exists($path . '/occ') && is_file($path . '/occ');
242: }
243:
244: /**
245: * Get all available Ghost versions
246: *
247: * @return array
248: */
249: public function get_versions(): array
250: {
251: $versions = $this->_getVersions();
252:
253: return array_column($versions, 'version');
254: }
255:
256: /**
257: * Get all current major versions
258: *
259: * @return array
260: */
261: protected function _getVersions(string $name = null)
262: {
263: $key = 'nextcloud.versions';
264: $cache = Cache_Super_Global::spawn();
265: if (false !== ($ver = $cache->get($key))) {
266: return $name ? ($ver[$name] ?? []) : (array)$ver;
267: }
268: $versions = (new Github)->setMode('tags')->fetch('nextcloud/server');
269: $versions = array_filter(array_combine(array_column($versions, 'version'), $versions),
270: static function ($v) {
271: if (strspn($v['version'], '0123456789.') !== \strlen($v['version'])) {
272: return false;
273: }
274:
275: return $v;
276: });
277: $cache->set($key, $versions, 43200);
278:
279: return $name ? ($versions[$name] ?? []) : $versions;
280: }
281:
282:
283: /**
284: * Uninstall Laravel from a location
285: *
286: * @param $hostname
287: * @param string $path
288: * @param string $delete remove all files under docroot
289: * @return bool
290: */
291: public function uninstall(string $hostname, string $path = '', string $delete = 'all'): bool
292: {
293: return parent::uninstall($hostname, $path, $delete);
294: }
295:
296: /**
297: * Get database configuration for a blog
298: *
299: * @param string $hostname domain or subdomain of wp blog
300: * @param string $path optional path
301: * @return array|bool
302: */
303: public function db_config(string $hostname, string $path = '')
304: {
305: $this->web_purge();
306: $docroot = $this->getAppRoot($hostname, $path);
307: if (!$docroot) {
308: return error('failed to determine %s', self::APP_NAME);
309: }
310: $code = 'include("./config/config.php"); ' .
311: 'print serialize(array("user" => $CONFIG["dbuser"], "password" => $CONFIG["dbpassword"], "db" => $CONFIG["dbname"], ' .
312: '"host" => $CONFIG["dbhost"], "prefix" => $CONFIG["dbtableprefix"]));';
313: $cmd = 'cd %(path)s && php -d mysqli.default_socket=' . escapeshellarg(ini_get('mysqli.default_socket')) . ' -r %(code)s';
314: $ret = $this->pman_run($cmd, ['path' => $docroot, 'code' => $code]);
315:
316: if (!$ret['success']) {
317: return error("failed to obtain Nextcloud configuration for `%s'", $docroot);
318: }
319: $data = \Util_PHP::unserialize($ret['stdout']);
320:
321: return $data;
322: }
323:
324: public function update_all(string $hostname, string $path = '', string $version = null): bool
325: {
326: return $this->update($hostname, $path, $version) || error('failed to update all components');
327: }
328:
329: protected function getAppRoot(string $hostname, string $path = ''): ?string
330: {
331: return $this->getDocumentRoot($hostname, $path);
332: }
333:
334: /**
335: * Update Nextcloud to latest version
336: *
337: * @param string $hostname domain or subdomain under which WP is installed
338: * @param string $path optional subdirectory
339: * @param string $version version to upgrade
340: * @return bool
341: */
342: public function update(string $hostname, string $path = '', string $version = null): bool
343: {
344: $docroot = $this->getDocumentRoot($hostname, $path);
345: if (!$docroot) {
346: return error('update failed');
347: }
348: $newversion = $version ?? \Opcenter\Versioning::maxVersion(
349: $this->get_versions(),
350: $this->parseLock(
351: \Module\Support\Webapps\App\Loader::fromDocroot('nextcloud', $docroot, $this->getAuthContext())->getOptions()['verlock'] ?? Handler::DEFAULT_FORTIFICATION,
352: $this->get_version($hostname, $path)
353: )
354: );
355: $ret = serial(function () use ($docroot, $newversion, $hostname, $path) {
356: $dlUrl = 'https://download.nextcloud.com/server/releases/nextcloud-' . $newversion . '.tar.bz2';
357: $this->download($dlUrl, "$docroot/nextcloud.tar.bz2");
358: // exclude data/ and config/ from upgrade per upgrade docs
359: // also exclude .htaccess or .user.ini fixups
360: $this->pman_run('cd %(chdir)s && rsync -aWx --delete ' .
361: '--exclude=.htaccess --exclude=.user.ini --exclude=config/ --exclude=nextcloud/ --exclude=data/ ' .
362: 'nextcloud/ .',
363: ['chdir' => $docroot], null, ['user' => $this->getDocrootUser($docroot)]);
364: $this->file_delete("$docroot/nextcloud", true);
365: $this->writeConfiguration($docroot, 'config_is_read_only', false);
366: $ret = $this->execOcc($docroot, '--no-warnings upgrade');
367: if ($ret['success']) {
368: $this->execOcc($docroot, '--no-warnings maintenance:mode --off');
369: }
370:
371: $this->fortify($hostname, $path, array_get($this->getOptions($docroot), 'fortify') ?: Handler::DEFAULT_FORTIFICATION);
372: return $ret['success'] ?: error("Failed to upgrade %s: %s", static::APP_NAME, coalesce($ret['stderr'], $ret['stdout']));
373: });
374:
375: $this->setInfo($docroot, [
376: 'version' => $newversion,
377: 'failed' => (bool)$ret
378: ]);
379:
380: return (bool)$ret;
381: }
382:
383: /**
384: * Update Nextcloud plugins
385: *
386: * @param string $hostname domain or subdomain
387: * @param string $path optional path within host
388: * @param array $plugins
389: * @return bool
390: */
391: public function update_plugins(string $hostname, string $path = '', array $plugins = array()): bool
392: {
393: return parent::update_plugins($hostname, $path, $plugins);
394: }
395:
396: /**
397: * Update Nextcloud themes
398: *
399: * @param string $hostname subdomain or domain
400: * @param string $path optional path under hostname
401: * @param array $themes
402: * @return bool
403: */
404: public function update_themes(string $hostname, string $path = '', array $themes = array()): bool
405: {
406: return parent::update_themes($hostname, $path, $themes);
407: }
408:
409: /**
410: * @inheritDoc
411: */
412: public function has_fortification(string $hostname, string $path = '', string $mode = null): bool
413: {
414: return parent::has_fortification($hostname, $path, $mode);
415: }
416:
417: /**
418: * @inheritDoc
419: */
420: public function fortification_modes(string $hostname, string $path = ''): array
421: {
422: return parent::fortification_modes($hostname, $path);
423: }
424:
425: /**
426: * Relax permissions to allow write-access
427: *
428: * @param string $hostname
429: * @param string $path
430: * @return bool
431: * @internal param string $mode
432: */
433: public function unfortify(string $hostname, string $path = ''): bool
434: {
435: return parent::unfortify($hostname, $path) &&
436: $this->setLockdown($hostname, $path, false);
437: }
438:
439: private function setLockdown(string $hostname, string $path, bool $enabled): bool
440: {
441: $approot = $this->getAppRoot($hostname, $path);
442: $ret = $this->writeConfiguration($approot, 'appstoreenabled', !$enabled) && $this->writeConfiguration($approot,
443: 'config_is_read_only', $enabled);
444: if ($ret) {
445: $this->file_set_acls(
446: $approot . '/config/config.php',
447: [$this->web_get_user($hostname, $path) => 'rw']
448: );
449: }
450: return $ret;
451: }
452:
453: private function writeConfiguration(string $approot, string $var, $val): bool
454: {
455: if ($var === 'config_is_read_only') {
456: return $this->directWrite($approot, $var, $val);
457: }
458: $args = [
459: 'var' => $var,
460: 'idx' => is_array($val) ? key($val) : null,
461: 'type' => is_array($val) ? current($val) : gettype($val),
462: 'val' => $val
463: ];
464: if (is_bool($val)) {
465: $args['val'] = $val ? 'true' : 'false';
466: }
467: $ret = $this->execOcc(
468: $approot,
469: '--no-warnings config:system:set %(var)s %(idx)s --type=%(type)s --value=%(val)s', $args);
470: return $ret['success'] ?: error("Failed to set %s: %s", $var, $ret['stderr']);
471: }
472:
473: private function directWrite(string $approot, string $var, $val): bool
474: {
475: return TreeWalker::instantiateContexted($this->getAuthContext(), [$approot . '/config/config.php'])->set($var, $val)->save();
476: }
477:
478: public function plugin_status(string $hostname, string $path = '', string $plugin = null)
479: {
480: return false;
481: }
482:
483: public function install_plugin(
484: string $hostname,
485: string $path,
486: string $plugin,
487: string $version = 'stable'
488: ): bool {
489: return false;
490: }
491:
492: public function uninstall_plugin(string $hostname, string $path, string $plugin, bool $force = false): bool
493: {
494: return false;
495: }
496:
497: public function disable_all_plugins(string $hostname, string $path = ''): bool
498: {
499: return false;
500: }
501:
502: public function theme_status(string $hostname, string $path = '', string $theme = null)
503: {
504: return parent::theme_status($hostname, $path, $theme); // TODO: Change the autogenerated stub
505: }
506:
507: public function install_theme(string $hostname, string $path, string $theme, string $version = null): bool
508: {
509: return parent::install_theme($hostname, $path, $theme, $version);
510: }
511:
512: /**
513: * Change admin values
514: *
515: * @param string $hostname
516: * @param string $path
517: * @param array $fields valid fields: "password"
518: * @return bool
519: */
520: public function change_admin(string $hostname, string $path, array $fields): bool
521: {
522: if (null === ($admin = $this->get_admin($hostname, $path))) {
523: return error("Cannot detect admin");
524: }
525: $approot = $this->getAppRoot($hostname, $path);
526: if ($password = array_pull($fields, 'password')) {
527: $ret = $this->execOcc($approot, 'user:resetpassword --no-warnings --password-from-env %s', [$admin], ['OC_PASS' => $password]);
528: if (!$ret['success']) {
529: return error("Failed to change password for %s: %s", $admin, $ret['stdout']);
530: }
531: }
532:
533: return true;
534: }
535:
536: public function get_admin(string $hostname, string $path = ''): ?string
537: {
538: $approot = $this->getAppRoot($hostname, $path);
539: $ret = $this->execOcc($approot, '-i --output=json --no-warnings user:list');
540: if (!$ret['success']) {
541: return null;
542: }
543: return array_first(json_decode($ret['stdout'], true), static function ($v) {
544: return in_array('admin', $v['groups'], true);
545: })['user_id'] ?? null;
546: }
547:
548: /**
549: * @inheritDoc
550: */
551: public function reconfigure(string $hostname, string $path, $param, $value = null): bool
552: {
553: if (!is_array($param)) {
554: $param = [$param => $value];
555: $value = null;
556: }
557: if (array_pull($param, 'migrate')) {
558: $hostnameCanonical = $hostname;
559: if (!str_contains($hostnameCanonical, '.') && $this->web_is_subdomain($hostnameCanonical)) {
560: // global subdomain, blech
561: $hostnameCanonical .= '.*';
562: }
563: $approot = $this->getAppRoot($hostname, $path);
564: $this->execOcc($approot, 'config:system:set trusted_domains 1 --value=%s', [$hostnameCanonical]);
565: }
566:
567: if (empty($param)) {
568: return true;
569: }
570:
571: return parent::reconfigure($hostname, $path, $param, $value); // TODO: Change the autogenerated stub
572: }
573:
574: /**
575: * @inheritDoc
576: */
577: public function reconfigurables(string $hostname, string $path = ''): array
578: {
579: return parent::reconfigurables($hostname, $path);
580: }
581:
582: /**
583: * OCC wrapper to run as config.php owner
584: *
585: * @param string $approot
586: * @param string $cmd
587: * @param array $args
588: * @return array
589: */
590: private function execOcc(string $approot, string $cmd, array $args = [], array $env = []): array
591: {
592: $user = $this->getDocrootUser($approot . '/config/config.php');
593:
594: return \Module\Support\Webapps\PhpWrapper::instantiateContexted(\Auth::context($user,
595: $this->site))->exec($approot, "occ $cmd", $args, $env);
596: }
597: }