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]