From c4f203336803a8f3e65be08728c7897a928bbb2b Mon Sep 17 00:00:00 2001 From: the-djmaze <3752035+the-djmaze@users.noreply.github.com> Date: Tue, 3 Nov 2026 00:58:35 +0100 Subject: [PATCH] More Nextcloud changes #2055 --- .../nextcloud/snappymail/app/include.php | 4 +- .../nextcloud/snappymail/appinfo/info.xml | 2 + .../snappymail/lib/AppInfo/Application.php | 2 +- .../lib/Controller/FetchController.php | 9 +- .../lib/Controller/PageController.php | 127 +++++++++++++++++- .../snappymail/lib/Settings/AdminSettings.php | 3 +- .../lib/Settings/PersonalSettings.php | 4 +- .../snappymail/lib/Settings/Section.php | 38 ++++++ .../snappymail/lib/Util/RainLoop.php | 5 +- .../snappymail/lib/Util/SnappyMailHelper.php | 64 +++++++-- 10 files changed, 228 insertions(+), 30 deletions(-) create mode 100644 integrations/nextcloud/snappymail/lib/Settings/Section.php diff --git a/integrations/nextcloud/snappymail/app/include.php b/integrations/nextcloud/snappymail/app/include.php index 7aa451923..fcb485a78 100644 --- a/integrations/nextcloud/snappymail/app/include.php +++ b/integrations/nextcloud/snappymail/app/include.php @@ -28,13 +28,15 @@ * Custom 'data' folder path */ if (class_exists('OC')) { + define('APP_DATA_FOLDER_PATH', \OCA\SnappyMail\Util\SnappyMailHelper::getAppDataFolder()); +/* define('APP_DATA_FOLDER_PATH', \rtrim(\trim( -// \OCA\SnappyMail\Util\SnappyMailHelper::getSystemConfigValue('datadirectory', '') \method_exists(\OC::$server, 'getSystemConfig') ? \OC::$server->getSystemConfig()->getValue('datadirectory', '') // Nextcloud 34+ : \OC::$server->get(\OCP\IConfig::class)->getSystemValue('datadirectory', '') ), '\\/') . '/appdata_snappymail/'); +*/ } else { http_response_code(400); header($_SERVER['SERVER_PROTOCOL'].' 400 Bad Request', true, 400); diff --git a/integrations/nextcloud/snappymail/appinfo/info.xml b/integrations/nextcloud/snappymail/appinfo/info.xml index 5f25f2e54..48f24eb42 100644 --- a/integrations/nextcloud/snappymail/appinfo/info.xml +++ b/integrations/nextcloud/snappymail/appinfo/info.xml @@ -68,7 +68,9 @@ There, click on the link to go to the SnappyMail admin panel. + OCA\SnappyMail\Settings\Section OCA\SnappyMail\Settings\AdminSettings + OCA\SnappyMail\Settings\Section OCA\SnappyMail\Settings\PersonalSettings diff --git a/integrations/nextcloud/snappymail/lib/AppInfo/Application.php b/integrations/nextcloud/snappymail/lib/AppInfo/Application.php index ce417562a..ceb584c96 100644 --- a/integrations/nextcloud/snappymail/lib/AppInfo/Application.php +++ b/integrations/nextcloud/snappymail/lib/AppInfo/Application.php @@ -75,7 +75,7 @@ class Application extends App implements IBootstrap { // SnappyMailHelper::$server = $context->getServerContainer(); - if (!\is_dir(\rtrim(\trim(SnappyMailHelper::getSystemConfigValue('datadirectory', '')), '\\/') . '/appdata_snappymail')) { + if (!\is_dir(SnappyMailHelper::getAppDataFolder())) { return; } diff --git a/integrations/nextcloud/snappymail/lib/Controller/FetchController.php b/integrations/nextcloud/snappymail/lib/Controller/FetchController.php index 66d3724e9..4c1a73b78 100644 --- a/integrations/nextcloud/snappymail/lib/Controller/FetchController.php +++ b/integrations/nextcloud/snappymail/lib/Controller/FetchController.php @@ -10,17 +10,20 @@ use OCP\AppFramework\Http\JSONResponse; use OCP\IConfig; use OCP\IL10N; use OCP\IRequest; +//use OCP\IUserSession; class FetchController extends Controller { private IConfig $config; private IL10N $l10n; +// private IUserSession $userSession; public function __construct(string $appName, IRequest $request, \Psr\Container\ContainerInterface $container) { parent::__construct($appName, $request); $this->config = $container->query(IConfig::class); $this->l10n = $container->query(IL10N::class); +// $this->userSession = $container->query(IUserSession::class); } public function upgrade(): JSONResponse @@ -34,7 +37,7 @@ class FetchController extends Controller 'Message' => $this->l10n->t('Upgraded successfully') ]); } - } catch (Exception $e) { + } catch (\Throwable $e) { $error .= ': ' . $e->getMessage(); } return new JSONResponse([ @@ -91,7 +94,7 @@ class FetchController extends Controller 'status' => 'success', 'Message' => $this->l10n->t('Saved successfully') ]); - } catch (Exception $e) { + } catch (\Throwable $e) { return new JSONResponse([ 'status' => 'error', 'Message' => $e->getMessage() @@ -136,7 +139,7 @@ class FetchController extends Controller 'Message' => $this->l10n->t('Saved successfully'), 'Email' => $sEmail ]); - } catch (Exception $e) { + } catch (\Throwable $e) { // Logout as the credentials might have changed, as exception could be in one attribute // TODO: Handle both exceptions separately? SnappyMailHelper::loadApp(); diff --git a/integrations/nextcloud/snappymail/lib/Controller/PageController.php b/integrations/nextcloud/snappymail/lib/Controller/PageController.php index e78b3d5fc..058cb45ee 100644 --- a/integrations/nextcloud/snappymail/lib/Controller/PageController.php +++ b/integrations/nextcloud/snappymail/lib/Controller/PageController.php @@ -33,13 +33,18 @@ class PageController extends Controller $bAdmin = false; if (!empty($_SERVER['QUERY_STRING'])) { SnappyMailHelper::loadApp(); - $bAdmin = \RainLoop\Api::Config()->Get('security', 'admin_panel_key', 'admin') == $_SERVER['QUERY_STRING']; + $bAdmin = $this->getAdminPanelKey() == $_SERVER['QUERY_STRING']; + if ($this->isAdminServiceRequest()) { + $this->ensureNextcloudAdminSession(); + } if (!$bAdmin) { return SnappyMailHelper::startApp(true); } } - if (!$bAdmin && SnappyMailHelper::getAppConfigValue('snappymail-no-embed')) { + if (!$bAdmin && $this->config->getAppValue('snappymail', 'snappymail-no-embed')) { +// if (!$bAdmin && SnappyMailHelper::getAppConfigValue('snappymail-no-embed')) { +// SnappyMailHelper::getNavigationManager()->setActiveEntry('snappymail'); $this->navigationManager->setActiveEntry('snappymail'); \OCP\Util::addScript('snappymail', 'snappymail'); \OCP\Util::addStyle('snappymail', 'style'); @@ -51,10 +56,119 @@ class PageController extends Controller $csp = new ContentSecurityPolicy(); $csp->addAllowedFrameDomain("'self'"); // $csp->addAllowedFrameAncestorDomain("'self'"); - $response->setContentSecurityPolicy($csp); + $response->setContentSecurityPolicy($csp->getPolicy()); return $response; } + return $this->renderEmbeddedApp($bAdmin); + } + + /** + * Integrated administration entry point. + * + * Unlike the legacy ?admin URL this route is protected by Nextcloud's + * default admin middleware. No @NoAdminRequired annotation must be added. + * + * @NoCSRFRequired + */ + public function admin() + { + $this->ensureNextcloudAdminSession(); + + if (!empty($_SERVER['QUERY_STRING'])) { + return $this->handleIntegratedAdminRequest(); + } + + return $this->renderEmbeddedApp(true, true); + } + + /** + * SnappyMail uses its own X-SM-Token for the legacy admin JSON API. The + * Nextcloud CSRF middleware cannot validate that token, therefore this + * endpoint opts out of the Nextcloud request token check. It remains admin + * protected and SnappyMail validates X-SM-Token for every modifying call. + * + * @NoCSRFRequired + */ + public function adminPost() + { + $this->ensureNextcloudAdminSession(); + return $this->handleIntegratedAdminRequest(); + } + + private function ensureNextcloudAdminSession(): void + { + $user = \OC::$server->get(\OCP\IUserSession::class)->getUser(); + if (!$user || !\OC_User::isAdminUser($user->getUID())) { + throw new \RuntimeException('SnappyMail administration requires a Nextcloud administrator session.'); + } + + SnappyMailHelper::loadApp(); + $config = \RainLoop\Api::Config(); + if (!$config->Get('security', 'allow_admin_panel', true)) { + throw new \RuntimeException('SnappyMail administration is disabled.'); + } + + $actions = \RainLoop\Api::Actions(); + if (!$actions->IsAdminLoggined(false)) { + $random = \MailSo\Base\Utils::Sha1Rand(); + if (!$actions->Cacher(null, true)->Set(\RainLoop\KeyPathHelper::SessionAdminKey($random), \time())) { + throw new \RuntimeException('Could not create the SnappyMail administrator session.'); + } + + $token = \RainLoop\Utils::EncodeKeyValuesQ(['token', $random]); + if (!$token) { + throw new \RuntimeException('Could not encode the SnappyMail administrator session.'); + } + \SnappyMail\Cookies::set('smadmin', $token); + } + } + + private function handleIntegratedAdminRequest(): void + { + $adminKey = $this->getAdminPanelKey(); + $query = \trim((string) ($_SERVER['QUERY_STRING'] ?? ''), " \t\n\r\0\x0B/"); + // The SnappyMail admin client already includes its configured admin + // path in JSON and asset requests. Prefix only requests that do not + // contain it yet; otherwise "admin/admin/Json" is produced and domain + // as well as extension requests are routed to a non-existing service. + if ($query !== $adminKey && 0 !== \strpos($query, $adminKey . '/')) { + $query = $adminKey . ($query ? '/' . $query : ''); + } + $_SERVER['QUERY_STRING'] = $query; + $_GET[$adminKey] = '1'; + + SnappyMailHelper::startApp(true); + } + + private function getAdminPanelKey(): string + { + $key = (string) \RainLoop\Api::Config()->Get('admin_panel', 'key', ''); + return '' !== $key ? $key : 'admin'; + } + + private function isAdminServiceRequest(): bool + { + $query = \trim((string) ($_SERVER['QUERY_STRING'] ?? ''), " \t\n\r\0\x0B/"); + $ampersand = \strpos($query, '&'); + if (false !== $ampersand) { + $query = \substr($query, 0, $ampersand); + } + $adminKey = $this->getAdminPanelKey(); + return $query === $adminKey || 0 === \strpos($query, $adminKey . '/'); + } + + private function protectLegacyAdminRequest(): void + { + SnappyMailHelper::loadApp(); + if ($this->isAdminServiceRequest()) { + $this->ensureNextcloudAdminSession(); + } + } + + private function renderEmbeddedApp(bool $bAdmin, bool $blank = false) + { +// SnappyMailHelper::getNavigationManager()->setActiveEntry('snappymail'); $this->navigationManager->setActiveEntry('snappymail'); \OCP\Util::addStyle('snappymail', 'embed'); @@ -93,9 +207,9 @@ class PageController extends Controller \OCP\Util::addHeader('link', ['type'=>'text/css','rel'=>'stylesheet','href'=>\RainLoop\Utils::WebStaticPath('css/'.($bAdmin?'admin':'app').$sAppCssMin.'.css')], ''); // \OCP\Util::addHeader('style', ['id'=>'app-theme-style','data-href'=>$params['BaseAppThemeCssLink']], $params['BaseAppThemeCss']); - $response = new TemplateResponse('snappymail', 'index_embed', $params); + $response = new TemplateResponse('snappymail', 'index_embed', $params, $blank ? 'blank' : 'user'); - $response->setContentSecurityPolicy($csp); + $response->setContentSecurityPolicy($csp->getPolicy()); return $response; } @@ -106,6 +220,7 @@ class PageController extends Controller */ public function appGet() { + $this->protectLegacyAdminRequest(); return SnappyMailHelper::startApp(true); } @@ -115,6 +230,7 @@ class PageController extends Controller */ public function appPost() { + $this->protectLegacyAdminRequest(); return SnappyMailHelper::startApp(true); } @@ -124,6 +240,7 @@ class PageController extends Controller */ public function indexPost() { + $this->protectLegacyAdminRequest(); return SnappyMailHelper::startApp(true); } } diff --git a/integrations/nextcloud/snappymail/lib/Settings/AdminSettings.php b/integrations/nextcloud/snappymail/lib/Settings/AdminSettings.php index eea9f49a8..d05f6dbe2 100644 --- a/integrations/nextcloud/snappymail/lib/Settings/AdminSettings.php +++ b/integrations/nextcloud/snappymail/lib/Settings/AdminSettings.php @@ -68,6 +68,7 @@ class AdminSettings implements ISettings $parameters['snappymail-admin-password'] = $sPassword; $parameters['can-import-rainloop'] = $sPassword && \is_dir( +// SnappyMailHelper::getAppDataFolder() \rtrim(\trim($this->config->getSystemValue('datadirectory', '')), '\\/') . '/rainloop-storage' ); @@ -98,7 +99,7 @@ class AdminSettings implements ISettings public function getSection() { - return 'additional'; + return 'snappymail'; } public function getPriority() diff --git a/integrations/nextcloud/snappymail/lib/Settings/PersonalSettings.php b/integrations/nextcloud/snappymail/lib/Settings/PersonalSettings.php index 7a3e7ef56..3b3e8aadb 100644 --- a/integrations/nextcloud/snappymail/lib/Settings/PersonalSettings.php +++ b/integrations/nextcloud/snappymail/lib/Settings/PersonalSettings.php @@ -8,7 +8,7 @@ use OCP\Settings\ISettings; class PersonalSettings implements ISettings { - private $config; + private IConfig $config; public function __construct(IConfig $config) { @@ -33,7 +33,7 @@ class PersonalSettings implements ISettings public function getSection() { - return 'additional'; + return 'snappymail'; } public function getPriority() diff --git a/integrations/nextcloud/snappymail/lib/Settings/Section.php b/integrations/nextcloud/snappymail/lib/Settings/Section.php new file mode 100644 index 000000000..3df65d201 --- /dev/null +++ b/integrations/nextcloud/snappymail/lib/Settings/Section.php @@ -0,0 +1,38 @@ +l = $l; + $this->urlGenerator = $urlGenerator; + } + + public function getID(): string + { + return 'snappymail'; + } + + public function getName(): string + { + return $this->l->t('SnappyMail'); + } + + public function getPriority(): int + { + return 50; + } + + public function getIcon(): string + { + return $this->urlGenerator->imagePath('snappymail', 'favicon-mask.svg'); + } +} diff --git a/integrations/nextcloud/snappymail/lib/Util/RainLoop.php b/integrations/nextcloud/snappymail/lib/Util/RainLoop.php index 737f73ded..3454e47cc 100644 --- a/integrations/nextcloud/snappymail/lib/Util/RainLoop.php +++ b/integrations/nextcloud/snappymail/lib/Util/RainLoop.php @@ -15,9 +15,8 @@ class RainLoop */ public static function import(): array { - $dir = \rtrim(\trim(SnappyMailHelper::getSystemConfigValue('datadirectory', '')), '\\/'); - $dir_snappy = $dir . '/appdata_snappymail/'; - $dir_rainloop = $dir . '/rainloop-storage'; + $dir_snappy = SnappyMailHelper::getAppDataFolder(); + $dir_rainloop = SnappyMailHelper::getDataDirectory() . '/rainloop-storage'; $result = []; $rainloop_plugins = []; if (\is_dir($dir_rainloop)) { diff --git a/integrations/nextcloud/snappymail/lib/Util/SnappyMailHelper.php b/integrations/nextcloud/snappymail/lib/Util/SnappyMailHelper.php index 8d165466e..d7e3e8848 100644 --- a/integrations/nextcloud/snappymail/lib/Util/SnappyMailHelper.php +++ b/integrations/nextcloud/snappymail/lib/Util/SnappyMailHelper.php @@ -34,6 +34,16 @@ class SnappyMailHelper return static::$server || \OC::$server; } + public static function getAppDataFolder(): string + { + return static::getDataDirectory() . '/appdata_snappymail/'; + } + + public static function getDataDirectory(): string + { + return \rtrim(\trim(static::getSystemConfigValue('datadirectory', '')), '\\/'); + } + public static function getConfig(): object { return \method_exists(\OC::$server, 'getConfig') @@ -110,10 +120,26 @@ class SnappyMailHelper } } + public static function linkToRoute(string $value): string + { + return (\method_exists(\OC::$server, 'getURLGenerator') + ? \OC::$server->getURLGenerator() + // Nextcloud 34+ + : \OCP\Server::get(\OCP\IURLGenerator::class) + )->linkToRoute($value); + } + public static function loadApp(): void { + $app_dir = \dirname(\dirname(__DIR__)) . '/app'; + if (\class_exists('RainLoop\\Api')) { - return; + $expected = \realpath($app_dir); + $actual = \defined('APP_INDEX_ROOT_PATH') ? \realpath(APP_INDEX_ROOT_PATH) : false; + if ($expected && $actual && $expected === $actual) { + return; + } + throw new \RuntimeException('SnappyMail cannot start because another SnappyMail-compatible app is already loaded in the same request.'); } // Nextcloud the default spl_autoload_register() not working @@ -129,9 +155,8 @@ class SnappyMailHelper // define('APP_VERSION', '0.0.0'); // define('APP_INDEX_ROOT_PATH', __DIR__ . DIRECTORY_SEPARATOR); // include APP_INDEX_ROOT_PATH.'snappymail/v/'.APP_VERSION.'/include.php'; -// define('APP_DATA_FOLDER_PATH', \rtrim(\trim(static::getSystemConfigValue('datadirectory', '')), '\\/').'/appdata_snappymail/'); +// define('APP_DATA_FOLDER_PATH', static::getAppDataFolder()); - $app_dir = \dirname(\dirname(__DIR__)) . '/app'; require_once $app_dir . '/index.php'; } @@ -173,7 +198,7 @@ class SnappyMailHelper if (24 < $OC_Version[0]) { $ocSession = static::getSession(); $ocSession->reopen(); - if (!$doLogin && $ocSession['snappymail-uid'] && $ocSession['snappymail-uid'] != $aCredentials[0]) { + if (!$doLogin && $ocSession->get('snappymail-uid') && $ocSession->get('snappymail-uid') != $aCredentials[0]) { // UID changed, Impersonate plugin probably active $oActions->Logout(true); $doLogin = true; @@ -193,9 +218,20 @@ class SnappyMailHelper } catch (\Throwable $e) { // Login failure, reset password to prevent more attempts if (!$isOIDC) { - $sUID = static::getUID(); - static::setSessionValue('snappymail-passphrase', ''); - static::getConfig()->setUserValue($sUID, 'snappymail', 'passphrase', ''); + // Credentials saved in the user's personal settings must never be + // deleted as a side effect of a failed login. A temporary network, + // DNS, TLS, or mail server outage is not a password change. + // + // Only discard the password captured from the current Nextcloud + // session when the IMAP server explicitly rejected authentication. + // Persisted credentials remain available for the user to inspect or + // replace in their personal settings. + if ($e instanceof \RainLoop\Exceptions\ClientException + && \RainLoop\Notifications::AuthError === $e->getCode() + ) { + static::setSessionValue('snappymail-passphrase', ''); +// static::getConfig()->setUserValue(static::getUID(), 'snappymail', 'passphrase', ''); + } \SnappyMail\Log::error('Nextcloud', $e->getMessage()); } } @@ -261,7 +297,7 @@ class SnappyMailHelper // If the current user ID is identical to login ID (not valid when using account switching), // this has the second priority. - if ($ocSession['snappymail-nc-uid'] == $sUID) { + if ($ocSession->get('snappymail-nc-uid') == $sUID) { // If OpenID Connect (OIDC) is enabled and used for login, use this. if (static::isOIDCLogin()) { @@ -276,10 +312,10 @@ class SnappyMailHelper $sPassword = ''; if ($config->getAppValue('snappymail', 'snappymail-autologin', false)) { $sEmail = $sUID; - $sPassword = $ocSession['snappymail-passphrase']; + $sPassword = $ocSession->get('snappymail-passphrase'); } else if ($config->getAppValue('snappymail', 'snappymail-autologin-with-email', false)) { $sEmail = $config->getUserValue($sUID, 'settings', 'email'); - $sPassword = $ocSession['snappymail-passphrase']; + $sPassword = $ocSession->get('snappymail-passphrase'); } else { \SnappyMail\Log::debug('Nextcloud', 'snappymail-autologin is off'); } @@ -287,7 +323,7 @@ class SnappyMailHelper return [$sUID, $sEmail, static::decodePassword($sPassword, $sUID)]; } } else { - \SnappyMail\Log::debug('Nextcloud', "snappymail-nc-uid mismatch '{$ocSession['snappymail-nc-uid']}' != '{$sUID}'"); + \SnappyMail\Log::debug('Nextcloud', "snappymail-nc-uid mismatch '{$ocSession->get('snappymail-nc-uid')}' != '{$sUID}'"); } return [$sUID, '', '']; @@ -295,7 +331,7 @@ class SnappyMailHelper public static function getAppUrl(): string { - return \OC::$server->getURLGenerator()->linkToRoute('snappymail.page.appGet'); + return static::linkToRoute('snappymail.page.appGet'); } public static function normalizeUrl(string $sUrl): string @@ -308,13 +344,13 @@ class SnappyMailHelper return $sUrl; } - public static function encodePassword(string $sPassword, string $sSalt): string + public static function encodePassword(#[\SensitiveParameter] string $sPassword, string $sSalt): string { static::loadApp(); return \SnappyMail\Crypt::EncryptUrlSafe($sPassword, $sSalt); } - public static function decodePassword(string $sPassword, string $sSalt): ?\SnappyMail\SensitiveString + public static function decodePassword(#[\SensitiveParameter] string $sPassword, string $sSalt): ?\SnappyMail\SensitiveString { static::loadApp(); $result = \SnappyMail\Crypt::DecryptUrlSafe($sPassword, $sSalt);