owncloud/core

View on GitHub
lib/private/License/LicenseManager.php

Summary

Maintainability
C
1 day
Test Coverage
<?php
/**
 *
 * @copyright Copyright (c) 2020, ownCloud GmbH
 * @license AGPL-3.0
 *
 * This code is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License, version 3,
 * as published by the Free Software Foundation.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License, version 3,
 * along with this program.  If not, see <http://www.gnu.org/licenses/>
 *
 */
namespace OC\License;

use OCP\License\ILicenseManager;
use OCP\License\Exceptions\LicenseException;
use OCP\License\Exceptions\LicenseManagerException;
use OCP\IConfig;
use OCP\App\IAppManager;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\ILogger;

class LicenseManager implements ILicenseManager {
    public const GRACE_PERIOD = 60 * 60 * 24;  // 24h

    /** @var LicenseFetcher */
    private $licenseFetcher;
    /** @var MessageService */
    private $messageService;
    /** @var IConfig */
    private $config;
    /** @var IAppManager */
    private $appManager;
    /** @var ITimeFactory */
    private $timeFactory;
    /** @var ILogger */
    private $logger;

    public function __construct(
        LicenseFetcher $licenseFetcher,
        MessageService $messageService,
        IAppManager $appManager,
        IConfig $config,
        ITimeFactory $timeFactory,
        ILogger $logger
    ) {
        $this->licenseFetcher = $licenseFetcher;
        $this->messageService = $messageService;
        $this->appManager = $appManager;
        $this->config = $config;
        $this->timeFactory = $timeFactory;
        $this->logger = $logger;
    }

    /**
     * Check if "now" is inside the closed interval [t, t+p], where t = $timestamp
     * and p = the grace period (24h)
     *
     * @param int $timestamp the timestamp when the grace period started
     * @return bool
     */
    private function isNowUnderGracePeriod(int $timestamp): bool {
        $currentTime = $this->timeFactory->getTime();
        return $timestamp <= $currentTime && $currentTime <= ($timestamp + self::GRACE_PERIOD);
    }

    /**
     * Get the apps that are complaining about not having a valid license
     */
    private function getAppComplains(): array {
        $apps = [];
        $appComplains = $this->config->getAppKeys('core-license-complains');
        foreach ($appComplains as $appComplain) {
            if ($this->config->getAppValue($appComplain, 'enabled', 'no') === 'yes' && $this->appManager->getAppPath($appComplain) !== false) {
                // if the app is installed and hasn't been removed from the FS...
                $licenseWithState = $this->getLicenseWithState($appComplain);
                $licenseMatchesStoredOne = false;
                if ($licenseWithState[0]) {  // license exists for the app
                    $complainedLicense = $this->config->getAppValue('core-license-complains', $appComplain, null);
                    $licenseMatchesStoredOne = $licenseWithState[0]->getLicenseString() === $complainedLicense;
                }

                if ($licenseMatchesStoredOne) {
                    // if current license key matches the one the app is complaining about, mark the app
                    $apps[] = $appComplain;
                } else {
                    if ($licenseWithState[1] !== ILicenseManager::LICENSE_STATE_VALID) {
                        // if the new license isn't valid, mark the app
                        $apps[] = $appComplain;
                    } else {
                        // if the new license is valid, remove the previous complain
                        $this->config->deleteAppValue('core-license-complains', $appComplain);
                    }
                }
            } else {
                // missing app -> delete complain
                $this->config->deleteAppValue('core-license-complains', $appComplain);
            }
        }
        return $apps;
    }

