...
 
Commits (21)
......@@ -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
......
File added
<?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;
$environmentList = array_filter(explode(',', $this->getOpt('environment') ?: ''));
if (!$environmentList) {
throw new CliException('Specify an environment');
}
while (true /* Forever running task */) {
foreach ($environmentList as $environment) {
$this->out([
date('c'),
"TTL: {$ttl}",
"Environment: {$environment}"
], static::OUTPUT_PRE);
$sync = $manager
->setEnvironment($environment)
->sync($ttl);
foreach ($sync as $key => $output) {
$this->out(sprintf("Sync %s: %s", $key, $output));
}
}
if (!$this->getOpt('forever')) {
break;
}
$this->out("Done, sleeping {$ttl}s");
sleep($ttl);
}
}
}
......@@ -45,9 +45,9 @@ class register implements Interfaces\Api, Interfaces\ApiIgnorePam
}
try {
$captcha = Core\Di\Di::_()->get('Security\ReCaptcha');
$captcha->setAnswer($_POST['captcha']);
if (isset($_POST['captcha']) && !$captcha->validate()) {
$captcha = Core\Di\Di::_()->get('Captcha\Manager');
if (isset($_POST['captcha']) && !$captcha->verifyFromClientJson($_POST['captcha'])) {
throw new \Exception('Captcha failed');
}
......
<?php
/**
* features
*
* @author edgebal
*/
namespace Minds\Controllers\api\v2\admin;
use Exception;
use Minds\Api\Factory;
use Minds\Core\Features\Manager;
use Minds\Core\Session;
use Minds\Entities\User;
use Minds\Interfaces;
use Minds\Core\Di\Di;
class features implements Interfaces\Api, Interfaces\ApiAdminPam
{
/**
* @inheritDoc
*/
public function get($pages)
{
$for = null;
if (isset($_GET['for'])) {
try {
$for = new User(strtolower($_GET['for']));
if (!$for || !$for->guid) {
$for = null;
}
} catch (Exception $e) {
$for = null;
}
}
/** @var Manager $manager */
$manager = Di::_()->get('Features\Manager');
return Factory::response(
$manager->breakdown($for)
);
}
/**
* @inheritDoc
*/
public function post($pages)
{
return Factory::response([]);
}
/**
* @inheritDoc
*/
public function put($pages)
{
return Factory::response([]);
}
/**
* @inheritDoc
*/
public function delete($pages)
{
return Factory::response([]);
}
}
<?php
/**
* API for returning a captcha
*/
namespace Minds\Controllers\api\v2;
use Minds\Api\Factory;
use Minds\Common\Cookie;
use Minds\Core\Di\Di;
use Minds\Core\Config;
use Minds\Core\Session;
use Minds\Interfaces;
class captcha implements Interfaces\Api
{
public function get($pages)
{
$captchaManager = Di::_()->get('Captcha\Manager');
$captcha = $captchaManager->build();
return Factory::response(
$captcha->export()
);
}
public function post($pages)
{
return Factory::response([]);
}
public function put($pages)
{
return Factory::response([]);
}
public function delete($pages)
{
return Factory::response([]);
}
}
<?php
/**
* Captcha Model
*/
namespace Minds\Core\Captcha;
use Minds\Traits\MagicAttributes;
class Captcha
{
use MagicAttributes;
/** @var string */
private $jwtToken;
/** @var string */
private $clientText;
/** @var string */
private $base64Image;
/**
* Export the captcha
* @param array $extras
* @return array
*/
public function export(array $extras = []): array
{
return [
'jwt_token' => $this->jwtToken,
'base64_image' => $this->base64Image,
];
}
}
<?php
namespace Minds\Core\Captcha;
class ImageGenerator
{
/** @var int */
protected $width = 250;
/** @var int */
protected $height = 100;
/** @var string */
protected $text;
/**
* Set the width
* @param int $width
* @return self
*/
public function setWidth(int $width): self
{
$this->width = $width;
return $this;
}
/**
* Set the height
* @param int $height
* @return self
*/
public function setHeight(int $height): self
{
$this->height = $height;
return $this;
}
/**
* Set the text to output
* @param string $text
* @return self
*/
public function setText(string $text): self
{
$this->text = $text;
return $this;
}
/**
* Outputs the captcha image
* @return string
*/
public function build(): string
{
$image = imagecreatetruecolor($this->width, $this->height);
// Slight grey background
$backgroundColor = imagecolorallocate($image, 240, 240, 240);
// Builds the image background
imagefilledrectangle($image, 0, 0, $this->width, $this->height, $backgroundColor);
// Set the line thickness
imagesetthickness($image, 3);
// Dark grey lines
$lineColor = imagecolorallocate($image, 74, 74, 74);
$numberOfLines = rand(4, 10);
for ($i = 0; $i < $numberOfLines; $i++) {
imagesetthickness($image, rand(1, 3));
imageline($image, 0, rand() % $this->height, $this->width, rand() % $this->height, $lineColor);
}
for ($i = 0; $i< $this->width * 4; $i++) {
$pixelColor = imagecolorallocate($image, rand(0, 255), rand(0, 255), rand(0, 255));
imagesetpixel($image, rand() % $this->width, rand() % $this->height, $pixelColor);
}
$font = __MINDS_ROOT__ . '/Assets/fonts/Roboto-Medium.ttf';
$angle = rand(-6, 6);
$size = rand($this->height * 0.25, $this->height * 0.55);
$x = 10;
$y = ($this->height / 2) + ($size / 2);
$color = imagecolorallocate($image, 64, 64, 64);
// Write the text to the image
imagettftext($image, $size, $angle, $x, $y, $color, $font, $this->text);
ob_start();
imagepng($image);
$imagedata = ob_get_clean();
$base64 = base64_encode($imagedata);
imagedestroy($image);
return "data:image/png;base64,$base64";
}
}
<?php
namespace Minds\Core\Captcha;
use Minds\Common\Jwt;
use Minds\Core\Config;
use Minds\Core\Di\Di;
class Manager
{
/** @var ImageGenerator */
private $imageGenerator;
/** @var JWT */
private $jwt;
/** @var string */
private $secret;
public function __construct($imageGenerator = null, $jwt = null, $config = null)
{
$this->imageGenerator = $imageGenerator ?? new ImageGenerator;
$this->jwt = $jwt ?? new Jwt();
$config = $config ?? Di::_()->get('Config');
$this->secret = $config->get('captcha') ? $config->get('captcha')['jwt_secret'] : 'todo';
$this->jwt->setKey($this->secret);
}
/**
* Verify from client json
* @param string $json
* @return bool
*/
public function verifyFromClientJson(string $json): bool
{
$data = json_decode($json, true);
$captcha = new Captcha();
$captcha->setJwtToken($data['jwtToken'])
->setClientText($data['clientText']);
return $this->verify($captcha);
}
/**
* Verify if a captcha is valid
* @param Captcha $captcha
* @return bool
*/
public function verify(Captcha $captcha): bool
{
$jwtToken = $captcha->getJwtToken();
$decodedJwtToken = $this->jwt->decode($jwtToken);
$salt = $decodedJwtToken['salt'];
$hash = $decodedJwtToken['public_hash'];
// This is what the client has said the captcha image has
$clientText = $captcha->getClientText();
// Now convert this back to our hash
$clientHash = $this->buildCaptchaHash($clientText, $salt);
return $clientHash === $hash;
}
/**
* Output the captcha
* @param string $forcedText
* @return void
*/
public function build(string $forcedText = ''): Captcha
{
$text = $forcedText ?: $this->getRandomText(6);
$now = time();
$expires = $now + 300; // Captcha are good for 5 minutes
$salt = $this->jwt->randomString();
$jwtToken = $this->jwt
->setKey($this->secret)
->encode([
'public_hash' => $this->buildCaptchaHash($text, $salt),
'salt' => $salt,
], $expires, $now);
$image = $this->imageGenerator
->setText($text)
->build();
$captcha = new Captcha();
$captcha->setBase64Image($image)
->setJwtToken($jwtToken);
return $captcha;
}
/**
* Get the random text
* @param int $length
* @return sdtring
*/
protected function getRandomText(int $length): string
{
$chars = array_merge(
range(1, 9),
range('A', 'N'),
range('P', 'Z'), // We don't want O's or 0s
range('a', 'n'),
range('p', 'z')
);
shuffle($chars);
$text="";
for ($i = 0; $i < $length; $i++) {
$text .= $chars[array_rand($chars)];
}
return $text;
}
/**
* Return hash based on text and salt with a secret
* @param string $text
* @param string $salt
* @return string
*/
protected function buildCaptchaHash(string $text, string $salt): string
{
return hash('sha1', $text . $this->secret . $salt);
}
}
<?php
/**
* Captcha Module
*/
namespace Minds\Core\Captcha;
use Minds\Interfaces\ModuleInterface;
class Module implements ModuleInterface
{
/**
* OnInit.
*/
public function onInit()
{
$provider = new Provider();
$provider->register();
}
}
<?php
/**
* Minds Captcha Provider.
*/
namespace Minds\Core\Captcha;
use Minds\Core\Di\Provider as DiProvider;
class Provider extends DiProvider
{
public function register()
{
$this->di->bind('Captcha\Manager', function ($di) {
return new Manager();
}, ['useFactory' => true]);
}
}
......@@ -44,12 +44,14 @@ class Exported
$thirdPartyNetworks = null,
$i18n = 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');
}
......@@ -117,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;
}
}
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'] ?>) |
| |
......@@ -8,9 +8,11 @@
namespace Minds\Core\Features;
use Exception;
use Minds\Core\Di\Di;
use Minds\Core\Features\Exceptions\FeatureNotImplementedException;
use Minds\Core\Sessions\ActiveSession;
use Minds\Entities\User;
/**
* Features Manager
......@@ -27,17 +29,23 @@ class Manager
/** @var string[] */
protected $featureKeys;
/** @var string */
protected $environment;
/**
* Manager constructor.
* @param string $environment
* @param Services\ServiceInterface[] $services
* @param ActiveSession $activeSession
* @param string[] $features
*/
public function __construct(
$environment = null,
$services = null,
$activeSession = null,
array $features = null
) {
$this->environment = $environment;
$this->services = $services ?: [
new Services\Config(),
new Services\Unleash(),
......@@ -47,6 +55,46 @@ class Manager
$this->featureKeys = ($features ?? Di::_()->get('Features\Keys')) ?: [];
}
/**
* Sets the current environment
* @param string $environment
* @return Manager
*/
public function setEnvironment(string $environment): Manager
{
$this->environment = $environment;
return $this;
}
/**
* Gets the current environment based on overrides or environment variables
* @return string
*/
public function getEnvironment()
{
return $this->environment ?: getenv('MINDS_ENV') ?: 'development';
}
/**
* Synchronizes all services using their respective mechanisms
* @param int $ttl
* @return iterable
*/
public function sync(int $ttl): iterable
{
foreach ($this->services as $service) {
try {
$output = $service
->setEnvironment($this->getEnvironment())
->sync($ttl);
} catch (Exception $e) {
$output = $e;
}
yield get_class($service) => $output;
}
}
/**
* Checks if a feature is enabled
* @param string $feature
......@@ -86,6 +134,7 @@ class Manager
$features = array_merge(
$features,
$service
->setEnvironment($this->getEnvironment())
->setUser($this->activeSession->getUser())
->fetch($this->featureKeys)
);
......@@ -95,4 +144,64 @@ class Manager
return $features;
}
/**
* Breakdown for services, features and its individual values for certain user.
* Used by admin interface.
* @param User|null $for
* @return array
*/
public function breakdown(?User $for = null)
{
$env = $this->getEnvironment();
$output = [
'environment' => $env,
'for' => $for ? (string) $for->username : null,
'services' => [
'Default'
],
'features' => [],
];
$cache = [];
foreach ($this->featureKeys as $feature) {
$cache[$feature] = [
'Default' => false,
];
foreach ($this->services as $service) {
$cache[$feature][$service->getReadableName()] = null;
}
}
foreach ($this->services as $service) {
$output['services'][] = $service->getReadableName();
$features = [];
$features = array_merge(
$features,
$service
->setUser($for)
->setEnvironment($env)
->fetch($this->featureKeys)
);
foreach ($features as $feature => $value) {
$cache[$feature][$service->getReadableName()] = $value;
}
}
foreach ($cache as $name => $services) {
$output['features'][] = compact('name', 'services');
}
usort($output['features'], function ($a, $b) {
return $a['name'] <=> $b['name'];
});
return $output;
}
}
......@@ -49,10 +49,10 @@ class Provider extends DiProvider
$this->di->bind('Features\Manager', function ($di) {
return new Manager();
}, [ 'useFactory'=> true ]);
}, [ 'useFactory' => true ]);
$this->di->bind('Features\Canary', function ($di) {
return new Canary();
}, [ 'useFactory'=> true ]);
}, [ 'useFactory' => true ]);
}
}
......@@ -15,9 +15,23 @@ use Minds\Entities\User;
*/
abstract class BaseService implements ServiceInterface
{
/** @var string */
protected $environment;
/** @var User */
protected $user;
/**
* @inheritDoc
* @param string $environment
* @return ServiceInterface
*/
public function setEnvironment(string $environment): ServiceInterface
{
$this->environment = $environment;
return $this;
}
/**
* @inheritDoc
* @param User|null $user
......
......@@ -30,6 +30,23 @@ class Config extends BaseService
$this->config = $config ?: Di::_()->get('Config');
}
/**
* @inheritDoc
*/
public function getReadableName(): string
{
return '$CONFIG';
}
/**
* @inheritDoc
*/
public function sync(int $ttl): bool
{
// No need for sync
return true;
}
/**
* @inheritDoc
*/
......
......@@ -16,6 +16,14 @@ class Environment extends BaseService
/** @var array|null */
protected $global = null;
/**
* @inheritDoc
*/
public function getReadableName(): string
{
return 'EnvVars';
}
/**
* @param array $global
* @return Environment
......@@ -26,6 +34,15 @@ class Environment extends BaseService
return $this;
}
/**
* @inheritDoc
*/
public function sync(int $ttl): bool
{
// No need for sync
return true;
}
/**
* @inheritDoc
*/
......
......@@ -11,6 +11,19 @@ use Minds\Entities\User;
interface ServiceInterface
{
/**
* Readable name. Used for admin interface.
* @return string
*/
public function getReadableName(): string;
/**
* Sets the current interface to sync/fetch
* @param string $environment
* @return ServiceInterface
*/
public function setEnvironment(string $environment): ServiceInterface;
/**
* Sets the current user to calculate context values
* @param User|null $user
......@@ -18,6 +31,13 @@ interface 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
......
......@@ -7,11 +7,19 @@
namespace Minds\Core\Features\Services;
use Exception;
use Minds\Core\Config;
use Minds\Core\Di\Di;
use Minds\UnleashClient\Config as UnleashConfig;
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 UnleashClient;
use Minds\UnleashClient\Unleash as UnleashResolver;
/**
* Unleash server (GitLab FF) feature flags service
......@@ -22,47 +30,92 @@ class Unleash extends BaseService
/** @var Config */
protected $config;
/** @var UnleashClient */
protected $unleash;
/** @var Repository */
protected $repository;
/** @var UnleashResolver */
protected $unleashResolver;
/** @var UnleashFeatureArrayFactory */
protected $unleashFeatureArrayFactory;
/** @var Unleash\ClientFactory */
protected $unleashClientFactory;
/**
* Unleash constructor.
* @param Config $config
* @param UnleashClient $unleash
* @param Repository $repository
* @param UnleashResolver $unleashResolver
* @param UnleashFeatureArrayFactory $unleashFeatureArrayFactory
* @param Unleash\ClientFactory $unleashClientFactory
*/
public function __construct(
$config = null,
$unleash = null
$repository = null,
$unleashResolver = null,
$unleashFeatureArrayFactory = null,
$unleashClientFactory = null
) {
$this->config = $config ?: Di::_()->get('Config');
$this->unleash = $unleash ?: $this->initUnleashClient();
$this->repository = $repository ?: new Repository();
$this->unleashResolver = $unleashResolver ?: new UnleashResolver(Di::_()->get('Logger\Singleton'));
$this->unleashFeatureArrayFactory = $unleashFeatureArrayFactory ?: new UnleashFeatureArrayFactory();
$this->unleashClientFactory = $unleashClientFactory ?: new Unleash\ClientFactory($this->config, Di::_()->get('Logger\Singleton'));
}
/**
* Initializes Unleash client package
* @return UnleashClient
* @inheritDoc
*/
public function initUnleashClient(): UnleashClient
public function getReadableName(): string
{
$configValues = $this->config->get('unleash');
$config = new UnleashConfig(
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 'GitLab';
}
$logger = Di::_()->get('Logger\Singleton');
$cache = Di::_()->get('Cache\PsrWrapper');
/**
* @inheritDoc
* @throws Exception
*/
public function sync(int $ttl): bool
{
$client = $this->unleashClientFactory
->build($this->environment);
return new UnleashClient($config, $logger, null, $cache);
$registered = $client->register();
if (!$registered) {
throw new Exception('Could not register Unleash client');
}
$now = time();
$features = $client->fetch();
foreach ($features as $feature) {
$entity = new Entity();
$entity
->setEnvironment($this->environment)
->setFeatureName($feature['name'])
->setData($feature)
->setCreatedAt($now)
->setStaleAt($now + $ttl);
$this->repository
->add($entity);
}
return true;
}
/**
* @inheritDoc
* @throws \Psr\SimpleCache\InvalidArgumentException
* @param array $keys
* @return array
* @throws InvalidFeatureImplementationException
* @throws InvalidFeatureNameException
* @throws InvalidFeaturesArrayException
* @throws InvalidStrategyImplementationException
* @throws NoContextException
* @throws Exception
*/
public function fetch(array $keys): array
{
......@@ -77,11 +130,23 @@ class Unleash extends BaseService
->setUserId((string) $this->user->guid);
}
// Read features from local repository
$features = $this->unleashFeatureArrayFactory
->build(
$this->repository
->getAllData([
'environment' => $this->environment,
])
->toArray()
);
// Return whitelisted 'features' array with its values resolved
return array_intersect_key(
$this->unleash
$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
* @param string $environment
* @return Client
*/
public function build(?string $environment): Client
{
$environment = $environment ?: ($configValues['applicationName'] ?? 'development');
$this->logger->info(sprintf("Building Unleash Client for %s", $environment));
$configValues = $this->config->get('unleash');
$config = new Config(
getenv('UNLEASH_API_URL') ?: ($configValues['apiUrl'] ?? null),
getenv('UNLEASH_INSTANCE_ID') ?: ($configValues['instanceId'] ?? null),
$environment,
$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 getEnvironment()
* @method Entity setEnvironment(string $environment)
* @method string getFeatureName()
* @method Entity setFeatureName(string $featureName)
* @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 $environment;
/** @var string */
protected $featureName;
/** @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
* @param array $opts
* @return Response
* @throws Exception
*/
public function getList(array $opts = []): Response
{
$opts = array_merge([
'environment' => null,
], $opts);
if (!$opts['environment']) {
throw new Exception('Specify an environment');
}
$cql = "SELECT * FROM feature_toggles_cache_ns WHERE environment = ?";
$values = [
(string) $opts['environment']
];
$prepared = new Custom();
$prepared->query($cql, $values);
$response = new Response();
try {
$rows = $this->db->request($prepared);
foreach ($rows ?: [] as $row) {
$entity = new Entity();
$entity
->setEnvironment($row['environment'])
->setFeatureName($row['feature_name'])
->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
* @param array $opts getList() opts
* @return Response
* @throws Exception
*/
public function getAllData(array $opts = []): Response
{
return $this
->getList($opts)
->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->getEnvironment()) {
throw new Exception('Invalid Unleash entity namespace');
}
if (!$entity->getFeatureName()) {
throw new Exception('Invalid Unleash entity feature name');
}
$cql = "INSERT INTO feature_toggles_cache_ns (environment, feature_name, data, created_at, stale_at) VALUES (?, ?, ?, ?, ?)";
$values = [
(string) $entity->getEnvironment(),
(string) $entity->getFeatureName(),
(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();
}
}
......@@ -33,6 +33,7 @@ class Minds extends base
VideoChat\Module::class,
Feeds\Module::class,
Front\Module::class,
Captcha\Module::class,
];
/**
......
......@@ -1567,4 +1567,13 @@ 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_ns (
environment text,
feature_name text,
data text,
created_at timestamp,
stale_at timestamp,
PRIMARY KEY (environment, feature_name)
);
......@@ -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;
}
......
......@@ -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\Captcha;
use Minds\Core\Captcha\ImageGenerator;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;
class ImageGeneratorSpec extends ObjectBehavior
{
public function it_is_initializable()
{
$this->shouldHaveType(ImageGenerator::class);
}
}
<?php
namespace Spec\Minds\Core\Captcha;
use Minds\Core\Captcha\Manager;
use Minds\Core\Captcha\Captcha as CaptchaModel;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;
class ManagerSpec extends ObjectBehavior
{
public function it_is_initializable()
{
$this->shouldHaveType(Manager::class);
}
public function it_should_build_a_captcha()
{
$captcha = $this->build();
$jwtToken = $captcha->getJwtToken();
$jwtToken->shouldBeString();
}
public function it_should_verify_a_captcha()
{
$captcha = $this->build('abfu21')->getWrappedObject();
$captcha->setClientText('abfu21');
$this->verify($captcha)
->shouldBe(true);
}
public function it_should_verify_a_captcha_with_fail()
{
$captcha = $this->build();
$captcha->setClientText('abfu21');
$this->verify($captcha)
->shouldBe(false);
}
}
......@@ -2,13 +2,12 @@
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
......@@ -32,6 +31,7 @@ class ManagerSpec extends ObjectBehavior
$this->activeSession = $activeSession;
$this->beConstructedWith(
'phpspec',
[ $service1, $service2 ],
$activeSession,
['feature1', 'feature2', 'feature3']
......@@ -43,6 +43,32 @@ class ManagerSpec extends ObjectBehavior
$this->shouldHaveType(Manager::class);
}
public function it_should_sync()
{
$this->service1->setEnvironment('phpspec')
->shouldBeCalled()
->willReturn($this->service1);
$this->service1->sync(30)
->shouldBeCalled()
->willReturn(true);
$this->service2->setEnvironment('phpspec')
->shouldBeCalled()
->willReturn($this->service2);
$this->service2->sync(30)
->shouldBeCalled()
->willReturn(false);
$this
->sync(30)
->shouldBeAnIterator([
get_class($this->service1->getWrappedObject()) => true,
get_class($this->service2->getWrappedObject()) => false,
]);
}
public function it_should_throw_during_has_if_a_feature_does_not_exist(
User $user
) {
......@@ -50,6 +76,10 @@ class ManagerSpec extends ObjectBehavior
->shouldBeCalled()
->willReturn($user);
$this->service1->setEnvironment('phpspec')
->shouldBeCalled()
->willReturn($this->service1);
$this->service1->setUser($user)
->shouldBeCalled()
->willReturn($this->service1);
......@@ -61,6 +91,10 @@ class ManagerSpec extends ObjectBehavior
'feature2' => false,
]);
$this->service2->setEnvironment('phpspec')
->shouldBeCalled()
->willReturn($this->service2);
$this->service2->setUser($user)
->shouldBeCalled()
->willReturn($this->service2);
......@@ -83,6 +117,10 @@ class ManagerSpec extends ObjectBehavior
->shouldBeCalled()
->willReturn($user);
$this->service1->setEnvironment('phpspec')
->shouldBeCalled()
->willReturn($this->service1);
$this->service1->setUser($user)
->shouldBeCalled()
->willReturn($this->service1);
......@@ -94,6 +132,10 @@ class ManagerSpec extends ObjectBehavior
'feature2' => false,
]);
$this->service2->setEnvironment('phpspec')
->shouldBeCalled()
->willReturn($this->service2);
$this->service2->setUser($user)
->shouldBeCalled()
->willReturn($this->service2);
......@@ -117,6 +159,10 @@ class ManagerSpec extends ObjectBehavior
->shouldBeCalled()
->willReturn($user);
$this->service1->setEnvironment('phpspec')
->shouldBeCalled()
->willReturn($this->service1);
$this->service1->setUser($user)
->shouldBeCalled()
->willReturn($this->service1);
......@@ -128,6 +174,10 @@ class ManagerSpec extends ObjectBehavior
'feature2' => false,
]);
$this->service2->setEnvironment('phpspec')
->shouldBeCalled()
->willReturn($this->service2);
$this->service2->setUser($user)
->shouldBeCalled()
->willReturn($this->service2);
......@@ -151,6 +201,10 @@ class ManagerSpec extends ObjectBehavior
->shouldBeCalled()
->willReturn($user);
$this->service1->setEnvironment('phpspec')
->shouldBeCalled()
->willReturn($this->service1);
$this->service1->setUser($user)
->shouldBeCalled()
->willReturn($this->service1);
......@@ -162,6 +216,10 @@ class ManagerSpec extends ObjectBehavior
'feature2' => false,
]);
$this->service2->setEnvironment('phpspec')
->shouldBeCalled()
->willReturn($this->service2);
$this->service2->setUser($user)
->shouldBeCalled()
->willReturn($this->service2);
......@@ -181,4 +239,25 @@ class ManagerSpec extends ObjectBehavior
'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;
};
return $matchers;
}
}
......@@ -25,6 +25,13 @@ class ConfigSpec extends ObjectBehavior
$this->shouldHaveType(Config::class);
}
public function it_should_sync()
{
$this
->sync(30)
->shouldReturn(true);
}
public function it_should_fetch(
User $user1,
User $user2
......
......@@ -14,6 +14,13 @@ class EnvironmentSpec extends ObjectBehavior
$this->shouldHaveType(Environment::class);
}
public function it_should_sync()
{
$this
->sync(30)
->shouldReturn(true);
}
public function it_should_fetch(
User $user1,
User $user2
......
......@@ -2,11 +2,15 @@
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\Unleash as UnleashClient;
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;
......@@ -15,16 +19,38 @@ class UnleashSpec extends ObjectBehavior
/** @var Config */
protected $config;
/** @var UnleashClient */
protected $unleash;
/** @var Repository */
protected $repository;
/** @var UnleashResolver */
protected $unleashResolver;
/** @var UnleashFeatureArrayFactory */
protected $unleashFeatureArrayFactory;
/** @var Unleash\ClientFactory */
protected $unleashClientFactory;
public function let(
Config $config,
UnleashClient $unleash
Repository $repository,
UnleashResolver $unleashResolver,
UnleashFeatureArrayFactory $unleashFeatureArrayFactory,
Unleash\ClientFactory $unleashClientFactory
) {
$this->config = $config;
$this->unleash = $unleash;
$this->beConstructedWith($config, $unleash);
$this->repository = $repository;
$this->unleashResolver = $unleashResolver;
$this->unleashFeatureArrayFactory = $unleashFeatureArrayFactory;
$this->unleashClientFactory = $unleashClientFactory;
$this->beConstructedWith(
$config,
$repository,
$unleashResolver,
$unleashFeatureArrayFactory,
$unleashClientFactory
);
}
public function it_is_initializable()
......@@ -32,19 +58,68 @@ class UnleashSpec extends ObjectBehavior
$this->shouldHaveType(Unleash::class);
}
public function it_should_sync(
UnleashClient $client
) {
$this->unleashClientFactory->build('phpspec')
->shouldBeCalled()
->willReturn($client);
$client->register()
->shouldBeCalled()
->willReturn(true);
public function it_should_fetch()
{
$this->unleash->setContext(Argument::that(function (Context $context) {
$client->fetch()
->shouldBeCalled()
->willReturn([
['name' => 'feature1'],
['name' => 'feature2'],
]);
$this->repository->add(Argument::type(Unleash\Entity::class))
->shouldBeCalledTimes(2)
->willReturn(true);
$this
->setEnvironment('phpspec')
->sync(30)
->shouldReturn(true);
}
public function it_should_fetch(
Response $response
) {
$featuresMock = ['featuresMock' . mt_rand()];
$resolvedFeaturesMock = ['resolvedFeaturesMock' . mt_rand()];
$this->repository->getAllData([
'environment' => 'phpspec'
])
->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->unleash);
->willReturn($this->unleashResolver);
$this->unleash->export()
$this->unleashResolver->export()
->shouldBeCalled()
->willReturn([
'feature1' => true,
......@@ -57,6 +132,7 @@ class UnleashSpec extends ObjectBehavior
]);
$this
->setEnvironment('phpspec')
->fetch([
'feature1',
'feature2',
......@@ -76,8 +152,30 @@ class UnleashSpec extends ObjectBehavior
}
public function it_should_fetch_with_user(
User $user
User $user,
Response $response
) {
$featuresMock = ['featuresMock' . mt_rand()];
$resolvedFeaturesMock = ['resolvedFeaturesMock' . mt_rand()];
$this->repository->getAllData([
'environment' => 'phpspec'
])
->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);
......@@ -98,16 +196,16 @@ class UnleashSpec extends ObjectBehavior
->shouldBeCalled()
->willReturn(true);
$this->unleash->setContext(Argument::that(function (Context $context) {
$this->unleashResolver->setContext(Argument::that(function (Context $context) {
return
$context->getUserId() === '1000' &&
$context->getUserGroups() === ['authenticated', 'admin', 'canary', 'pro', 'plus']
;
}))
->shouldBeCalled()
->willReturn($this->unleash);
->willReturn($this->unleashResolver);
$this->unleash->export()
$this->unleashResolver->export()
->shouldBeCalled()
->willReturn([
'feature1' => true,
......@@ -120,6 +218,7 @@ class UnleashSpec extends ObjectBehavior
]);
$this
->setEnvironment('phpspec')
->setUser($user)
->fetch([
'feature1',
......
......@@ -38,7 +38,7 @@
"psr/http-server-middleware": "1.0.1",
"monolog/monolog": "1.25.2",
"psr/simple-cache": "1.0.1",
"minds/unleash-client-php": "0.1.1"
"minds/unleash-client-php": "0.1.2"
},
"repositories": [
{
......
This diff is collapsed.
......@@ -626,3 +626,8 @@ $CONFIG->set('unleash', [
'pollingIntervalSeconds' => 300,
'metricsIntervalSeconds' => 15
]);
$CONFIG->set('captcha', [
'jwt_secret' => '{{site-secret}}',
]);