diff --git a/.gitignore b/.gitignore index 9695be68..aebab397 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ build composer.lock vendor/ .env +data/database.sqlite diff --git a/bin/cli b/bin/cli index abbeed47..6f752583 100755 --- a/bin/cli +++ b/bin/cli @@ -1,17 +1,4 @@ #!/usr/bin/env php get('translator'); -$translator->setLocale(env('CLI_LOCALE', 'en')); - -/** @var Application $app */ -$app = $container->get(CliApp::class); -$app->run(); +include 'cli.php'; diff --git a/bin/cli.php b/bin/cli.php new file mode 100644 index 00000000..d15f1eef --- /dev/null +++ b/bin/cli.php @@ -0,0 +1,10 @@ +get(CliApp::class); +$app->run(); diff --git a/bin/install b/bin/install new file mode 100755 index 00000000..fde1f4a7 --- /dev/null +++ b/bin/install @@ -0,0 +1,4 @@ +#!/usr/bin/env php +add(new InstallCommand(new PhpArray())); +$app->setDefaultCommand('shlink:install'); +$app->run(); diff --git a/build.sh b/build.sh new file mode 100755 index 00000000..6dc1afcc --- /dev/null +++ b/build.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -e + +if [ "$#" -ne 1 ]; then + echo "Usage:" >&2 + echo " $0 {version}" >&2 + exit 1 +fi + +version=$1 +builtcontent=$(readlink -f '../shlink_build_tmp') +projectdir=$(pwd) + +# Copy project content to temp dir +echo 'Copying project files...' +rm -rf "${builtcontent}" +mkdir "${builtcontent}" +cp -R "${projectdir}"/* "${builtcontent}" +cd "${builtcontent}" + +# Install dependencies +rm -r vendor +rm composer.lock +composer self-update +composer install --no-dev --optimize-autoloader + +# Delete development files +echo 'Deleting dev files...' +rm build.sh +rm CHANGELOG.md +rm composer.* +rm LICENSE +rm php* +rm README.md +rm -r build +rm -f data/database.sqlite +rm -f data/{cache,log,proxies}/{*,.gitignore} +rm -f config/params/{*,.gitignore} +rm -f config/autoload/{{,*.}local.php{,.dist},.gitignore} + +# Compressing file +rm -f "${projectdir}"/build/Shlink_${version}.dist.zip +zip -r "${projectdir}"/build/Shlink_${version}.dist.zip "${builtcontent}" +rm -rf "${builtcontent}" diff --git a/composer.json b/composer.json index b8dc3f63..88c74604 100644 --- a/composer.json +++ b/composer.json @@ -27,6 +27,7 @@ "doctrine/orm": "^2.5", "guzzlehttp/guzzle": "^6.2", "symfony/console": "^3.0", + "symfony/process": "^3.0", "firebase/php-jwt": "^4.0", "monolog/monolog": "^1.21", "theorchard/monolog-cascade": "^0.4", diff --git a/config/autoload/app_options.global.php b/config/autoload/app_options.global.php index 4db642ce..e3f8fbdd 100644 --- a/config/autoload/app_options.global.php +++ b/config/autoload/app_options.global.php @@ -3,7 +3,7 @@ return [ 'app_options' => [ 'name' => 'Shlink', - 'version' => '1.1.0', + 'version' => '1.2.0', 'secret_key' => env('SECRET_KEY'), ], diff --git a/config/config.php b/config/config.php index d722185a..5eec4734 100644 --- a/config/config.php +++ b/config/config.php @@ -1,11 +1,10 @@ getMergedConfig(); diff --git a/config/params/.gitignore b/config/params/.gitignore new file mode 100644 index 00000000..d6b7ef32 --- /dev/null +++ b/config/params/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/module/CLI/config/cli.config.php b/module/CLI/config/cli.config.php index 1244e259..31c4e460 100644 --- a/module/CLI/config/cli.config.php +++ b/module/CLI/config/cli.config.php @@ -4,6 +4,7 @@ use Shlinkio\Shlink\CLI\Command; return [ 'cli' => [ + 'locale' => env('CLI_LOCALE', 'en'), 'commands' => [ Command\Shortcode\GenerateShortcodeCommand::class, Command\Shortcode\ResolveUrlCommand::class, diff --git a/module/CLI/src/Command/Install/InstallCommand.php b/module/CLI/src/Command/Install/InstallCommand.php new file mode 100644 index 00000000..bb19514f --- /dev/null +++ b/module/CLI/src/Command/Install/InstallCommand.php @@ -0,0 +1,267 @@ + 'pdo_mysql', + 'PostgreSQL' => 'pdo_pgsql', + 'SQLite' => 'pdo_sqlite', + ]; + const SUPPORTED_LANGUAGES = ['en', 'es']; + + /** + * @var InputInterface + */ + private $input; + /** + * @var OutputInterface + */ + private $output; + /** + * @var QuestionHelper + */ + private $questionHelper; + /** + * @var ProcessHelper + */ + private $processHelper; + /** + * @var WriterInterface + */ + private $configWriter; + + public function __construct(WriterInterface $configWriter) + { + parent::__construct(null); + $this->configWriter = $configWriter; + } + + public function configure() + { + $this->setName('shlink:install') + ->setDescription('Installs Shlink'); + } + + public function execute(InputInterface $input, OutputInterface $output) + { + $this->input = $input; + $this->output = $output; + $this->questionHelper = $this->getHelper('question'); + $this->processHelper = $this->getHelper('process'); + $params = []; + + $output->writeln([ + 'Welcome to Shlink!!', + 'This process will guide you through the installation.', + ]); + + // Check if a cached config file exists and drop it if so + if (file_exists('data/cache/app_config.php')) { + $output->write('Deleting old cached config...'); + if (unlink('data/cache/app_config.php')) { + $output->writeln(' Success'); + } else { + $output->writeln( + ' Failed! You will have to manually delete the data/cache/app_config.php file to get' + . ' new config applied.' + ); + } + } + + // Ask for custom config params + $params['DATABASE'] = $this->askDatabase(); + $params['URL_SHORTENER'] = $this->askUrlShortener(); + $params['LANGUAGE'] = $this->askLanguage(); + $params['APP'] = $this->askApplication(); + + // Generate config params files + $config = $this->buildAppConfig($params); + $this->configWriter->toFile('config/params/generated_config.php', $config, false); + $output->writeln(['Custom configuration properly generated!', '']); + + // Generate database + $output->write('Initializing database...'); + $this->processHelper->run($output, 'php vendor/bin/doctrine.php orm:schema-tool:create'); + $output->writeln(' Success!'); + + // Generate proxies + $output->write('Generating proxies...'); + $this->processHelper->run($output, 'php vendor/bin/doctrine.php orm:generate-proxies'); + $output->writeln(' Success!'); + } + + protected function askDatabase() + { + $params = []; + $this->printTitle('DATABASE'); + + // Select database type + $databases = array_keys(self::DATABASE_DRIVERS); + $dbType = $this->questionHelper->ask($this->input, $this->output, new ChoiceQuestion( + 'Select database type (defaults to ' . $databases[0] . '):', + $databases, + 0 + )); + $params['DRIVER'] = self::DATABASE_DRIVERS[$dbType]; + + // Ask for connection params if database is not SQLite + if ($params['DRIVER'] !== self::DATABASE_DRIVERS['SQLite']) { + $params['NAME'] = $this->ask('Database name', 'shlink'); + $params['USER'] = $this->ask('Database username'); + $params['PASSWORD'] = $this->ask('Database password'); + } + + return $params; + } + + protected function askUrlShortener() + { + $this->printTitle('URL SHORTENER'); + + // Ask for URL shortener params + return [ + 'SCHEMA' => $this->questionHelper->ask($this->input, $this->output, new ChoiceQuestion( + 'Select schema for generated short URLs (defaults to http):', + ['http', 'https'], + 0 + )), + 'HOSTNAME' => $this->ask('Hostname for generated URLs'), + 'CHARS' => $this->ask( + 'Character set for generated short codes (leave empty to autogenerate one)', + null, + true + ) ?: str_shuffle(UrlShortener::DEFAULT_CHARS) + ]; + } + + protected function askLanguage() + { + $this->printTitle('LANGUAGE'); + + return [ + 'DEFAULT' => $this->questionHelper->ask($this->input, $this->output, new ChoiceQuestion( + 'Select default language for the application in general (defaults to ' + . self::SUPPORTED_LANGUAGES[0] . '):', + self::SUPPORTED_LANGUAGES, + 0 + )), + 'CLI' => $this->questionHelper->ask($this->input, $this->output, new ChoiceQuestion( + 'Select default language for CLI executions (defaults to ' + . self::SUPPORTED_LANGUAGES[0] . '):', + self::SUPPORTED_LANGUAGES, + 0 + )), + ]; + } + + protected function askApplication() + { + $this->printTitle('APPLICATION'); + + return [ + 'SECRET' => $this->ask( + 'Define a secret string that will be used to sign API tokens (leave empty to autogenerate one)', + null, + true + ) ?: $this->generateRandomString(32), + ]; + } + + /** + * @param string $text + */ + protected function printTitle($text) + { + $text = trim($text); + $length = strlen($text) + 4; + $header = str_repeat('*', $length); + + $this->output->writeln([ + '', + '' . $header . '', + '* ' . strtoupper($text) . ' *', + '' . $header . '', + ]); + } + + /** + * @param string $text + * @param string|null $default + * @param bool $allowEmpty + * @return string + */ + protected function ask($text, $default = null, $allowEmpty = false) + { + if (isset($default)) { + $text .= ' (defaults to ' . $default . ')'; + } + do { + $value = $this->questionHelper->ask($this->input, $this->output, new Question( + '' . $text . ': ', + $default + )); + if (empty($value) && ! $allowEmpty) { + $this->output->writeln('Value can\'t be empty'); + } + } while (empty($value) && empty($default) && ! $allowEmpty); + + return $value; + } + + /** + * @param array $params + * @return array + */ + protected function buildAppConfig(array $params) + { + // Build simple config + $config = [ + 'app_options' => [ + 'secret_key' => $params['APP']['SECRET'], + ], + 'entity_manager' => [ + 'connection' => [ + 'driver' => $params['DATABASE']['DRIVER'], + ], + ], + 'translator' => [ + 'locale' => $params['LANGUAGE']['DEFAULT'], + ], + 'cli' => [ + 'locale' => $params['LANGUAGE']['CLI'], + ], + 'url_shortener' => [ + 'domain' => [ + 'schema' => $params['URL_SHORTENER']['SCHEMA'], + 'hostname' => $params['URL_SHORTENER']['HOSTNAME'], + ], + 'shortcode_chars' => $params['URL_SHORTENER']['CHARS'], + ], + ]; + + // Build dynamic database config + if ($params['DATABASE']['DRIVER'] === 'pdo_sqlite') { + $config['entity_manager']['connection']['path'] = 'data/database.sqlite'; + } else { + $config['entity_manager']['connection']['user'] = $params['DATABASE']['USER']; + $config['entity_manager']['connection']['password'] = $params['DATABASE']['PASSWORD']; + $config['entity_manager']['connection']['dbname'] = $params['DATABASE']['NAME']; + } + + return $config; + } +} diff --git a/module/CLI/src/Factory/ApplicationFactory.php b/module/CLI/src/Factory/ApplicationFactory.php index a8e24bf3..d13cce7c 100644 --- a/module/CLI/src/Factory/ApplicationFactory.php +++ b/module/CLI/src/Factory/ApplicationFactory.php @@ -3,7 +3,9 @@ namespace Shlinkio\Shlink\CLI\Factory; use Interop\Container\ContainerInterface; use Interop\Container\Exception\ContainerException; +use Shlinkio\Shlink\Core\Options\AppOptions; use Symfony\Component\Console\Application as CliApp; +use Zend\I18n\Translator\Translator; use Zend\ServiceManager\Exception\ServiceNotCreatedException; use Zend\ServiceManager\Exception\ServiceNotFoundException; use Zend\ServiceManager\Factory\FactoryInterface; @@ -25,9 +27,12 @@ class ApplicationFactory implements FactoryInterface public function __invoke(ContainerInterface $container, $requestedName, array $options = null) { $config = $container->get('config')['cli']; - $app = new CliApp('Shlink', '1.0.0'); + $appOptions = $container->get(AppOptions::class); + $translator = $container->get(Translator::class); + $translator->setLocale($config['locale']); $commands = isset($config['commands']) ? $config['commands'] : []; + $app = new CliApp($appOptions->getName(), $appOptions->getVersion()); foreach ($commands as $command) { if (! $container->has($command)) { continue; diff --git a/module/CLI/test/Command/Install/InstallCommandTest.php b/module/CLI/test/Command/Install/InstallCommandTest.php new file mode 100644 index 00000000..6fb2ee79 --- /dev/null +++ b/module/CLI/test/Command/Install/InstallCommandTest.php @@ -0,0 +1,101 @@ +prophesize(ProcessHelper::class); + $processHelper->getName()->willReturn('process'); + $processHelper->setHelperSet(Argument::any())->willReturn(null); + $processHelper->run(Argument::cetera())->willReturn(null); + + $app = new Application(); + $helperSet = $app->getHelperSet(); + $helperSet->set($processHelper->reveal()); + $app->setHelperSet($helperSet); + + $this->configWriter = $this->prophesize(WriterInterface::class); + $command = new InstallCommand($this->configWriter->reveal()); + $app->add($command); + + $questionHelper = $command->getHelper('question'); + $questionHelper->setInputStream($this->createInputStream()); + $this->commandTester = new CommandTester($command); + } + + protected function createInputStream() + { + $stream = fopen('php://memory', 'r+', false); + fputs($stream, <<configWriter->toFile(Argument::any(), [ + 'app_options' => [ + 'secret_key' => 'my_secret', + ], + 'entity_manager' => [ + 'connection' => [ + 'driver' => 'pdo_mysql', + 'dbname' => 'shlink_db', + 'user' => 'alejandro', + 'password' => '1234', + ], + ], + 'translator' => [ + 'locale' => 'en', + ], + 'cli' => [ + 'locale' => 'es', + ], + 'url_shortener' => [ + 'domain' => [ + 'schema' => 'http', + 'hostname' => 'doma.in', + ], + 'shortcode_chars' => 'abc123BCA', + ], + ], false)->shouldBeCalledTimes(1); + $this->commandTester->execute([ + 'command' => 'shlink:install', + ]); + } +} diff --git a/module/CLI/test/Command/GenerateShortcodeCommandTest.php b/module/CLI/test/Command/Shortcode/GenerateShortcodeCommandTest.php similarity index 97% rename from module/CLI/test/Command/GenerateShortcodeCommandTest.php rename to module/CLI/test/Command/Shortcode/GenerateShortcodeCommandTest.php index 011dcb32..43367f31 100644 --- a/module/CLI/test/Command/GenerateShortcodeCommandTest.php +++ b/module/CLI/test/Command/Shortcode/GenerateShortcodeCommandTest.php @@ -1,5 +1,5 @@ [ 'config' => [ - 'cli' => $config, + 'cli' => array_merge($config, ['locale' => 'en']), ], + AppOptions::class => new AppOptions(), + Translator::class => Translator::factory([]), ]]); } } diff --git a/module/Rest/config/rest.config.php b/module/Rest/config/rest.config.php deleted file mode 100644 index 223c864f..00000000 --- a/module/Rest/config/rest.config.php +++ /dev/null @@ -1,9 +0,0 @@ - [ - 'username' => env('REST_USER'), - 'password' => env('REST_PASSWORD'), - ], - -]; diff --git a/module/Rest/test/ConfigProviderTest.php b/module/Rest/test/ConfigProviderTest.php index 6801a82b..0270183f 100644 --- a/module/Rest/test/ConfigProviderTest.php +++ b/module/Rest/test/ConfigProviderTest.php @@ -25,7 +25,6 @@ class ConfigProviderTest extends TestCase $this->assertArrayHasKey('error_handler', $config); $this->assertArrayHasKey('middleware_pipeline', $config); - $this->assertArrayHasKey('rest', $config); $this->assertArrayHasKey('routes', $config); $this->assertArrayHasKey('dependencies', $config); $this->assertArrayHasKey('translator', $config); diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 00000000..a5c40815 --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,17 @@ +RewriteEngine On +# The following rule tells Apache that if the requested filename +# exists, simply serve it. +RewriteCond %{REQUEST_FILENAME} -s [OR] +RewriteCond %{REQUEST_FILENAME} -l [OR] +RewriteCond %{REQUEST_FILENAME} -d +RewriteRule ^.*$ - [NC,L] + +# The following rewrites all other queries to index.php. The +# condition ensures that if you are using Apache aliases to do +# mass virtual hosting, the base path will be prepended to +# allow proper resolution of the index.php file; it will work +# in non-aliased environments as well, providing a safe, one-size +# fits all solution. +RewriteCond %{REQUEST_URI}::$1 ^(/.+)(.+)::\2$ +RewriteRule ^(.*) - [E=BASE:%1] +RewriteRule ^(.*)$ %{ENV:BASE}index.php [NC,L]