    /**
     * {@inheritDoc}
     *
     * If the "includeExtras" is used, the following keys will be added:
     * - "apps" => list of apps actively using the grace period
     * - "demoKeyLink" => an url with a web page to get a demo key
     *
     * @param bool $includeExtras if the 'apps' key is going to be returned
     */
    public function getGracePeriod(bool $includeExtras = false): ?array {
        $gracePeriod = $this->config->getAppValue('core', 'grace_period', null);
        if ($gracePeriod === null || !$this->isNowUnderGracePeriod($gracePeriod)) {
            return null;
        }

        if ($includeExtras) {
            $apps = $this->getAppComplains();
            $demoKeyLink = $this->config->getSystemValue('grace_period.demo_key.link', 'https://owncloud.com/try-enterprise/');
            return [
                'apps' => $apps,
                'demoKeyLink' => $demoKeyLink,
                'start' => (int)$gracePeriod,
                'end' => (int)$gracePeriod + self::GRACE_PERIOD,
            ];
        }

        return [
            'start' => (int)$gracePeriod,
            'end' => (int)$gracePeriod + self::GRACE_PERIOD,
        ];
    }

    /**
     * @inheritDoc
     */
    public function setLicenseString(?string $licenseString) {
        // we can only set the ownCloud's license for now
        if ($licenseString !== null) {
            $this->licenseFetcher->setOwncloudLicense($licenseString);
        }
        $this->config->deleteAppValues('core-license-complains');
    }

    /**
     * @inheritDoc
     */
    public function removeLicenseString() {
        // we can only remove the ownCloud's license for now
        $this->licenseFetcher->removeOwncloudLicense();

        $this->config->deleteAppValues('core-license-complains');
    }

    /**
     * Per-app licenses aren't implemented at the moment. This method will return the state
     * of the ownCloud's license for all apps (including public community apps)
     * Returns an array with the license object and the state of the license (any of the
     * ILicenseManager::LICENSE_STATE_* constants). Note that the license object could be null
     * if the license is missing.
     * @return array [ILicense|null, ILicenseManager::LICENSE_STATE_*]
     */
    private function getLicenseWithState(string $appid) {
        // check if license is missing
        $licenseObj = $this->licenseFetcher->getOwncloudLicense();
        if ($licenseObj === null) {
            $licenseState = ILicenseManager::LICENSE_STATE_MISSING;
        } else {
            // check if the license is valid
            if ($licenseObj->isValid()) {
                // check if the license has expired
                $currentTime = $this->timeFactory->getTime();
                if ($licenseObj->getExpirationTime() < $currentTime) {
                    $licenseState = ILicenseManager::LICENSE_STATE_EXPIRED;
                } else {
                    $daysLeft = ($licenseObj->getExpirationTime() - $currentTime) / 86400;
                    if ($daysLeft <= ILicenseManager::THRESHOLD_ABOUT_TO_EXPIRE) {
                        $licenseState = ILicenseManager::LICENSE_STATE_ABOUT_TO_EXPIRE;
                    } else {
                        $licenseState = ILicenseManager::LICENSE_STATE_VALID;
                    }
                }
            } else {
                $licenseState = ILicenseManager::LICENSE_STATE_INVALID;
            }
        }

        return [$licenseObj, $licenseState];
    }

    /**
     * {@inheritdoc}
     *
     * Per-app licenses aren't implemented at the moment. This method will return the state
     * of the ownCloud's license for all apps (including public community apps)
     */
    public function getLicenseStateFor(string $appid): int {
        $info = $this->getLicenseWithState($appid);
        return $info[1];  // license state is the second item.
    }

    public function getLicenseMessageFor(string $appid, string $language = null): array {
        $info = $this->getLicenseWithState($appid);
        if ($info[0] === null) {
            // no license
            $infoForMessage = [
                'licenseState' => $info[1],
            ];
        } else {
            $infoForMessage = [
                'licenseState' => $info[1],
                'licenseType' => $info[0]->getType(),
                // daysLeft won't be accurate if the license isn't valid, but it's ok.
                'daysLeft' => \ceil(($info[0]->getExpirationTime() - $this->timeFactory->getTime()) / 86400),
            ];
        }

        $messageData = $this->messageService->getMessageForLicense($infoForMessage, $language);
        // need to include license_state and type fields
        $messageData['license_state'] = $info[1];
        if ($info[0] === null) {
            $messageData['type'] = -1;
        } else {
            $messageData['type'] = $info[0]->getType();
        }
        return $messageData;
    }

