...
 
Commits (30)
......@@ -16,7 +16,7 @@ build:
- apk add --no-cache git
- sh tools/setup.sh production
artifacts:
name: '$CI_COMMIT_REF_SLUG'
name: "$CI_COMMIT_REF_SLUG"
paths:
- vendor
- bin
......@@ -39,7 +39,7 @@ prepare:fpm:
services:
- docker:dind
script:
- docker login -u gitlab-ci-token -p ${CI_BUILD_TOKEN} ${CI_REGISTRY}
- docker login -u gitlab-ci-token -p ${CI_BUILD_TOKEN} ${CI_REGISTRY}
- |
docker build \
-t $CI_REGISTRY_IMAGE/fpm:$CI_PIPELINE_ID \
......@@ -84,25 +84,26 @@ review:start:
- echo "Upgrading helm for pipeline ${CI_PIPELINE_ID}"
- echo "Setting to image ${CI_REGISTRY_IMAGE}"
- "helm upgrade \
--install \
--reuse-values \
--set phpfpm.image.repository=$CI_REGISTRY_IMAGE/fpm \
--set-string phpfpm.image.tag=$CI_PIPELINE_ID \
--set runners.image.repository=$CI_REGISTRY_IMAGE/runners \
--set-string runners.image.tag=$CI_PIPELINE_ID \
--set domain=$CI_BUILD_REF_SLUG.$KUBE_INGRESS_BASE_DOMAIN \
--set elasticsearch.clusterName=$CI_BUILD_REF_SLUG-elasticsearch \
--wait \
$CI_BUILD_REF_SLUG \
./helm-charts/minds"
--install \
--reuse-values \
--set phpfpm.image.repository=$CI_REGISTRY_IMAGE/fpm \
--set-string phpfpm.image.tag=$CI_PIPELINE_ID \
--set runners.image.repository=$CI_REGISTRY_IMAGE/runners \
--set-string runners.image.tag=$CI_PIPELINE_ID \
--set domain=$CI_BUILD_REF_SLUG.$KUBE_INGRESS_BASE_DOMAIN \
--set elasticsearch.clusterName=$CI_BUILD_REF_SLUG-elasticsearch \
--wait \
$CI_BUILD_REF_SLUG \
./helm-charts/minds"
- sentry-cli releases deploys $CI_PIPELINE_ID new -e review-$CI_COMMIT_REF_SLUG
environment:
name: review/$CI_COMMIT_REF_NAME
url: https://$CI_BUILD_REF_SLUG.$KUBE_INGRESS_BASE_DOMAIN
on_stop: review:stop
except:
except:
refs:
- master
- production
- test/gitlab-ci
review:stop:
......@@ -118,9 +119,10 @@ review:stop:
variables:
GIT_STRATEGY: none
when: manual
except:
except:
refs:
- master
- production
- test/gitlab-ci
qa:manual:
......@@ -131,16 +133,20 @@ qa:manual:
only:
refs:
- master
- production
- test/gitlab-ci
allow_failure: false
staging:fpm:
################
# Deploy Stage #
################
.deploy: &deploy
stage: deploy:staging
image: minds/ci:latest
services:
- docker:dind
script:
- IMAGE_LABEL="staging"
- $(aws ecr get-login --no-include-email --region us-east-1)
- docker login -u gitlab-ci-token -p ${CI_BUILD_TOKEN} ${CI_REGISTRY}
- docker pull $CI_REGISTRY_IMAGE/fpm:$CI_PIPELINE_ID
......@@ -151,40 +157,51 @@ staging:fpm:
- docker tag $CI_REGISTRY_IMAGE/fpm:$CI_PIPELINE_ID $CI_REGISTRY_IMAGE/fpm:$IMAGE_LABEL
- docker push $CI_REGISTRY_IMAGE/fpm:$IMAGE_LABEL
# Deploy to staging
- aws ecs update-service --service=$ECS_APP_STAGING_SERVICE --force-new-deployment --region us-east-1 --cluster=$ECS_CLUSTER
- aws ecs update-service --service=$ECS_SERVICE --force-new-deployment --region us-east-1 --cluster=$ECS_CLUSTER
# Update sentry
- sentry-cli releases deploys $CI_PIPELINE_ID new -e $IMAGE_LABEL
staging:fpm:
<<: *deploy
stage: deploy:staging
variables:
IMAGE_LABEL: "staging"
ECS_SERVICE: $ECS_APP_STAGING_SERVICE
environment:
name: staging
url: https://www.minds.com/?staging=1
only:
only:
refs:
- master
- production
- test/gitlab-ci
review:preprod:
<<: *deploy
stage: review
when: manual
allow_failure: true
variables:
IMAGE_LABEL: "preprod"
ECS_SERVICE: $ECS_APP_PREPROD_SERVICE
environment:
name: preprod
url: https://www.minds.com/?preprod=1
# except:
# refs:
# - master
# - test/gitlab-ci
canary:fpm:
<<: *deploy
stage: deploy:canary
image: minds/ci:latest
services:
- docker:dind
script:
- IMAGE_LABEL="canary"
- $(aws ecr get-login --no-include-email --region us-east-1)
- docker login -u gitlab-ci-token -p ${CI_BUILD_TOKEN} ${CI_REGISTRY}
- docker pull $CI_REGISTRY_IMAGE/fpm:$CI_PIPELINE_ID
# Push to AWS registry
- docker tag $CI_REGISTRY_IMAGE/fpm:$CI_PIPELINE_ID $ECR_REPOSITORY_URL_FPM:$IMAGE_LABEL
- docker push $ECR_REPOSITORY_URL_FPM:$IMAGE_LABEL
# Push to Gitlab registry
- docker tag $CI_REGISTRY_IMAGE/fpm:$CI_PIPELINE_ID $CI_REGISTRY_IMAGE/fpm:$IMAGE_LABEL
- docker push $CI_REGISTRY_IMAGE/fpm:$IMAGE_LABEL
# Deploy to ECS
- aws ecs update-service --service=$ECS_APP_CANARY_SERVICE --force-new-deployment --region us-east-1 --cluster=$ECS_CLUSTER
# Update sentry
- sentry-cli releases deploys $CI_PIPELINE_ID new -e $IMAGE_LABEL
variables:
IMAGE_LABEL: "canary"
ECS_SERVICE: $ECS_APP_CANARY_SERVICE
only:
refs:
- master
- production
- test/gitlab-ci
environment:
name: canary
......@@ -193,28 +210,15 @@ canary:fpm:
allow_failure: false # prevents auto deploy to full production
production:fpm:
<<: *deploy
stage: deploy:production
image: minds/ci:latest
services:
- docker:dind
script:
- IMAGE_LABEL="production"
- $(aws ecr get-login --no-include-email --region us-east-1)
- docker login -u gitlab-ci-token -p ${CI_BUILD_TOKEN} ${CI_REGISTRY}
- docker pull $CI_REGISTRY_IMAGE/fpm:$CI_PIPELINE_ID
# Push to AWS registry
- docker tag $CI_REGISTRY_IMAGE/fpm:$CI_PIPELINE_ID $ECR_REPOSITORY_URL_FPM:$IMAGE_LABEL
- docker push $ECR_REPOSITORY_URL_FPM:$IMAGE_LABEL
# Push to Gitlab registry
- docker tag $CI_REGISTRY_IMAGE/fpm:$CI_PIPELINE_ID $CI_REGISTRY_IMAGE/fpm:$IMAGE_LABEL
- docker push $CI_REGISTRY_IMAGE/fpm:$IMAGE_LABEL
# Delpoy to ECS
- aws ecs update-service --service=$ECS_APP_PRODUCTION_SERVICE --force-new-deployment --region us-east-1 --cluster=$ECS_CLUSTER
# Update sentry
- sentry-cli releases deploys $CI_PIPELINE_ID new -e $IMAGE_LABEL
variables:
IMAGE_LABEL: "production"
ECS_SERVICE: $ECS_APP_PRODUCTION_SERVICE
only:
refs:
- master
- production
- test/gitlab-ci
environment:
name: production
......@@ -242,6 +246,7 @@ production:runners:
only:
refs:
- master
- production
- test/gitlab-ci
environment:
name: production
......
<?php
/**
* Features
*
* @author edgebal
*/
namespace Minds\Controllers\Cli;
use Minds\Cli;
use Minds\Core\Di\Di;
use Minds\Core\Features\Manager;
use Minds\Exceptions\CliException;
use Minds\Interfaces;
class Features extends Cli\Controller implements Interfaces\CliControllerInterface
{
/**
* @inheritDoc
*/
public function help($command = null)
{
$this->out('Syntax usage: cli features sync');
}
/**
* @inheritDoc
*/
public function exec()
{
return $this->help();
}
public function sync()
{
/** @var Manager $manager */
$manager = Di::_()->get('Features\Manager');
$ttl = $this->getOpt('ttl') ?: 300;
while (true /* Forever running task */) {
$this->out([date('c'), "TTL: {$ttl}"], static::OUTPUT_PRE);
foreach ($manager->sync($ttl) as $key => $output) {
$this->out(sprintf("Sync %s: %s", $key, $output));
}
if (!$this->getOpt('forever')) {
break;
}
$this->out("Done, sleeping {$ttl}s");
sleep($ttl);
}
}
}
......@@ -104,8 +104,8 @@ class authenticate implements Interfaces\Api, Interfaces\ApiIgnorePam
Security\XSRF::setCookie(true);
// Set the canary cookie
Di::_()->get('Features\Manager')
->setCanaryCookie($user->isCanary());
Di::_()->get('Features\Canary')
->setCookie($user->isCanary());
$response['status'] = 'success';
$response['user'] = $user->export();
......
......@@ -24,8 +24,8 @@ class canary implements Interfaces\Api
}
// Refresh the canary cookie
Di::_()->get('Features\Manager')
->setCanaryCookie($user->isCanary());
Di::_()->get('Features\Canary')
->setCookie($user->isCanary());
return Factory::response([
'enabled' => (bool) $user->isCanary(),
......@@ -57,8 +57,8 @@ class canary implements Interfaces\Api
]);
// Set the canary cookie
Di::_()->get('Features\Manager')
->setCanaryCookie($user->isCanary());
Di::_()->get('Features\Canary')
->setCookie($user->isCanary());
return Factory::response([]);
}
......
......@@ -19,7 +19,7 @@ class feeds implements Interfaces\Api
'24h' => '7d',
'7d' => '30d',
'30d' => '1y',
'1y' => 'all'
'1y' => 'all',
];
/**
......@@ -87,7 +87,7 @@ class feeds implements Interfaces\Api
$exportCounts = false;
if (isset($_GET['exportCounts'])) {
if (isset($_GET['export_user_counts'])) {
$exportCounts = true;
}
......@@ -247,10 +247,9 @@ class feeds implements Interfaces\Api
$entities = $entities->map([$elasticEntities, 'cast']);
}
}
if ($type === 'user' && $exportCounts) {
foreach ($entities as $entity) {
$entity->exportCounts = true;
$entity->getEntity()->exportCounts = true;
}
}
......
......@@ -8,6 +8,7 @@ namespace Minds\Core\Config;
use Minds\Core\Blockchain\Manager as BlockchainManager;
use Minds\Core\Di\Di;
use Minds\Core\Features\Manager as FeaturesManager;
use Minds\Core\I18n\I18n;
use Minds\Core\Navigation\Manager as NavigationManager;
use Minds\Core\Rewards\Contributions\ContributionValues;
......@@ -27,23 +28,31 @@ class Exported
/** @var I18n */
protected $i18n;
/** @var FeaturesManager */
protected $features;
/**
* Exported constructor.
* @param Config $config
* @param ThirdPartyNetworksManager $thirdPartyNetworks
* @param I18n $i18n
* @param BlockchainManager $blockchain
* @param FeaturesManager $features
*/
public function __construct(
$config = null,
$thirdPartyNetworks = null,
$i18n = null,
$blockchain = null
$blockchain = null,
$proDomain = null,
$features = null
) {
$this->config = $config ?: Di::_()->get('Config');
$this->thirdPartyNetworks = $thirdPartyNetworks ?: Di::_()->get('ThirdPartyNetworks\Manager');
$this->i18n = $i18n ?: Di::_()->get('I18n');
$this->blockchain = $blockchain ?: Di::_()->get('Blockchain\Manager');
$this->proDomain = $proDomain ?: Di::_()->get('Pro\Domain');
$this->features = $features ?: Di::_()->get('Features\Manager');
}
/**
......@@ -71,7 +80,7 @@ class Exported
'recaptchaKey' => $this->config->get('google')['recaptcha']['site_key'],
'max_video_length' => $this->config->get('max_video_length'),
'max_video_file_size' => $this->config->get('max_video_file_size'),
'features' => (object) ($this->config->get('features') ?: []),
'features' => (object) ($this->features->export() ?: []),
'blockchain' => (object) $this->blockchain->getPublicSettings(),
'sale' => $this->config->get('blockchain')['sale'],
'last_tos_update' => $this->config->get('last_tos_update') ?: time(),
......@@ -110,6 +119,12 @@ class Exported
$exported['from_email_confirmation'] = true;
}
// Pro export
if ($pro = $this->proDomain->lookup($_SERVER['HTTP_HOST'] ?? null)) {
$exported['pro'] = $pro;
}
return $exported;
}
}
......@@ -25,6 +25,9 @@ class DataProvider extends Provider
$this->di->bind('Cache\Apcu', function ($di) {
return new cache\apcu();
}, ['useFactory'=>true]);
$this->di->bind('Cache\PsrWrapper', function ($di) {
return new cache\PsrWrapper();
}, ['useFactory'=>true]);
/**
* Database bindings
*/
......
<?php
/**
* PsrWrapper
*
* @author edgebal
*/
namespace Minds\Core\Data\cache;
use Minds\Core\Di\Di;
use NotImplementedException;
use Psr\SimpleCache\CacheInterface;
class PsrWrapper implements CacheInterface
{
/** @var abstractCacher */
protected $cache;
/**
* PsrWrapper constructor.
* @param abstractCacher $cache
*/
public function __construct(
$cache = null
) {
$this->cache = $cache ?: Di::_()->get('Cache');
}
/**
* @inheritDoc
*/
public function get($key, $default = null)
{
return $this->cache->get($key) ?? $default;
}
/**
* @inheritDoc
*/
public function set($key, $value, $ttl = null)
{
return $this->cache->set($key, $value, $ttl);
}
/**
* @inheritDoc
*/
public function delete($key)
{
$this->cache->destroy($key);
}
/**
* @inheritDoc
* @throws NotImplementedException
*/
public function clear()
{
throw new NotImplementedException();
}
/**
* @inheritDoc
*/
public function getMultiple($keys, $default = null)
{
$values = [];
foreach ($keys as $key) {
$values[$key] = $this->cache->get($key) ?? $default;
}
return $values;
}
/**
* @inheritDoc
*/
public function setMultiple($values, $ttl = null)
{
foreach ($values as $key => $value) {
$this->cache->set($key, $value, $ttl);
}
return true;
}
/**
* @inheritDoc
*/
public function deleteMultiple($keys)
{
foreach ($keys as $key) {
$this->cache->destroy($key);
}
return true;
}
/**
* @inheritDoc
*/
public function has($key)
{
return $this->cache->get($key) !== null;
}
}
......@@ -55,7 +55,7 @@ class Confirmation extends EmailCampaign
'state' => 'new',
];
$subject = 'Confirm your Minds email (Action required)';
$subject = 'Please, confirm your email';
$this->template->setTemplate('default.tpl');
$this->template->setBody('./Templates/confirmation.tpl');
......
......@@ -17,14 +17,6 @@
</p>
</td>
</tr>
<tr>
<td>
<p>
Thanks,<br>
The Minds Team
</p>
</td>
</tr>
<tr>
<td>
<p>Also, be sure to download our mobile app using the links below:</p>
......
You've received a gift of 1 Minds token! You can spend this token to earn 1,000 extra views on your content with [Boost](https://www.minds.com/boost?__e_ct_guid=<?= $vars['guid']?>&campaign=<?= $vars['campaign']?>&topic=<?= $vars['topic'] ?>&validator=<?= $vars['validator'] ?>) or to tip your favorite content creators with [Wire](https://www.minds.com/wire?__e_ct_guid=<?= $vars['guid']?>&campaign=<?= $vars['campaign']?>&topic=<?= $vars['topic'] ?>&validator=<?= $vars['validator'] ?>).
Please use the button below to claim your gift (note: you will need to open the link in a web browser, the mobile app is not yet supported):
| |
|:--:|
| [![Claim Gift](https://cdn-assets.minds.com/emails/claim-gift.png){=150x}](https://www.minds.com/wallet/tokens/transactions?__e_ct_guid=<?= $vars['guid']?>&campaign=<?= $vars['campaign']?>&topic=<?= $vars['topic'] ?>&validator=<?= $vars['validator'] ?>) |
| |
<?php
/**
* Canary
*
* @author edgebal
*/
namespace Minds\Core\Features;
use Minds\Common\Cookie;
/**
* Controls Canary cookie setting
* @package Minds\Core\Features
*/
class Canary
{
/** @var Cookie $cookie */
protected $cookie;
/**
* Canary constructor.
* @param Cookie $cookie
*/
public function __construct(
$cookie = null
) {
$this->cookie = $cookie ?: new Cookie();
}
/**
* Sets canary cookie value
* @param bool $enabled
* @return bool
*/
public function setCookie(bool $enabled): bool
{
$this->cookie
->setName('canary')
->setValue((int) $enabled)
->setExpire(0)
->setSecure(true) //only via ssl
->setHttpOnly(true) //never by browser
->setPath('/')
->create();
return true;
}
}
<?php
/**
* FeatureNotImplementedException
*
* @author edgebal
*/
namespace Minds\Core\Features\Exceptions;
use Exception;
class FeatureNotImplementedException extends Exception
{
}
<?php
/**
* Minds Features Provider
*
* @author emi
*/
namespace Minds\Core\Features;
use Minds\Core\Di\Provider;
class FeaturesProvider extends Provider
{
public function register()
{
$this->di->bind('Features', function ($di) {
return new Manager();
}, [ 'useFactory'=> true ]);
$this->di->bind('Features\Manager', function ($di) {
return new Manager();
}, [ 'useFactory'=> true ]);
}
}
......@@ -8,88 +8,110 @@
namespace Minds\Core\Features;
use Exception;
use Minds\Core\Di\Di;
use Minds\Common\Cookie;
use Minds\Core\Session;
use Minds\Core\Features\Exceptions\FeatureNotImplementedException;
use Minds\Core\Sessions\ActiveSession;
/**
* Features Manager
* @package Minds\Core\Features
*/
class Manager
{
/** @var User $user */
private $user;
/** @var Services\ServiceInterface[] */
protected $services;
/** @var Config $config */
private $config;
/** @var ActiveSession */
protected $activeSession;
/** @var Cookie $cookie */
private $cookie;
public function __construct($config = null, $cookie = null, $user = null)
{
$this->config = $config ?: Di::_()->get('Config');
$this->cookie = $cookie ?: new Cookie;
$this->user = $user ?? Session::getLoggedInUser();
/** @var string[] */
protected $featureKeys;
/**
* Manager constructor.
* @param Services\ServiceInterface[] $services
* @param ActiveSession $activeSession
* @param string[] $features
*/
public function __construct(
$services = null,
$activeSession = null,
array $features = null
) {
$this->services = $services ?: [
new Services\Config(),
new Services\Unleash(),
new Services\Environment(),
];
$this->activeSession = $activeSession ?: Di::_()->get('Sessions\ActiveSession');
$this->featureKeys = ($features ?? Di::_()->get('Features\Keys')) ?: [];
}
/**
* Set the user
* @param User $user
* @return $this
* Synchronizes all services using their respective mechanisms
* @param int $ttl
* @return iterable
*/
public function setUser($user)
public function sync(int $ttl): iterable
{
$this->user = $user;
return $this;
foreach ($this->services as $service) {
try {
$output = $service->sync($ttl) ? 'OK' : 'NOT SYNC\'D';
} catch (Exception $e) {
$output = $e;
}
yield get_class($service) => $output;
}
}
/**
* Checks if a featured is enabled
* @param $feature
* Checks if a feature is enabled
* @param string $feature
* @return bool
* @throws FeatureNotImplementedException
*/
public function has($feature)
public function has(string $feature): ?bool
{
$features = $this->config->get('features') ?: [];
$features = $this->export();
if (!isset($features[$feature])) {
// error_log("[Features\Manager] Feature '{$feature}' is not declared. Assuming false.");
return false;
throw new FeatureNotImplementedException(
"${feature}: Not Implemented"
);
}
if ($features[$feature] === 'admin' && $this->user->isAdmin()) {
return true;
}
if ($features[$feature] === 'canary' && $this->user && $this->user->get('canary')) {
return true;
}
return $features[$feature] === true;
return (bool) $features[$feature];
}
/**
* Exports the features array
* Exports the whole features array based on Features DI
* @return array
*/
public function export()
public function export(): array
{
return $this->config->get('features') ?: [];
}
$features = [];
/**
* Set the canary cookie
* @param bool $enabled
* @return void
*/
public function setCanaryCookie(bool $enabled = true) : void
{
$this->cookie
->setName('canary')
->setValue((int) $enabled)
->setExpire(0)
->setSecure(true) //only via ssl
->setHttpOnly(true) //never by browser
->setPath('/')
->create();
// Initialize array with false values
foreach ($this->featureKeys as $feature) {
$features[$feature] = false;
}
// Fetch from every service
foreach ($this->services as $service) {
$features = array_merge(
$features,
$service
->setUser($this->activeSession->getUser())
->fetch($this->featureKeys)
);
}
//
return $features;
}
}
<?php
/**
* Module
*
* @author edgebal
*/
namespace Minds\Core\Features;
use Minds\Interfaces\ModuleInterface;
class Module implements ModuleInterface
{
/**
* @inheritDoc
*/
public function onInit()
{
(new Provider())->register();
}
}
<?php
/**
* Minds Features Provider
*
* @author emi
*/
namespace Minds\Core\Features;
use Minds\Core\Di\Provider as DiProvider;
/**
* Features provider
* @package Minds\Core\Features
*/
class Provider extends DiProvider
{
public function register()
{
$this->di->bind('Features\Keys', function () {
return [
'psr7-router',
'es-feeds',
'helpdesk',
'top-feeds',
'cassandra-notifications',
'dark-mode',
'allow-comments-toggle',
'permissions',
'pro',
'purchase-pro',
'webtorrent',
'top-feeds-by-age',
'homepage-december-2019',
'onboarding-december-2019',
'register_pages-december-2019',
'modal-pager',
'blockchain_creditcard',
'channel-filter-feeds',
'suggested-users',
'top-feeds-filter',
'media-modal',
'wire-multi-currency',
'cdn-jwt',
'post-scheduler',
'navigation-2020',
'wallet-upgrade'
];
});
$this->di->bind('Features\Manager', function ($di) {
return new Manager();
}, [ 'useFactory'=> true ]);
$this->di->bind('Features\Canary', function ($di) {
return new Canary();
}, [ 'useFactory'=> true ]);
}
}
<?php
/**
* BaseService
*
* @author edgebal
*/
namespace Minds\Core\Features\Services;
use Minds\Entities\User;
/**
* Base service to be used when building integrations
* @package Minds\Core\Features\Services
*/
abstract class BaseService implements ServiceInterface
{
/** @var User */
protected $user;
/**
* @inheritDoc
* @param User|null $user
* @return ServiceInterface
*/
public function setUser(?User $user): ServiceInterface
{
$this->user = $user;
return $this;
}
/**
* Calculate user groups based on their state
* @return array
*/
public function getUserGroups(): array
{
$groups = [];
if (!$this->user) {
$groups[] = 'anonymous';
} else {
$groups[] = 'authenticated';
if ($this->user->isAdmin()) {
$groups[] = 'admin';
}
if ($this->user->isCanary()) {
$groups[] = 'canary';
}
if ($this->user->isPro()) {
$groups[] = 'pro';
}
if ($this->user->isPlus()) {
$groups[] = 'plus';
}
}
return $groups;
}
}
<?php
/**
* Config
*
* @author edgebal
*/
namespace Minds\Core\Features\Services;
use InvalidArgumentException;
use Minds\Core\Config as MindsConfig;
use Minds\Core\Di\Di;
/**
* Static config (settings.php) feature flags service
* @package Minds\Core\Features\Services
*/
class Config extends BaseService
{
/** @var MindsConfig */
protected $config;
/**
* Config constructor.
* @param Config $config
*/
public function __construct(
$config = null
) {
$this->config = $config ?: Di::_()->get('Config');
}
/**
* @inheritDoc
*/
public function sync(int $ttl): bool
{
// No need for sync
return true;
}
/**
* @inheritDoc
*/
public function fetch(array $keys): array
{
// Return whitelisted 'features' array with its values resolved
return array_intersect_key(
array_map(
[$this, '_resolveValue'],
$this->config->get('features') ?: []
),
array_flip($keys)
);
}
/**
* Resolve strings to groups. Boolean are returned as is. Other types throw an exception.
* @param mixed $value
* @return bool
* @throws InvalidArgumentException
*/
protected function _resolveValue($value): bool
{
if (is_string($value)) {
return in_array(strtolower($value), $this->getUserGroups(), true);
} elseif (is_bool($value)) {
return $value;
}
throw new InvalidArgumentException();
}
}
<?php
/**
* Environment
*
* @author edgebal
*/
namespace Minds\Core\Features\Services;
/**
* Environment variables ($_ENV) feature flags service
* @package Minds\Core\Features\Services
*/
class Environment extends BaseService
{
/** @var array|null */
protected $global = null;
/**
* @param array $global
* @return Environment
*/
public function setGlobal(?array $global): Environment
{
$this->global = $global;
return $this;
}
/**
* @inheritDoc
*/
public function sync(int $ttl): bool
{
// No need for sync
return true;
}
/**
* @inheritDoc
*/
public function fetch(array $keys): array
{
$output = [];
$global = $this->global ?? $_ENV;
foreach ($keys as $key) {
// Convert to variable name
// Example: `webtorrent` would be `MINDS_FEATURE_WEBTORRENT`; `psr7-router` would be `MINDS_FEATURE_PSR7_ROUTER`
$envName = sprintf('MINDS_FEATURE_%s', strtoupper(preg_replace('/[^a-zA-Z0-9]+/', '_', $key)));
if (isset($global[$envName])) {
// Read value as string
$value = (string) $global[$envName];
// Resolve group, if not 0 or 1
if (strlen($value) > 0 && $value !== '0' && $value !== '1') {
$value = in_array(strtolower($value), $this->getUserGroups(), true);
}
// Set value
$output[$key] = (bool) $value;
}
}
return $output;
}
}
<?php
/**
* ServiceInterface
*
* @author edgebal
*/
namespace Minds\Core\Features\Services;
use Minds\Entities\User;
interface ServiceInterface
{
/**
* Sets the current user to calculate context values
* @param User|null $user
* @return ServiceInterface
*/
public function setUser(?User $user): ServiceInterface;
/**
* Synchronizes and caches the service's schema/data, if needed
* @param int $ttl
* @return bool
*/
public function sync(int $ttl): bool;
/**
* Fetches the whole feature flag set
* @param string[] $keys Array of whitelisted keys
* @return array
*/
public function fetch(array $keys): array;
}
<?php
/**
* Unleash
*
* @author edgebal
*/
namespace Minds\Core\Features\Services;
use Exception;
use Minds\Core\Config;
use Minds\Core\Di\Di;
use Minds\Core\Features\Services\Unleash\Entity;
use Minds\Core\Features\Services\Unleash\Repository;
use Minds\UnleashClient\Exceptions\InvalidFeatureImplementationException;
use Minds\UnleashClient\Exceptions\InvalidFeatureNameException;
use Minds\UnleashClient\Exceptions\InvalidFeaturesArrayException;
use Minds\UnleashClient\Exceptions\InvalidStrategyImplementationException;
use Minds\UnleashClient\Exceptions\NoContextException;
use Minds\UnleashClient\Factories\FeatureArrayFactory as UnleashFeatureArrayFactory;
use Minds\UnleashClient\Entities\Context;
use Minds\UnleashClient\Unleash as UnleashResolver;
use Minds\UnleashClient\Http\Client as UnleashClient;
/**
* Unleash server (GitLab FF) feature flags service
* @package Minds\Core\Features\Services
*/
class Unleash extends BaseService
{
/** @var Config */
protected $config;
/** @var Repository */
protected $repository;
/** @var UnleashResolver */
protected $unleashResolver;
/** @var UnleashFeatureArrayFactory */
protected $unleashFeatureArrayFactory;
/** @var UnleashClient */
protected $unleashClient;
/**
* Unleash constructor.
* @param Config $config
* @param Repository $repository
* @param UnleashResolver $unleashResolver
* @param UnleashFeatureArrayFactory $unleashFeatureArrayFactory
* @param UnleashClient $unleashClient
*/
public function __construct(
$config = null,
$repository = null,
$unleashResolver = null,
$unleashFeatureArrayFactory = null,
$unleashClient = null
) {
$this->config = $config ?: Di::_()->get('Config');
$this->repository = $repository ?: new Repository();
$this->unleashResolver = $unleashResolver ?: new UnleashResolver(Di::_()->get('Logger\Singleton'));
$this->unleashFeatureArrayFactory = $unleashFeatureArrayFactory ?: new UnleashFeatureArrayFactory();
$this->unleashClient = $unleashClient ?: (new Unleash\ClientFactory($this->config, Di::_()->get('Logger\Singleton')))->build();
}
/**
* @inheritDoc
* @throws Exception
*/
public function sync(int $ttl): bool
{
$registered = $this->unleashClient->register();
if (!$registered) {
throw new Exception('Could not register Unleash client');
}
$now = time();
$features = $this->unleashClient->fetch();
foreach ($features as $feature) {
$entity = new Entity();
$entity
->setId($feature['name'])
->setData($feature)
->setCreatedAt($now)
->setStaleAt($now + $ttl);
$this->repository
->add($entity);
}
return true;
}
/**
* @inheritDoc
* @param array $keys
* @return array
* @throws InvalidFeatureImplementationException
* @throws InvalidFeatureNameException
* @throws InvalidFeaturesArrayException
* @throws InvalidStrategyImplementationException
* @throws NoContextException
*/
public function fetch(array $keys): array
{
$context = new Context();
$context
->setUserGroups($this->getUserGroups())
->setRemoteAddress($_SERVER['REMOTE_ADDR'] ?? '')
->setHostName($_SERVER['HTTP_HOST'] ?? '');
if ($this->user) {
$context
->setUserId((string) $this->user->guid);
}
// Read features from local repository
$features = $this->unleashFeatureArrayFactory
->build(
$this->repository
->getAllData()
->toArray()
);
// Return whitelisted 'features' array with its values resolved
return array_intersect_key(
$this->unleashResolver
->setContext($context)
->setFeatures($features)
->export(),
array_flip($keys)
);
}
}
<?php
/**
* ClientFactory
*
* @author edgebal
*/
namespace Minds\Core\Features\Services\Unleash;
use Minds\Core\Config as MindsConfig;
use Minds\Core\Di\Di;
use Minds\Core\Log\Logger;
use Minds\UnleashClient\Http\Client;
use Minds\UnleashClient\Http\Config;
class ClientFactory
{
/** @var MindsConfig */
protected $config;
/** @var Logger */
protected $logger;
/**
* ClientFactory constructor.
* @param MindsConfig $config
* @param Logger $logger
*/
public function __construct(
$config = null,
$logger = null
) {
$this->config = $config ?: Di::_()->get('Config');
$this->logger = $logger ?: Di::_()->get('Logger\Singleton');
}
/**
* Builds an Unleash Client using environment configuration
* @return Client
*/
public function build(): Client
{
$configValues = $this->config->get('unleash');
$config = new Config(
getenv('UNLEASH_API_URL') ?: ($configValues['apiUrl'] ?? null),
getenv('UNLEASH_INSTANCE_ID') ?: ($configValues['instanceId'] ?? null),
getenv('MINDS_ENV') ?: ($configValues['applicationName'] ?? 'development'),
$configValues['pollingIntervalSeconds'] ?? null,
$configValues['metricsIntervalSeconds'] ?? null
);
return new Client($config, $this->logger);
}
}
<?php
/**
* Entity
*
* @author edgebal
*/
namespace Minds\Core\Features\Services\Unleash;
use Minds\Traits\MagicAttributes;
/**
* Entity for cached feature flags
* @package Minds\Core\Features\Services\Unleash
* @method string getId()
* @method Entity setId(string $id)
* @method array getData()
* @method Entity setData(array $data)
* @method int getCreatedAt()
* @method Entity setCreatedAt(int $createdAt)
* @method int getStaleAt()
* @method Entity setStaleAt(int $staleAt)
*/
class Entity
{
use MagicAttributes;
/** @var string */
protected $id;
/** @var array */
protected $data;
/** @var int */
protected $createdAt;
/** @var int */
protected $staleAt;
}
<?php
/**
* Repository
*
* @author edgebal
*/
namespace Minds\Core\Features\Services\Unleash;
use Cassandra\Timestamp;
use Exception;
use Minds\Common\Repository\Response;
use Minds\Core\Data\Cassandra\Client;
use Minds\Core\Data\Cassandra\Prepared\Custom;
use Minds\Core\Di\Di;
use Minds\Helpers\Log;
use NotImplementedException;
class Repository
{
/** @var Client */
protected $db;
/**
* Repository constructor.
* @param Client $db
*/
public function __construct(
$db = null
) {
$this->db = $db ?: Di::_()->get('Database\Cassandra\Cql');
}
/**
* Returns a list of all feature toggles cached in Cassandra
* @return Response
*/
public function getList(): Response
{
$cql = "SELECT * FROM feature_toggles_cache";
$prepared = new Custom();
$prepared->query($cql);
$response = new Response();
try {
$rows = $this->db->request($prepared);
foreach ($rows ?: [] as $row) {
$entity = new Entity();
$entity
->setId($row['id'])
->setData(json_decode($row['data'], true))
->setCreatedAt($row['created_at']->time())
->setStaleAt($row['stale_at']->time());
$response[] = $entity;
}
} catch (Exception $e) {
Log::warning($e);
}
return $response;
}
/**
* Shortcut method that casts all the data from getList() entities
* @return Response
*/
public function getAllData(): Response
{
return $this->getList()->map(function (Entity $entity) {
return $entity->getData();
});
}
/**
* Adds a new feature toggle entity to Cassandra
* @param Entity $entity
* @return bool
* @throws Exception
*/
public function add(Entity $entity): bool
{
if (!$entity->getId()) {
throw new Exception('Invalid Unleash entity name');
}
$cql = "INSERT INTO feature_toggles_cache (id, data, created_at, stale_at) VALUES (?, ?, ?, ?)";
$values = [
(string) $entity->getId(),
(string) json_encode($entity->getData()),
new Timestamp($entity->getCreatedAt()),
new Timestamp($entity->getStaleAt())
];
$prepared = new Custom();
$prepared->query($cql, $values);
try {
return (bool) $this->db->request($prepared, true);
} catch (Exception $e) {
Log::warning($e);
return false;
}
}
/**
* Shortcut to add
* @param Entity $entity
* @return bool
* @throws Exception
*/
public function update(Entity $entity): bool
{
return $this->add($entity);
}
/**
* Deletes an entity. Not implemented.
* @param string $id
* @return bool
* @throws NotImplementedException
*/
public function delete(string $id): bool
{
throw new NotImplementedException();
}
}
......@@ -38,7 +38,7 @@ class Repository
$config = $config ?: Di::_()->get('Config');
$this->features = $features ?: Di::_()->get('Features');
$this->features = $features ?: Di::_()->get('Features\Manager');
$this->index = $config->get('elasticsearch')['index'];
}
......
......@@ -8,7 +8,7 @@
namespace Minds\Core\Feeds;
use JsonSerializable;
use Minds\Traits\Exportable;
use Minds\Entities\Entity;
use Minds\Traits\MagicAttributes;
/**
......@@ -22,6 +22,8 @@ use Minds\Traits\MagicAttributes;
* @method FeedSyncEntity setTimestamp(int $timestamp)
* @method string getUrn()
* @method FeedSyncEntity setUrn(string $urn)
* @method Entity getEntity()
* @method void setEntity(Entity $entity)
*/
class FeedSyncEntity implements JsonSerializable
{
......@@ -53,7 +55,7 @@ class FeedSyncEntity implements JsonSerializable
{
return [
'guid' => (string) $this->guid,
'owner_guid' => (string) $this->ownerGuid,
'owner_guid' => (string) $this->ownerGuid,
'timestamp' => $this->timestamp,
'urn' => $this->urn,
'entity' => $this->entity ? $this->entity->export() : null,
......
......@@ -36,13 +36,17 @@ class Logger extends MonologLogger
$options = array_merge([
'isProduction' => true,
'devToolsLogger' => '',
'minLogLevel' => null,
], $options);
$isProduction = (bool) $options['isProduction'];
$level = $options['minLogLevel'] ?? MonologLogger::WARNING;
$handlers = [];
$errorLogHandler = new ErrorLogHandler(
ErrorLogHandler::OPERATING_SYSTEM,
$options['isProduction'] ? MonologLogger::INFO : MonologLogger::DEBUG,
$level,
true,
true
);
......@@ -51,29 +55,30 @@ class Logger extends MonologLogger
->setFormatter(new LineFormatter(
"%channel%.%level_name%: %message% %context% %extra%\n",
'c',
!$options['isProduction'], // Allow newlines on dev mode
!$isProduction, // Allow newlines on dev mode
true
));
$handlers[] = $errorLogHandler;
if ($options['isProduction']) {
$handlers[] = new SentryHandler(SentrySdk::getCurrentHub());
if ($isProduction) {
// Do _NOT_ send INFO or DEBUG
$handlers[] = new SentryHandler(SentrySdk::getCurrentHub(), max($level, MonologLogger::WARNING));
} else {
// Extra handlers for Development Mode
switch ($options['devToolsLogger']) {
case 'firephp':
$handlers[] = new FirePHPHandler();
$handlers[] = new FirePHPHandler($level);
break;
case 'chromelogger':
$handlers[] = new ChromePHPHandler();
$handlers[] = new ChromePHPHandler($level);
break;
case 'phpconsole':
try {
$handlers[] = new PHPConsoleHandler();
$handlers[] = new PHPConsoleHandler(null, null, $level);
} catch (Exception $exception) {
// If the server-side vendor package is not installed, ignore any warnings.
}
......
......@@ -29,6 +29,7 @@ class Provider extends DiProvider
$options = [
'isProduction' => $config ? !$config->get('development_mode') : true,
'devToolsLogger' => $config ? $config->get('devtools_logger') : '',
'minLogLevel' => $config ? $config->get('min_log_level') : null,
];
return new Logger('Minds', $options);
......
......@@ -18,6 +18,7 @@ class Minds extends base
private $modules = [
Log\Module::class,
Events\Module::class,
Features\Module::class,
SSO\Module::class,
Email\Module::class,
Experiments\Module::class,
......@@ -105,7 +106,6 @@ class Minds extends base
(new Groups\GroupsProvider())->register();
(new Search\SearchProvider())->register();
(new Votes\VotesProvider())->register();
(new Features\FeaturesProvider())->register();
(new SMS\SMSProvider())->register();
(new Blockchain\BlockchainProvider())->register();
(new Issues\IssuesProvider())->register();
......
......@@ -186,4 +186,26 @@ class Manager
return true;
}
/**
* Cancels all subscriptions to a specific entity guid
* @param string $fromGuid
* @param string $entityGuid
* @return bool
*/
public function cancelSubscriptions($fromGuid, $entityGuid): bool
{
$subscriptions = $this->repository
->getList([
'user_guid' => $fromGuid
]);
foreach ($subscriptions as $subscription) {
if ($subscription->getEntity()->getGuid() === $entityGuid) {
$this->repository->delete($subscription);
}
}
return true;
}
}
......@@ -18,6 +18,7 @@ class Subscription
private $id;
private $customer;
private $merchant;
private $entity;
private $fee;
private $quantity = 1;
......@@ -185,7 +186,7 @@ class Subscription
$this->interval = $interval;
return $this;
}
public function getInterval()
{
return $this->interval;
......
<?php
/**
* Handles pro subscriptions
* @author mark
*/
namespace Minds\Core\Pro\Delegates;
use Minds\Entities\User;
use Minds\Core\Config;
use Minds\Core\Di\Di;
use Minds\Core\Payments\Subscriptions;
class SubscriptionDelegate
{
/** @var Subscriptions\Manager */
private $subscriptionsManager;
/** @var Config */
private $config;
public function __construct($subscriptionsManager = null, $config = null)
{
$this->subscriptionsManager = $subscriptionsManager ?? Di::_()->get('Payments\Subscriptions\Manager');
$this->config = $config ?? Di::_()->get('Config');
}
/**
* Called when pro is disabled
* @param User $user
* @return void
*/
public function onDisable(User $user): void
{
$proUserGuid = (string) $this->config->get('pro')['handler'];
$this->subscriptionsManager
->cancelSubscriptions($user->getGuid(), $proUserGuid);
}
}
......@@ -32,6 +32,9 @@ class Manager
/** @var Delegates\SetupRoutingDelegate */
protected $setupRoutingDelegate;
/** @var Delegates\SubscriptionDelegate */
protected $subscriptionDelegate;
/** @var User */
protected $user;
......@@ -47,6 +50,7 @@ class Manager
* @param Delegates\InitializeSettingsDelegate $initializeSettingsDelegate
* @param Delegates\HydrateSettingsDelegate $hydrateSettingsDelegate
* @param Delegates\SetupRoutingDelegate $setupRoutingDelegate
* @param Delegates\SubscriptionDelegate $subscriptionDelegate
*/
public function __construct(
$repository = null,
......@@ -54,7 +58,8 @@ class Manager
$entitiesBuilder = null,
$initializeSettingsDelegate = null,
$hydrateSettingsDelegate = null,
$setupRoutingDelegate = null
$setupRoutingDelegate = null,
$subscriptionDelegate = null
) {
$this->repository = $repository ?: new Repository();
$this->saveAction = $saveAction ?: new Save();
......@@ -62,6 +67,7 @@ class Manager
$this->initializeSettingsDelegate = $initializeSettingsDelegate ?: new Delegates\InitializeSettingsDelegate();
$this->hydrateSettingsDelegate = $hydrateSettingsDelegate ?: new Delegates\HydrateSettingsDelegate();
$this->setupRoutingDelegate = $setupRoutingDelegate ?: new Delegates\SetupRoutingDelegate();
$this->subscriptionDelegate = $subscriptionDelegate ?: new Delegates\SubscriptionDelegate();
}
/**
......@@ -131,7 +137,8 @@ class Manager
throw new Exception('Invalid user');
}
// TODO: Disable subscription instead, let Pro expire itself at the end of the sub
$this->subscriptionDelegate
->onDisable($this->user);
$this->user
->setProExpires(0);
......
......@@ -1567,4 +1567,11 @@ CREATE TABLE minds.video_transcodes (
PRIMARY KEY (guid, profile_id)
);
ALTER TABLE minds.video_transcodes ADD failure_reason text;
\ No newline at end of file
ALTER TABLE minds.video_transcodes ADD failure_reason text;
CREATE TABLE minds.feature_toggles_cache (
id text PRIMARY KEY,
created_at timestamp,
data text,
stale_at timestamp
);
......@@ -37,7 +37,7 @@ class Router
$fallback = null
) {
$this->dispatcher = $dispatcher ?: Di::_()->get('Router');
$this->features = $features ?: Di::_()->get('Features');
$this->features = $features ?: Di::_()->get('Features\Manager');
$this->fallback = $fallback ?: new Fallback();
}
......
......@@ -20,7 +20,7 @@ class Hot implements SortingAlgorithm
public function __construct($features = null)
{
$this->features = $features ?? Di::_()->get('Features');
$this->features = $features ?? Di::_()->get('Features\Manager');
}
......
......@@ -17,6 +17,10 @@ class XSRF
public static function validateRequest()
{
if (!Core\Session::isLoggedIn() && $_SERVER['REQUEST_METHOD'] === 'GET') {
return true; // If logged out and GET request we can accept
}
if (!isset($_SERVER['HTTP_X_XSRF_TOKEN'])) {
return false;
}
......
<?php
/**
* ActiveSession
*
* @author edgebal
*/
namespace Minds\Core\Sessions;
use Minds\Core\Session as CoreSession;
use Minds\Entities\User;
/**
* Allow using an instance-based dependency to retrieve
* the currently logged-in user
* @package Minds\Core\Sessions
*/
class ActiveSession
{
/**
* Gets the currently logged in user
* @return User|null
*/
public function getUser(): ?User
{
return CoreSession::getLoggedinUser() ?: null;
}
}
......@@ -15,5 +15,9 @@ class SessionsProvider extends Provider
$this->di->bind('Sessions\Manager', function ($di) {
return new Manager;
}, ['useFactory'=>true]);
$this->di->bind('Sessions\ActiveSession', function ($di) {
return new ActiveSession();
}, ['useFactory'=>true]);
}
}
......@@ -75,7 +75,7 @@ class EmailRewards
$validator = $_GET['validator'];
//$key = '.md';
//return;
if ($validator == sha1($campaign . 'gift-30-10-19.mdl' . $topic . $user->guid . Config::_()->get('emails_secret'))) {
if ($validator == sha1($campaign . 'gift-29-01-20.mdl' . $topic . $user->guid . Config::_()->get('emails_secret'))) {
$tokens = 1 * (10 ** 18);
$campaign = $validator; //hack
} else {
......
......@@ -165,4 +165,4 @@
}
}
}
}
\ No newline at end of file
}
<?php
namespace Spec\Minds\Core\Features;
use Minds\Common\Cookie;
use Minds\Core\Features\Canary;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;
class CanarySpec extends ObjectBehavior
{
/** @var Cookie */
protected $cookie;
public function let(
Cookie $cookie
) {
$this->cookie = $cookie;
$this->beConstructedWith($cookie);
}
public function it_is_initializable()
{
$this->shouldHaveType(Canary::class);
}
public function it_should_set_cookie_enabled()
{
$this->cookie->setName('canary')
->shouldBeCalled()
->willReturn($this->cookie);
$this->cookie->setValue(1)
->shouldBeCalled()
->willReturn($this->cookie);
$this->cookie->setExpire(0)
->shouldBeCalled()
->willReturn($this->cookie);
$this->cookie->setSecure(true)
->shouldBeCalled()
->willReturn($this->cookie);
$this->cookie->setHttpOnly(true)
->shouldBeCalled()
->willReturn($this->cookie);
$this->cookie->setPath('/')
->shouldBeCalled()
->willReturn($this->cookie);
$this->cookie->create()
->shouldBeCalled()
->willReturn(true);
$this
->setCookie(true)
->shouldReturn(true);
}
public function it_should_set_cookie_disabled()
{
$this->cookie->setName('canary')
->shouldBeCalled()
->willReturn($this->cookie);
$this->cookie->setValue(0)
->shouldBeCalled()
->willReturn($this->cookie);
$this->cookie->setExpire(0)
->shouldBeCalled()
->willReturn($this->cookie);
$this->cookie->setSecure(true)
->shouldBeCalled()
->willReturn($this->cookie);
$this->cookie->setHttpOnly(true)
->shouldBeCalled()
->willReturn($this->cookie);
$this->cookie->setPath('/')
->shouldBeCalled()
->willReturn($this->cookie);
$this->cookie->create()
->shouldBeCalled()
->willReturn(true);
$this
->setCookie(false)
->shouldReturn(true);
}
}
......@@ -2,21 +2,39 @@
namespace Spec\Minds\Core\Features;
use Minds\Core\Config;
use Minds\Core\Di\Di;
use Minds\Core\Features\Exceptions\FeatureNotImplementedException;
use Minds\Core\Features\Manager;
use Minds\Core\Features\Services\ServiceInterface;
use Minds\Core\Sessions\ActiveSession;
use Minds\Entities\User;
use PhpSpec\Exception\Example\FailureException;
use PhpSpec\ObjectBehavior;
class ManagerSpec extends ObjectBehavior
{
/** @var Config */
protected $config;
public function let(Config $config)
{
$this->beConstructedWith($config);
$this->config = $config;
/** @var ServiceInterface */
protected $service1;
/** @var ServiceInterface */
protected $service2;
/** @var ActiveSession */
protected $activeSession;
public function let(
ServiceInterface $service1,
ServiceInterface $service2,
ActiveSession $activeSession
) {
$this->service1 = $service1;
$this->service2 = $service2;
$this->activeSession = $activeSession;
$this->beConstructedWith(
[ $service1, $service2 ],
$activeSession,
['feature1', 'feature2', 'feature3']
);
}
public function it_is_initializable()
......@@ -24,72 +42,181 @@ class ManagerSpec extends ObjectBehavior
$this->shouldHaveType(Manager::class);
}
public function it_should_check_if_a_feature_exists_unsuccessfully_and_assume_its_inactive()
public function it_should_sync()
{
$this->config->get('features')
$this->service1->sync(30)
->shouldBeCalled()
->willReturn(['plus' => true, 'wire' => false]);
->willReturn(true);
$this->has('boost')->shouldReturn(false);
$this->service2->sync(30)
->shouldBeCalled()
->willReturn(false);
$this
->sync(30)
->shouldBeAnIterator([
get_class($this->service1->getWrappedObject()) => 'OK',
get_class($this->service2->getWrappedObject()) => 'NOT SYNC\'D',
]);
}
public function it_should_check_if_a_feature_exists_and_return_its_deactivated()
{
$this->config->get('features')
public function it_should_throw_during_has_if_a_feature_does_not_exist(
User $user
) {
$this->activeSession->getUser()
->shouldBeCalled()
->willReturn(['plus' => true, 'wire' => false]);
->willReturn($user);
$this->has('wire')->shouldReturn(false);
$this->service1->setUser($user)
->shouldBeCalled()
->willReturn($this->service1);
$this->service1->fetch(['feature1', 'feature2', 'feature3'])
->shouldBeCalled()
->willReturn([
'feature1' => true,
'feature2' => false,
]);
$this->service2->setUser($user)
->shouldBeCalled()
->willReturn($this->service2);
$this->service2->fetch(['feature1', 'feature2', 'feature3'])
->shouldBeCalled()
->willReturn([
'feature2' => true,
]);
$this
->shouldThrow(FeatureNotImplementedException::class)
->duringHas('feature99-non-existant');
}
public function it_should_check_if_a_user_is_active_for_an_admin_and_return_true(User $user)
{
$user->isAdmin()
public function it_should_return_false_if_a_feature_exists_and_it_is_deactivated(
User $user
) {
$this->activeSession->getUser()
->shouldBeCalled()
->willReturn(true);
->willReturn($user);
//remove ip whitelist check
$_SERVER['HTTP_X_FORWARDED_FOR'] = '10.56.0.1';
$this->service1->setUser($user)
->shouldBeCalled()
->willReturn($this->service1);
$this->setUser($user);
$this->service1->fetch(['feature1', 'feature2', 'feature3'])
->shouldBeCalled()
->willReturn([
'feature1' => true,
'feature2' => false,
]);
$this->config->get('features')
$this->service2->setUser($user)
->shouldBeCalled()
->willReturn(['plus' => true, 'wire' => 'admin']);
->willReturn($this->service2);
$this->has('wire')->shouldReturn(true);
$this->service2->fetch(['feature1', 'feature2', 'feature3'])
->shouldBeCalled()
->willReturn([
'feature2' => true,
'feature3' => false,
]);
$this
->has('feature3')
->shouldReturn(false);
}
public function it_should_check_if_a_user_is_active_for_an_admin_and_return_false(User $user)
{
$user->guid = '1234';
$user->admin = false;
public function it_should_return_true_if_a_feature_exists_and_it_is_activated(
User $user
) {
$this->activeSession->getUser()
->shouldBeCalled()
->willReturn($user);
$this->setUser($user);
$this->service1->setUser($user)
->shouldBeCalled()
->willReturn($this->service1);
//$this->config->get('last_tos_update')
// ->shouldBeCalled()
// ->willReturn(123456);
$this->service1->fetch(['feature1', 'feature2', 'feature3'])
->shouldBeCalled()
->willReturn([
'feature1' => true,
'feature2' => false,
]);
$this->config->get('features')
$this->service2->setUser($user)
->shouldBeCalled()
->willReturn(['plus' => true, 'wire' => 'admin']);
->willReturn($this->service2);
$this->has('wire')->shouldReturn(false);
$this->service2->fetch(['feature1', 'feature2', 'feature3'])
->shouldBeCalled()
->willReturn([
'feature2' => true,
'feature3' => false,
]);
$this
->has('feature2')
->shouldReturn(true);
}
public function it_should_export_all_features()
{
$features = [
'plus' => true,
'wire' => 'admin',
'boost' => false,
];
public function it_should_export_a_merge_of_all_features(
User $user
) {
$this->activeSession->getUser()
->shouldBeCalled()
->willReturn($user);
$this->service1->setUser($user)
->shouldBeCalled()
->willReturn($this->service1);
$this->service1->fetch(['feature1', 'feature2', 'feature3'])
->shouldBeCalled()
->willReturn([
'feature1' => true,
'feature2' => false,
]);
$this->config->get('features')
$this->service2->setUser($user)
->shouldBeCalled()
->willReturn($features);
->willReturn($this->service2);
$this->service2->fetch(['feature1', 'feature2', 'feature3'])
->shouldBeCalled()
->willReturn([
'feature2' => true,
'feature3' => false,
]);
$this
->export()
->shouldReturn([
'feature1' => true,
'feature2' => true,
'feature3' => false,
]);
}
public function getMatchers(): array
{
$matchers = [];
$matchers['beAnIterator'] = function ($subject, $elements = null) {
if (!is_iterable($subject)) {
throw new FailureException("Subject should be an iterable");
}
$resolvedSubject = iterator_to_array($subject);
if ($elements !== null && $elements !== $resolvedSubject) {
throw new FailureException("Subject elements don't match");
}
return true;
};
$this->export()->shouldReturn($features);
return $matchers;
}
}
<?php
namespace Spec\Minds\Core\Features\Services;
use Minds\Core\Config as CoreConfig;
use Minds\Core\Features\Services\Config;
use Minds\Entities\User;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;
class ConfigSpec extends ObjectBehavior
{
/** @var CoreConfig */
protected $config;
public function let(
CoreConfig $config
) {
$this->config = $config;
$this->beConstructedWith($config);
}
public function it_is_initializable()
{
$this->shouldHaveType(Config::class);
}
public function it_should_sync()
{
$this
->sync(30)
->shouldReturn(true);
}
public function it_should_fetch(
User $user1,
User $user2
) {
$this->config->get('features')
->shouldBeCalled()
->willReturn([
'feature1' => true,
'feature2' => false,
'feature3' => 'admin',
'feature4' => 'canary',
'feature5' => 'plus',
'feature6' => 'pro',
'unused-feature' => true,
]);
$this
->fetch([
'feature1',
'feature2',
'feature3',
'feature4',
'feature5',
'feature6',
])
->shouldReturn([
'feature1' => true,
'feature2' => false,
'feature3' => false,
'feature4' => false,
'feature5' => false,
'feature6' => false,
]);
$user1->isCanary()
->willReturn(true);
$user1->isAdmin()
->willReturn(true);
$user1->isPlus()
->willReturn(false);
$user1->isPro()
->willReturn(false);
$this
->setUser($user1)
->fetch([
'feature1',
'feature2',
'feature3',
'feature4',
'feature5',
'feature6',
])
->shouldReturn([
'feature1' => true,
'feature2' => false,
'feature3' => true,
'feature4' => true,
'feature5' => false,
'feature6' => false,
]);
$user2->isCanary()
->willReturn(false);
$user2->isAdmin()
->willReturn(false);
$user2->isPlus()
->willReturn(true);
$user2->isPro()
->willReturn(true);
$this
->setUser($user2)
->fetch([
'feature1',
'feature2',
'feature3',
'feature4',
'feature5',
'feature6',
])
->shouldReturn([
'feature1' => true,
'feature2' => false,
'feature3' => false,
'feature4' => false,
'feature5' => true,
'feature6' => true,
]);
}
}
<?php
namespace Spec\Minds\Core\Features\Services;
use Minds\Core\Features\Services\Environment;
use Minds\Entities\User;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;
class EnvironmentSpec extends ObjectBehavior
{
public function it_is_initializable()
{
$this->shouldHaveType(Environment::class);
}
public function it_should_sync()
{
$this
->sync(30)
->shouldReturn(true);
}
public function it_should_fetch(
User $user1,
User $user2
) {
$global = [
'MINDS_FEATURE_FEATURE1' => '1',
'MINDS_FEATURE_FEATURE2' => '0',
'MINDS_FEATURE_FEATURE3' => 'admin',
'MINDS_FEATURE_FEATURE4' => 'canary',
'MINDS_FEATURE_FEATURE5' => 'plus',
'MINDS_FEATURE_FEATURE_6' => 'pro',
'MINDS_FEATURE_UNUSED_FEATURE' => '1',
];
$this
->setGlobal($global)
->fetch([
'feature1',
'feature2',
'feature3',
'feature4',
'feature5',
'feature-6',
])
->shouldReturn([
'feature1' => true,
'feature2' => false,
'feature3' => false,
'feature4' => false,
'feature5' => false,
'feature-6' => false,
]);
$user1->isCanary()
->willReturn(true);
$user1->isAdmin()
->willReturn(true);
$user1->isPlus()
->willReturn(false);
$user1->isPro()
->willReturn(false);
$this
->setUser($user1)
->setGlobal($global)
->fetch([
'feature1',
'feature2',
'feature3',
'feature4',
'feature5',
'feature-6',
])
->shouldReturn([
'feature1' => true,
'feature2' => false,
'feature3' => true,
'feature4' => true,
'feature5' => false,
'feature-6' => false,
]);
$user2->isCanary()
->willReturn(false);
$user2->isAdmin()
->willReturn(false);
$user2->isPlus()
->willReturn(true);
$user2->isPro()
->willReturn(true);
$this
->setUser($user2)
->setGlobal($global)
->fetch([
'feature1',
'feature2',
'feature3',
'feature4',
'feature5',
'feature-6',
])
->shouldReturn([
'feature1' => true,
'feature2' => false,
'feature3' => false,
'feature4' => false,
'feature5' => true,
'feature-6' => true,
]);
}
}
<?php
namespace Spec\Minds\Core\Features\Services;
use Minds\Common\Repository\Response;
use Minds\Core\Config;
use Minds\Core\Features\Services\Unleash;
use Minds\Core\Features\Services\Unleash\Repository;
use Minds\Entities\User;
use Minds\UnleashClient\Entities\Context;
use Minds\UnleashClient\Factories\FeatureArrayFactory as UnleashFeatureArrayFactory;
use Minds\UnleashClient\Http\Client as UnleashClient;
use Minds\UnleashClient\Unleash as UnleashResolver;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;
class UnleashSpec extends ObjectBehavior
{
/** @var Config */
protected $config;
/** @var Repository */
protected $repository;
/** @var UnleashResolver */
protected $unleashResolver;
/** @var UnleashFeatureArrayFactory */
protected $unleashFeatureArrayFactory;
/** @var UnleashClient */
protected $unleashClient;
public function let(
Config $config,
Repository $repository,
UnleashResolver $unleashResolver,
UnleashFeatureArrayFactory $unleashFeatureArrayFactory,
UnleashClient $unleashClient
) {
$this->config = $config;
$this->repository = $repository;
$this->unleashResolver = $unleashResolver;
$this->unleashFeatureArrayFactory = $unleashFeatureArrayFactory;
$this->unleashClient = $unleashClient;
$this->beConstructedWith(
$config,
$repository,
$unleashResolver,
$unleashFeatureArrayFactory,
$unleashClient
);
}
public function it_is_initializable()
{
$this->shouldHaveType(Unleash::class);
}
public function it_should_sync()
{
$this->unleashClient->register()
->shouldBeCalled()
->willReturn(true);
$this->unleashClient->fetch()
->shouldBeCalled()
->willReturn([
['name' => 'feature1'],
['name' => 'feature2'],
]);
$this->repository->add(Argument::type(Unleash\Entity::class))
->shouldBeCalledTimes(2)
->willReturn(true);
$this
->sync(30)
->shouldReturn(true);
}
public function it_should_fetch(
Response $response
) {
$featuresMock = ['featuresMock' . mt_rand()];
$resolvedFeaturesMock = ['resolvedFeaturesMock' . mt_rand()];
$this->repository->getAllData()
->shouldBeCalled()
->willReturn($response);
$response->toArray()
->shouldBeCalled()
->willReturn($featuresMock);
$this->unleashFeatureArrayFactory->build($featuresMock)
->shouldBeCalled()
->willReturn($resolvedFeaturesMock);
$this->unleashResolver->setFeatures($resolvedFeaturesMock)
->shouldBeCalled()
->willReturn($this->unleashResolver);
$this->unleashResolver->setContext(Argument::that(function (Context $context) {
return
$context->getUserId() === null &&
$context->getUserGroups() === ['anonymous']
;
}))
->shouldBeCalled()
->willReturn($this->unleashResolver);
$this->unleashResolver->export()
->shouldBeCalled()
->willReturn([
'feature1' => true,
'feature2' => false,
'feature3' => true,
'feature4' => true,
'feature5' => false,
'feature6' => false,
'unused-feature' => true,
]);
$this
->fetch([
'feature1',
'feature2',
'feature3',
'feature4',
'feature5',
'feature6',
])
->shouldReturn([
'feature1' => true,
'feature2' => false,
'feature3' => true,
'feature4' => true,
'feature5' => false,
'feature6' => false,
]);
}
public function it_should_fetch_with_user(
User $user,
Response $response
) {
$featuresMock = ['featuresMock' . mt_rand()];
$resolvedFeaturesMock = ['resolvedFeaturesMock' . mt_rand()];
$this->repository->getAllData()
->shouldBeCalled()
->willReturn($response);
$response->toArray()
->shouldBeCalled()
->willReturn($featuresMock);
$this->unleashFeatureArrayFactory->build($featuresMock)
->shouldBeCalled()
->willReturn($resolvedFeaturesMock);
$this->unleashResolver->setFeatures($resolvedFeaturesMock)
->shouldBeCalled()
->willReturn($this->unleashResolver);
$user->get('guid')
->shouldBeCalled()
->willReturn(1000);
$user->isAdmin()
->shouldBeCalled()
->willReturn(true);
$user->isCanary()
->shouldBeCalled()
->willReturn(true);
$user->isPlus()
->shouldBeCalled()
->willReturn(true);
$user->isPro()
->shouldBeCalled()
->willReturn(true);
$this->unleashResolver->setContext(Argument::that(function (Context $context) {
return
$context->getUserId() === '1000' &&
$context->getUserGroups() === ['authenticated', 'admin', 'canary', 'pro', 'plus']
;
}))
->shouldBeCalled()
->willReturn($this->unleashResolver);
$this->unleashResolver->export()
->shouldBeCalled()
->willReturn([
'feature1' => true,
'feature2' => false,
'feature3' => true,
'feature4' => true,
'feature5' => false,
'feature6' => false,
'unused-feature' => true,
]);
$this
->setUser($user)
->fetch([
'feature1',
'feature2',
'feature3',
'feature4',
'feature5',
'feature6',
])
->shouldReturn([
'feature1' => true,
'feature2' => false,
'feature3' => true,
'feature4' => true,
'feature5' => false,
'feature6' => false,
]);
}
}
......@@ -5,6 +5,7 @@ namespace Spec\Minds\Core\Feeds\Elastic;
use Minds\Core\Config;
use Minds\Core\Data\ElasticSearch\Client;
use Minds\Core\Data\ElasticSearch\Prepared\Search;
use Minds\Core\Features\Manager as FeaturesManager;
use Minds\Core\Feeds\Elastic\MetricsSync;
use Minds\Core\Feeds\Elastic\Repository;
use PhpSpec\ObjectBehavior;
......@@ -18,16 +19,20 @@ class RepositorySpec extends ObjectBehavior
/** @var Config */
protected $config;
public function let(Client $client, Config $config)
/** @var FeaturesManager */
protected $features;
public function let(Client $client, Config $config, FeaturesManager $features)
{
$this->client = $client;
$this->config = $config;
$this->features = $features;
$config->get('elasticsearch')
->shouldBeCalled()
->willReturn(['index' => 'minds']);
$this->beConstructedWith($client, $config);
$this->beConstructedWith($client, $config, $features);
}
public function it_is_initializable()
......
......@@ -2,12 +2,26 @@
namespace Spec\Minds\Core\Notification;
use Minds\Core\Features\Manager as FeaturesManager;
use Minds\Core\Notification\Counters;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;
class CountersSpec extends ObjectBehavior
{
protected $sql;
protected $features;
public function let(
\PDO $sql,
FeaturesManager $features
) {
$this->sql = $sql;
$this->features = $features;
$this->beConstructedWith($sql, $features);
}
public function it_is_initializable()
{
$this->shouldHaveType(Counters::class);
......
......@@ -284,4 +284,22 @@ class ManagerSpec extends ObjectBehavior
->shouldThrow(new \Exception('Invalid recurring value'))
->duringGetNextBilling($last_billing, '^}invalid-recurring-value');
}*/
public function it_should_cancel_subscriptions()
{
$subscription = new Subscription();
$subscription->setUserGuid("123")
->setEntity((new User())->set("guid", "456"));
$this->repository->getList([
'user_guid' => "123"
])
->shouldBeCalled()
->willReturn([ $subscription ]);
$this->repository->delete($subscription)
->shouldBeCalled();
$this->cancelSubscriptions("123", "456");
}
}
<?php
namespace Spec\Minds\Core\Pro\Delegates;
use Minds\Core\Payments\Subscriptions;
use Minds\Core\Config;
use Minds\Entities\User;
use Minds\Core\Pro\Delegates\SubscriptionDelegate;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;
class SubscriptionDelegateSpec extends ObjectBehavior
{
private $subscriptionsManager;
private $config;
public function let(Subscriptions\Manager $subscriptionsManager, Config $config)
{
$this->beConstructedWith($subscriptionsManager, $config);
$this->subscriptionsManager = $subscriptionsManager;
$this->config = $config;
}
public function it_is_initializable()
{
$this->shouldHaveType(SubscriptionDelegate::class);
}
public function it_should_cancel_subscriptions_on_disable(User $user)
{
$user->getGUID()
->willReturn('123');
$this->config->get('pro')
->willReturn([
'handler' => "456"
]);
$this->subscriptionsManager
->cancelSubscriptions("123", "456")
->shouldBeCalled();
$this->onDisable($user);
}
}
<?php
namespace Spec\Minds\Core\Pro;
use Minds\Entities\User;
use Minds\Core\Pro\Manager;
use Minds\Core\Pro\Delegates\SubscriptionDelegate;
use Minds\Core\Entities\Actions\Save;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;
class ManagerSpec extends ObjectBehavior
{
private $subscriptionDelegate;
private $saveAction;
public function let(SubscriptionDelegate $subscriptionDelegate, Save $saveAction)
{
$this->beConstructedWith(null, $saveAction, null, null, null, null, $subscriptionDelegate);
$this->subscriptionDelegate = $subscriptionDelegate;
$this->saveAction = $saveAction;
}
public function it_is_initializable()
{
$this->shouldHaveType(Manager::class);
}
public function it_should_delete_subscription(User $user)
{
$this->setUser($user);
$this->subscriptionDelegate
->onDisable($user)
->shouldBeCalled();
$user->setProExpires(0)
->shouldBeCalled();
$this->saveAction
->setEntity($user)
->shouldBeCalled()
->willReturn($this->saveAction);
$this->saveAction
->save()
->shouldBeCalled()
->willReturn(true);
$this->disable();
}
}
This diff is collapsed.
......@@ -487,6 +487,7 @@ $CONFIG->set('features', [
'onboarding-december-2019' => true,
'register_pages-december-2019' => true,
'modal-pager' => true,
'wallet-upgrade' => false
]);
$CONFIG->set('email', [
......@@ -618,3 +619,11 @@ $CONFIG->set('email_confirmation', [
'signing_key' => '',
'expiration' => 172800, // 48 hours
]);
$CONFIG->set('unleash', [
'apiUrl' => '',
'instanceId' => '',
'applicationName' => '',
'pollingIntervalSeconds' => 300,
'metricsIntervalSeconds' => 15
]);