    public function askLicenseFor(string $appid, string $method, array $params = []) {
        $info = $this->getLicenseWithState($appid);
        $licenseObj = $info[0];
        if ($licenseObj === null) {
            throw new LicenseManagerException("No license available for app $appid", 1);
        }

        if (!\is_callable([$licenseObj, $method])) {
            throw new LicenseManagerException("Method $method can't be called", 2);
        }

        // function names are case-insensitive...
        $lowerCaseMethods = \array_map(function ($value) {
            return \strtolower($value);
        }, $licenseObj->getProtectedMethods());

        if (\in_array(\strtolower($method), $lowerCaseMethods, true)) {
            throw new LicenseManagerException("License doesn't allow method $method to be called", 3);
        }

        try {
            return $licenseObj->$method(...$params);
        } catch (LicenseException $ex) {
            throw new LicenseManagerException("Method $method failed", 99, $ex);
        }
    }

    /**
     * {@inheritDoc}
     *
     * This method will register a "complain" if we're under the grace period but the app
     * doesn't have a valid license, This way it's easier to keep track of what apps
     * have invalid license during the grace period
     *
     * Options:
     * - 'startGracePeriod' => true/false (default true): whether a grace period should
     *   start (if possible) automatically. Note that if a grace period is ongoing or
     *   has expired, this option won't have any effect. Set the option to false if you
     *   don't want to start the grace period.
     * - 'disableApp' => true/false (default true): whether the app will be disabled if the
     *   grace period has finished and there is no valid license. Setting this
     *   option to false is intended to be used by apps which are mostly open but
     *   have some parts that require a license: you don't want to disable the whole
     *   app if there is no valid license.
     */
    public function checkLicenseFor(string $appid, array $options = []): bool {
        $currentTime = $this->timeFactory->getTime();

        $gracePeriod = $this->config->getAppValue('core', 'grace_period', null);
        if ($gracePeriod === null) {
            if (isset($options['startGracePeriod']) && $options['startGracePeriod'] === false) {
                // don't set the grace period. Use a negative value for the "isNowUnderGracePeriod"
                $gracePeriod = -self::GRACE_PERIOD;
            } else {
                $this->config->setAppValue('core', 'grace_period', $currentTime);
                $gracePeriod = $currentTime;  // set the expected trial mark as now
            }
        }

        $licenseWithState = $this->getLicenseWithState($appid);
        $licenseObj = $licenseWithState[0];
        $licenseState = $licenseWithState[1];
        if (!$this->isNowUnderGracePeriod($gracePeriod)) {
            // we aren't under a grace period, so the app must be disabled if the license isn't valid
            if ($licenseState !== ILicenseManager::LICENSE_STATE_VALID &&
                $licenseState !== ILicenseManager::LICENSE_STATE_ABOUT_TO_EXPIRE
            ) {
                if (!isset($options['disableApp']) || $options['disableApp'] !== false) {
                    $this->appManager->disableApp($appid);
                    $this->logger->warning("$appid has been disabled because the license is not valid", ['app' => 'core']);
                }
                return false;
            }

            return true;
        }

        if ($licenseState !== ILicenseManager::LICENSE_STATE_VALID &&
            $licenseState !== ILicenseManager::LICENSE_STATE_ABOUT_TO_EXPIRE
        ) {
            // we're under a grace period but the license for the app isn't valid
            // mark the app to know there is at least one app using the grace period.
            // we'll still return true in this case.
            $licenseKey = '';
            if ($licenseObj) {
                $licenseKey = $licenseObj->getLicenseString();
            }
            $this->config->setAppValue('core-license-complains', $appid, $licenseKey);
            $this->logger->debug("$appid has registered a license complain over license $licenseKey", ['app' => 'core']);
        }
        return true;
    }
}