From ef7989fcfd6c31c01790bb4792fa0f2fdacfb924 Mon Sep 17 00:00:00 2001 From: "Buster \"Silver Eagle\" Neece" Date: Thu, 8 Jul 2021 15:03:54 -0500 Subject: [PATCH] Implement PHP Docker Installer (#4377) --- .dockerignore | 5 +- azuracast.dev.env | 66 +---- azuracast.sample.env | 10 - bin/installer | 22 ++ composer.json | 2 + composer.lock | 241 +++++++++++++++++- config/assets.php | 2 +- config/forms/profile.php | 2 +- docker-compose.installer.yml | 10 + docker.sh | 127 ++++----- phpcs.xml | 1 + src/AppFactory.php | 5 +- src/Console/Command/Locale/ImportCommand.php | 3 +- src/Customization.php | 2 +- src/Environment.php | 59 ++--- src/Installer/Command/InstallCommand.php | 255 +++++++++++++++++++ src/Installer/EnvFiles/AbstractEnvFile.php | 185 ++++++++++++++ src/Installer/EnvFiles/AzuraCastEnvFile.php | 216 ++++++++++++++++ src/Installer/EnvFiles/EnvFile.php | 93 +++++++ src/Locale.php | 127 ++++++--- src/Radio/Configuration.php | 25 +- util/docker/web/scripts/docker_installer | 4 + 22 files changed, 1226 insertions(+), 236 deletions(-) create mode 100644 bin/installer create mode 100644 docker-compose.installer.yml create mode 100644 src/Installer/Command/InstallCommand.php create mode 100644 src/Installer/EnvFiles/AbstractEnvFile.php create mode 100644 src/Installer/EnvFiles/AzuraCastEnvFile.php create mode 100644 src/Installer/EnvFiles/EnvFile.php create mode 100644 util/docker/web/scripts/docker_installer diff --git a/.dockerignore b/.dockerignore index 13da2c8ab..2247184e4 100644 --- a/.dockerignore +++ b/.dockerignore @@ -11,4 +11,7 @@ !web !templates !plugins -!crowdin.yaml \ No newline at end of file +!crowdin.yaml +!docker-compose.sample.yml +!sample.env +!azuracast.sample.env diff --git a/azuracast.dev.env b/azuracast.dev.env index c76f7c0ea..c8f557508 100644 --- a/azuracast.dev.env +++ b/azuracast.dev.env @@ -7,10 +7,14 @@ LOG_LEVEL=debug ENABLE_ADVANCED_FEATURES=true COMPOSER_PLUGIN_MODE=false +# Limit station port range +AUTO_ASSIGN_PORT_MIN=8000 +AUTO_ASSIGN_PORT_MAX=8099 + # Developer options. # Populate these! INIT_BASE_URL=http://azuracast.local -INIT_INSTANCE_NAME=local development +INIT_INSTANCE_NAME="local development" INIT_DEMO_API_KEY= INIT_ADMIN_EMAIL= INIT_ADMIN_PASSWORD= @@ -21,66 +25,8 @@ INIT_PODCASTS_PATH=/var/azuracast/www/util/fixtures/init_podcasts INIT_GEOLITE_LICENSE_KEY= -# -# Database Configuration -# -- -# Once the database has been installed, DO NOT CHANGE these values! -# - -# The host to connect to. Leave this as the default value unless you're connecting -# to an external database server. -# Default: mariadb -MYSQL_HOST=mariadb - -# The port to connect to. Leave this as the default value unless you're connecting -# to an external database server. -# Default: 3306 -MYSQL_PORT=3306 - -# The username AzuraCast will use to connect to the database. -# Default: azuracast -MYSQL_USER=azuracast - -# The password AzuraCast will use to connect to the database. -# By default, the database is not exposed to the Internet at all and this is only -# an internal password used by the service itself. -# Default: azur4c457 -MYSQL_PASSWORD=azur4c457 - -# The name of the AzuraCast database. -# Default: azuracast -MYSQL_DATABASE=azuracast - -# Automatically generate a random root password upon the first database spin-up. -# This password will be visible in the mariadb container's logs. -# Default: yes -MYSQL_RANDOM_ROOT_PASSWORD=yes - -# Log slower queries for the purpose of diagnosing issues. Only turn this on when -# you need to, by uncommenting this and switching it to 1. -# To read the slow query log once enabled, run: -# docker-compose exec mariadb slow_queries +# Debugging MYSQL_SLOW_QUERY_LOG=1 - -# Set the amount of allowed connections to the database. This value should be increased -# if you are seeing the `Too many connections` error in the logs. -# Default: 100 MYSQL_MAX_CONNECTIONS=100 - -# Enable the profiling extension. -# Profiling data can be viewed by visiting http://your-azuracast-site/?SPX_KEY=dev&SPX_UI_URI=/ -# Default: 0 PROFILING_EXTENSION_ENABLED=1 - -# Profile ALL requests made to this account. -# This will have significant performance impact on your installation and should only be used in test circumstances. -# Default: 0 -PROFILING_EXTENSION_ALWAYS_ON=0 - -# Configure the value for the SPX_KEY parameter needed to access the profiling dashboard -# Default: dev -PROFILING_EXTENSION_HTTP_KEY=dev - -# Configure the IP whitelist for the profiling dashboard -# Default: * PROFILING_EXTENSION_HTTP_IP_WHITELIST=* diff --git a/azuracast.sample.env b/azuracast.sample.env index 07e477463..06dc07074 100644 --- a/azuracast.sample.env +++ b/azuracast.sample.env @@ -105,10 +105,6 @@ MYSQL_MAX_CONNECTIONS=100 # Advanced Configuration # -# Override the IP/hostname to use when negotiating inbound FTP Passive Mode (PASV) connections. -# The system will attempt to automatically detect this, so you often don't need to change it. -# FTP_PASV_IP=localhost - # PHP's maximum POST body size and max upload filesize. # PHP_MAX_FILE_SIZE=25M @@ -127,12 +123,6 @@ MYSQL_MAX_CONNECTIONS=100 # Maximum number of PHP-FPM worker processes to spawn. # PHP_FPM_MAX_CHILDREN=5 -# Create additional media sync worker processes. -# This setting can be used to increase the performance of the media sync process -# by creating additional worker processes to consume messages -# Default: 0 -# ADDITIONAL_MEDIA_SYNC_WORKER_COUNT=0 - # # PHP-SPX profiling extension Configuration # diff --git a/bin/installer b/bin/installer new file mode 100644 index 000000000..5adc314fa --- /dev/null +++ b/bin/installer @@ -0,0 +1,22 @@ +#!/usr/bin/env php + dirname(__DIR__), + ] +); + +$console = new Silly\Application('AzuraCast installer', App\Version::FALLBACK_VERSION); + +$console->command( + 'install [--defaults] [--http-port=] [--https-port=] [--release-channel=] [base-dir]', + new App\Installer\Command\InstallCommand($environment) +); + +$console->setDefaultCommand('install'); +$console->run(); diff --git a/composer.json b/composer.json index ceb559625..cb3dcae3f 100644 --- a/composer.json +++ b/composer.json @@ -76,7 +76,9 @@ "symfony/redis-messenger": "^5", "symfony/serializer": "^5", "symfony/validator": "^5", + "symfony/yaml": "^5.3", "theiconic/php-ga-measurement-protocol": "^2.9", + "vlucas/phpdotenv": "^5.3", "voku/portable-utf8": "^5.4", "wikimedia/composer-merge-plugin": "dev-master", "zircote/swagger-php": "^3" diff --git a/composer.lock b/composer.lock index aeeafac82..bc8f7508a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,20 +4,20 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c0d14f7166d06e2d66bff25df9a2162e", + "content-hash": "b8d01546a885d3765652fdf3d8ba59fc", "packages": [ { "name": "aws/aws-sdk-php", - "version": "3.185.6", + "version": "3.185.9", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "35310302912fdc3b4a0e829b84424c41e3fd9727" + "reference": "b92714fbe995195e9ba970cf52a2fa601b334725" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/35310302912fdc3b4a0e829b84424c41e3fd9727", - "reference": "35310302912fdc3b4a0e829b84424c41e3fd9727", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/b92714fbe995195e9ba970cf52a2fa601b334725", + "reference": "b92714fbe995195e9ba970cf52a2fa601b334725", "shasum": "" }, "require": { @@ -92,9 +92,9 @@ "support": { "forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.185.6" + "source": "https://github.com/aws/aws-sdk-php/tree/3.185.9" }, - "time": "2021-07-02T18:13:18+00:00" + "time": "2021-07-08T18:21:21+00:00" }, { "name": "azuracast/azuraforms", @@ -2347,6 +2347,72 @@ ], "time": "2021-01-24T20:39:09+00:00" }, + { + "name": "graham-campbell/result-type", + "version": "v1.0.1", + "source": { + "type": "git", + "url": "https://github.com/GrahamCampbell/Result-Type.git", + "reference": "7e279d2cd5d7fbb156ce46daada972355cea27bb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/7e279d2cd5d7fbb156ce46daada972355cea27bb", + "reference": "7e279d2cd5d7fbb156ce46daada972355cea27bb", + "shasum": "" + }, + "require": { + "php": "^7.0|^8.0", + "phpoption/phpoption": "^1.7.3" + }, + "require-dev": { + "phpunit/phpunit": "^6.5|^7.5|^8.5|^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "psr-4": { + "GrahamCampbell\\ResultType\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "graham@alt-three.com" + } + ], + "description": "An Implementation Of The Result Type", + "keywords": [ + "Graham Campbell", + "GrahamCampbell", + "Result Type", + "Result-Type", + "result" + ], + "support": { + "issues": "https://github.com/GrahamCampbell/Result-Type/issues", + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.0.1" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/graham-campbell/result-type", + "type": "tidelift" + } + ], + "time": "2020-04-13T13:17:36+00:00" + }, { "name": "guzzlehttp/guzzle", "version": "7.3.0", @@ -4847,6 +4913,75 @@ }, "time": "2021-06-01T14:30:21+00:00" }, + { + "name": "phpoption/phpoption", + "version": "1.7.5", + "source": { + "type": "git", + "url": "https://github.com/schmittjoh/php-option.git", + "reference": "994ecccd8f3283ecf5ac33254543eb0ac946d525" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/994ecccd8f3283ecf5ac33254543eb0ac946d525", + "reference": "994ecccd8f3283ecf5ac33254543eb0ac946d525", + "shasum": "" + }, + "require": { + "php": "^5.5.9 || ^7.0 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.4.1", + "phpunit/phpunit": "^4.8.35 || ^5.7.27 || ^6.5.6 || ^7.0 || ^8.0 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.7-dev" + } + }, + "autoload": { + "psr-4": { + "PhpOption\\": "src/PhpOption/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Johannes M. Schmitt", + "email": "schmittjoh@gmail.com" + }, + { + "name": "Graham Campbell", + "email": "graham@alt-three.com" + } + ], + "description": "Option Type for PHP", + "keywords": [ + "language", + "option", + "php", + "type" + ], + "support": { + "issues": "https://github.com/schmittjoh/php-option/issues", + "source": "https://github.com/schmittjoh/php-option/tree/1.7.5" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption", + "type": "tidelift" + } + ], + "time": "2020-07-20T17:29:33+00:00" + }, { "name": "psr/cache", "version": "1.0.1", @@ -8834,6 +8969,86 @@ }, "time": "2020-09-24T23:37:47+00:00" }, + { + "name": "vlucas/phpdotenv", + "version": "v5.3.0", + "source": { + "type": "git", + "url": "https://github.com/vlucas/phpdotenv.git", + "reference": "b3eac5c7ac896e52deab4a99068e3f4ab12d9e56" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/b3eac5c7ac896e52deab4a99068e3f4ab12d9e56", + "reference": "b3eac5c7ac896e52deab4a99068e3f4ab12d9e56", + "shasum": "" + }, + "require": { + "ext-pcre": "*", + "graham-campbell/result-type": "^1.0.1", + "php": "^7.1.3 || ^8.0", + "phpoption/phpoption": "^1.7.4", + "symfony/polyfill-ctype": "^1.17", + "symfony/polyfill-mbstring": "^1.17", + "symfony/polyfill-php80": "^1.17" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.4.1", + "ext-filter": "*", + "phpunit/phpunit": "^7.5.20 || ^8.5.14 || ^9.5.1" + }, + "suggest": { + "ext-filter": "Required to use the boolean validator." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.3-dev" + } + }, + "autoload": { + "psr-4": { + "Dotenv\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "graham@alt-three.com", + "homepage": "https://gjcampbell.co.uk/" + }, + { + "name": "Vance Lucas", + "email": "vance@vancelucas.com", + "homepage": "https://vancelucas.com/" + } + ], + "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.", + "keywords": [ + "dotenv", + "env", + "environment" + ], + "support": { + "issues": "https://github.com/vlucas/phpdotenv/issues", + "source": "https://github.com/vlucas/phpdotenv/tree/v5.3.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/vlucas/phpdotenv", + "type": "tidelift" + } + ], + "time": "2021-01-20T15:23:13+00:00" + }, { "name": "voku/portable-ascii", "version": "1.5.6", @@ -10806,16 +11021,16 @@ }, { "name": "phpstan/phpstan-doctrine", - "version": "0.12.41", + "version": "0.12.42", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-doctrine.git", - "reference": "062fc75df9a393dc38a9722e0e8ab7036f5429ae" + "reference": "e3173175dcdaf808d5ca6408528dca669e4de19f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-doctrine/zipball/062fc75df9a393dc38a9722e0e8ab7036f5429ae", - "reference": "062fc75df9a393dc38a9722e0e8ab7036f5429ae", + "url": "https://api.github.com/repos/phpstan/phpstan-doctrine/zipball/e3173175dcdaf808d5ca6408528dca669e4de19f", + "reference": "e3173175dcdaf808d5ca6408528dca669e4de19f", "shasum": "" }, "require": { @@ -10868,9 +11083,9 @@ "description": "Doctrine extensions for PHPStan", "support": { "issues": "https://github.com/phpstan/phpstan-doctrine/issues", - "source": "https://github.com/phpstan/phpstan-doctrine/tree/0.12.41" + "source": "https://github.com/phpstan/phpstan-doctrine/tree/0.12.42" }, - "time": "2021-07-04T09:30:39+00:00" + "time": "2021-07-08T08:41:15+00:00" }, { "name": "phpunit/php-code-coverage", diff --git a/config/assets.php b/config/assets.php index f986ee67a..a8dba0b49 100644 --- a/config/assets.php +++ b/config/assets.php @@ -155,7 +155,7 @@ return [ ? (string)$localeObj : Locale::DEFAULT_LOCALE; - $locale = explode('.', $locale)[0]; + $locale = explode('.', $locale, 2)[0]; $localeShort = substr($locale, 0, 2); $localeWithDashes = str_replace('_', '-', $locale); diff --git a/config/forms/profile.php b/config/forms/profile.php index 2313954b3..eebde6961 100644 --- a/config/forms/profile.php +++ b/config/forms/profile.php @@ -1,7 +1,7 @@ getSupportedLocales(); +$locale_select = \App\Locale::SUPPORTED_LOCALES; $locale_select = ['default' => __('Use Browser Default')] + $locale_select; return [ diff --git a/docker-compose.installer.yml b/docker-compose.installer.yml new file mode 100644 index 000000000..4ce557b8f --- /dev/null +++ b/docker-compose.installer.yml @@ -0,0 +1,10 @@ +services : + installer : + container_name : azuracast_installer + image : 'ghcr.io/azuracast/web:${AZURACAST_VERSION:-latest}' + volumes : + - './:/installer' + restart : 'no' + user : root + entrypoint : docker_installer + command : install diff --git a/docker.sh b/docker.sh index 7fe70c600..5d1dd7140 100755 --- a/docker.sh +++ b/docker.sh @@ -1,9 +1,10 @@ #!/usr/bin/env bash # shellcheck disable=SC2145,SC2178,SC2120,SC2162 +set -x + # Constants export COMPOSE_VERSION=1.29.2 -export LEGACY_PORTS="8000,8005,8006,8010,8015,8016,8020,8025,8026,8030,8035,8036,8040,8045,8046,8050,8055,8056,8060,8065,8066,8070,8075,8076,8090,8095,8096,8100,8105,8106,8110,8115,8116,8120,8125,8126,8130,8135,8136,8140,8145,8146,8150,8155,8156,8160,8165,8166,8170,8175,8176,8180,8185,8186,8190,8195,8196,8200,8205,8206,8210,8215,8216,8220,8225,8226,8230,8235,8236,8240,8245,8246,8250,8255,8256,8260,8265,8266,8270,8275,8276,8280,8285,8286,8290,8295,8296,8300,8305,8306,8310,8315,8316,8320,8325,8326,8330,8335,8336,8340,8345,8346,8350,8355,8356,8360,8365,8366,8370,8375,8376,8380,8385,8386,8390,8395,8396,8400,8405,8406,8410,8415,8416,8420,8425,8426,8430,8435,8436,8440,8445,8446,8450,8455,8456,8460,8465,8466,8470,8475,8476,8480,8485,8486,8490,8495,8496" # Functions to manage .env files __dotenv= @@ -120,6 +121,25 @@ version-number() { echo "$@" | awk -F. '{ printf("%03d%03d%03d\n", $1,$2,$3); }' } +# Get the current release channel for AzuraCast +get-release-channel() { + local AZURACAST_VERSION="latest" + if [[ -f .env ]]; then + .env --file .env get AZURACAST_VERSION + AZURACAST_VERSION="${REPLY:-latest}" + fi + + echo "$AZURACAST_VERSION" +} + +get-release-branch-name() { + if [[ $(get-release-channel) == "stable" ]]; then + echo "stable" + else + echo "main" + fi +} + # This is a general-purpose function to ask Yes/No questions in Bash, either # with or without a default answer. It keeps repeating the question until it # gets a valid answer. @@ -197,11 +217,6 @@ setup-letsencrypt() { # Configure release mode settings. # setup-release() { - if [[ ! -f .env ]]; then - echo "Writing default .env file..." - curl -fsSL https://raw.githubusercontent.com/AzuraCast/AzuraCast/main/sample.env -o .env - fi - local AZURACAST_VERSION="latest" if ask "Prefer stable release versions of AzuraCast?" N; then AZURACAST_VERSION="stable" @@ -243,6 +258,16 @@ install-docker-compose() { fi } +run-installer() { + local AZURACAST_RELEASE_BRANCH + AZURACAST_RELEASE_BRANCH=$(get-release-branch-name) + + curl -fsSL https://raw.githubusercontent.com/AzuraCast/AzuraCast/$AZURACAST_RELEASE_BRANCH/docker-compose.installer.yml -o docker-compose.installer.yml + + docker-compose -f docker-compose.installer.yml pull + docker-compose -f docker-compose.installer.yml run --rm installer install "$@" +} + # # Run the initial installer of Docker and AzuraCast. # Usage: ./docker.sh install @@ -271,43 +296,16 @@ install() { fi fi - if [[ ! -f .env ]]; then - setup-release - fi + run-installer - if [[ ! -f azuracast.env ]]; then - echo "Creating default AzuraCast settings file..." - curl -fsSL https://raw.githubusercontent.com/AzuraCast/AzuraCast/main/azuracast.sample.env -o azuracast.env + # Installer creates a file at docker-compose.new.yml; copy it to the main spot. + rm docker-compose.yml + mv docker-compose.new.yml docker-compose.yml - # Generate a random password and replace the MariaDB password with it. - local NEW_PASSWORD - NEW_PASSWORD=$( - tr src/Radio/Backend/Liquidsoap/ConfigWriter.php + src/Installer/EnvFiles/*.php diff --git a/src/AppFactory.php b/src/AppFactory.php index e701bbf81..1daf853a0 100644 --- a/src/AppFactory.php +++ b/src/AppFactory.php @@ -37,7 +37,8 @@ class AppFactory $di = self::buildContainer($autoloader, $appEnvironment, $diDefinitions); self::buildAppFromContainer($di); - $locale = $di->make(Locale::class); + $env = $di->get(Environment::class); + $locale = Locale::createForCli($env); $locale->register(); return $di->get(Application::class); @@ -164,7 +165,7 @@ class AppFactory return $di; } - protected static function buildEnvironment(array $environment): Environment + public static function buildEnvironment(array $environment): Environment { if (!isset($environment[Environment::BASE_DIR])) { throw new Exception\BootstrapException('No base directory specified!'); diff --git a/src/Console/Command/Locale/ImportCommand.php b/src/Console/Command/Locale/ImportCommand.php index f327f8834..78b7f43e5 100644 --- a/src/Console/Command/Locale/ImportCommand.php +++ b/src/Console/Command/Locale/ImportCommand.php @@ -4,6 +4,7 @@ namespace App\Console\Command\Locale; use App\Console\Command\CommandAbstract; use App\Environment; +use App\Locale; use Gettext\Translations; use Symfony\Component\Console\Style\SymfonyStyle; @@ -15,7 +16,7 @@ class ImportCommand extends CommandAbstract ): int { $io->title('Import Locales'); - $locales = $environment->getSupportedLocales(); + $locales = Locale::SUPPORTED_LOCALES; $locale_base = $environment->getBaseDirectory() . '/resources/locale'; foreach ($locales as $locale_key => $locale_name) { diff --git a/src/Customization.php b/src/Customization.php index 18457e44b..6533ab64e 100644 --- a/src/Customization.php +++ b/src/Customization.php @@ -53,7 +53,7 @@ class Customization } // Register locale - $this->locale = new Locale($environment, $request); + $this->locale = Locale::createFromRequest($this->environment, $request); $this->locale->register(); } diff --git a/src/Environment.php b/src/Environment.php index b94920faf..7ffe3c592 100644 --- a/src/Environment.php +++ b/src/Environment.php @@ -34,7 +34,6 @@ class Environment public const DOCKER_REVISION = 'AZURACAST_DC_REVISION'; public const LANG = 'LANG'; - public const SUPPORTED_LOCALES = 'SUPPORTED_LOCALES'; public const RELEASE_CHANNEL = 'AZURACAST_VERSION'; @@ -69,32 +68,34 @@ class Environment self::APP_NAME => 'AzuraCast', self::APP_ENV => self::ENV_PRODUCTION, + self::LOG_LEVEL => LogLevel::NOTICE, self::IS_DOCKER => true, self::IS_CLI => ('cli' === PHP_SAPI), self::ASSET_URL => '/static', - self::ENABLE_REDIS => true, + self::AUTO_ASSIGN_PORT_MIN => 8000, + self::AUTO_ASSIGN_PORT_MAX => 8499, - self::SUPPORTED_LOCALES => [ - 'en_US.UTF-8' => 'English (Default)', - 'cs_CZ.UTF-8' => 'čeština', // Czech - 'de_DE.UTF-8' => 'Deutsch', // German - 'es_ES.UTF-8' => 'Español', // Spanish - 'fr_FR.UTF-8' => 'Français', // French - 'el_GR.UTF-8' => 'ελληνικά', // Greek - 'it_IT.UTF-8' => 'Italiano', // Italian - 'hu_HU.UTF-8' => 'magyar', // Hungarian - 'nl_NL.UTF-8' => 'Nederlands', // Dutch - 'pl_PL.UTF-8' => 'Polski', // Polish - 'pt_PT.UTF-8' => 'Português', // Portuguese - 'pt_BR.UTF-8' => 'Português do Brasil', // Brazilian Portuguese - 'ru_RU.UTF-8' => 'Русский язык', // Russian - 'sv_SE.UTF-8' => 'Svenska', // Swedish - 'tr_TR.UTF-8' => 'Türkçe', // Turkish - 'zh_CN.UTF-8' => '簡化字', // Simplified Chinese - 'ko_KR.UTF-8' => '한국어', // Korean (South Korean) - ], + self::DB_HOST => 'mariadb', + self::DB_PORT => 3306, + self::DB_USER => 'azuracast', + self::DB_PASSWORD => 'azur4c457', + self::DB_NAME => 'azuracast', + + self::ENABLE_REDIS => true, + self::REDIS_HOST => 'redis', + self::REDIS_PORT => 6379, + self::REDIS_DB => 1, + + self::SYNC_SHORT_EXECUTION_TIME => 600, + self::SYNC_LONG_EXECUTION_TIME => 1800, + + self::PROFILING_EXTENSION_ENABLED => 0, + self::PROFILING_EXTENSION_ALWAYS_ON => 0, + self::PROFILING_EXTENSION_HTTP_KEY => 'dev', + + self::LANG => Locale::DEFAULT_LOCALE, ]; public function __construct(array $elements = []) @@ -102,6 +103,14 @@ class Environment $this->data = array_merge($this->defaults, $elements); } + /** + * @return mixed[] + */ + public function toArray(): array + { + return $this->data; + } + protected function envToBool(string|bool $value): bool { if (is_bool($value)) { @@ -216,14 +225,6 @@ class Environment return $this->data[self::LANG]; } - /** - * @return string[] - */ - public function getSupportedLocales(): array - { - return $this->data[self::SUPPORTED_LOCALES] ?? []; - } - public function getReleaseChannel(): string { $channel = $this->data[self::RELEASE_CHANNEL] ?? 'latest'; diff --git a/src/Installer/Command/InstallCommand.php b/src/Installer/Command/InstallCommand.php new file mode 100644 index 000000000..70a8ebb98 --- /dev/null +++ b/src/Installer/Command/InstallCommand.php @@ -0,0 +1,255 @@ +error($e->getMessage()); + $env = new EnvFile($envPath); + } + + try { + $azuracastEnv = AzuraCastEnvFile::fromEnvFile($azuracastEnvPath); + } catch (InvalidArgumentException $e) { + $io->error($e->getMessage()); + $azuracastEnv = new AzuraCastEnvFile($envPath); + } + + // Initialize locale for translated installer/updater. + if ($isNewInstall || empty($azuracastEnv[Environment::LANG])) { + $langOptions = []; + foreach (Locale::SUPPORTED_LOCALES as $langKey => $langName) { + $langOptions[Locale::stripLocaleEncoding($langKey)] = $langName; + } + + $azuracastEnv[Environment::LANG] = $io->choice( + 'Select Language', + $langOptions, + Locale::stripLocaleEncoding(Locale::DEFAULT_LOCALE) + ); + } + + $locale = new Locale($this->environment, $azuracastEnv[Environment::LANG] ?? Locale::DEFAULT_LOCALE); + $locale->register(); + + $envConfig = EnvFile::getConfiguration(); + $env->setFromDefaults(); + + $azuracastEnvConfig = AzuraCastEnvFile::getConfiguration(); + $azuracastEnv->setFromDefaults(); + + // Apply values passed via flags + if (null !== $releaseChannel) { + $env['AZURACAST_VERSION'] = $releaseChannel; + } + if (null !== $httpPort) { + $env['AZURACAST_HTTP_PORT'] = $httpPort; + } + if (null !== $httpsPort) { + $env['AZURACAST_HTTPS_PORT'] = $httpsPort; + } + + // Migrate legacy config values. + if (isset($azuracastEnv['PREFER_RELEASE_BUILDS'])) { + $env['AZURACAST_VERSION'] = ('true' === $azuracastEnv['PREFER_RELEASE_BUILDS']) + ? 'stable' + : 'latest'; + + unset($azuracastEnv['PREFER_RELEASE_BUILDS']); + } + + unset($azuracastEnv['ENABLE_ADVANCED_FEATURES']); + + // Randomize the MariaDB root password for new installs. + if ($isNewInstall && 'azur4c457' === $azuracastEnv[Environment::DB_PASSWORD]) { + $azuracastEnv[Environment::DB_PASSWORD] = Strings::generatePassword(12); + } + + // Display header messages + if ($isNewInstall) { + $io->title( + __('AzuraCast Installer') + ); + $io->block( + __('Welcome to AzuraCast! Complete the initial server setup by answering a few questions.') + ); + } else { + $io->title( + __('AzuraCast Updater') + ); + } + + if ($defaults) { + $customize = false; + } else { + $customize = $io->confirm( + __('Customize server settings (ports, databases, etc.)?'), + false + ); + } + + if ($customize) { + // Release channel + $env['AZURACAST_VERSION'] = $io->choice( + __('AzuraCast Release Channel'), + [ + 'stable' => __('Stable'), + 'latest' => __('Rolling Release'), + ], + $env['AZURACAST_VERSION'] + ); + + // Port customization + $io->writeln( + __('AzuraCast is currently configured to listen on the following ports:'), + ); + $io->listing( + [ + __('HTTP Port: %d', $env['AZURACAST_HTTP_PORT']), + __('HTTPS Port: %d', $env['AZURACAST_HTTPS_PORT']), + __('SFTP Port: %d', $env['AZURACAST_SFTP_PORT']), + __('Radio Ports: %s', $env['AZURACAST_STATION_PORTS']), + ], + ); + + $customizePorts = $io->confirm( + __('Customize ports used for AzuraCast?'), + false + ); + + if ($customizePorts) { + $simplePorts = [ + 'AZURACAST_HTTP_PORT', + 'AZURACAST_HTTPS_PORT', + 'AZURACAST_SFTP_PORT', + ]; + + foreach ($simplePorts as $port) { + $env[$port] = (int)$io->ask( + $envConfig[$port]['name'] . ' - ' . $envConfig[$port]['description'], + (int)$env[$port] + ); + } + + $azuracastEnv[Environment::AUTO_ASSIGN_PORT_MIN] = (int)$io->ask( + $azuracastEnvConfig[Environment::AUTO_ASSIGN_PORT_MIN]['name'], + (int)$azuracastEnv[Environment::AUTO_ASSIGN_PORT_MIN] + ); + + $azuracastEnv[Environment::AUTO_ASSIGN_PORT_MAX] = (int)$io->ask( + $azuracastEnvConfig[Environment::AUTO_ASSIGN_PORT_MAX]['name'], + (int)$azuracastEnv[Environment::AUTO_ASSIGN_PORT_MAX] + ); + + $stationPorts = Configuration::enumerateDefaultPorts( + rangeMin: $azuracastEnv[Environment::AUTO_ASSIGN_PORT_MIN], + rangeMax: $azuracastEnv[Environment::AUTO_ASSIGN_PORT_MAX] + ); + $env['AZURACAST_STATION_PORTS'] = implode(',', $stationPorts); + } + + $customizeLetsEncrypt = $io->confirm( + __('Set up LetsEncrypt?'), + false + ); + + if ($customizeLetsEncrypt) { + $env['LETSENCRYPT_HOST'] = $io->ask( + $envConfig['LETSENCRYPT_HOST']['description'], + $env['LETSENCRYPT_HOST'] + ); + + $env['LETSENCRYPT_EMAIL'] = $io->ask( + $envConfig['LETSENCRYPT_EMAIL']['description'], + $env['LETSENCRYPT_EMAIL'] + ); + } + } + + $io->writeln( + __('Writing configuration files...') + ); + + $envStr = $env->writeToFile(); + $azuracastEnvStr = $azuracastEnv->writeToFile(); + + if ($io->isVerbose()) { + $io->section($env->getBasename()); + $io->block($envStr); + + $io->section($azuracastEnv->getBasename()); + $io->block($azuracastEnvStr); + } + + $dockerComposePath = $baseDir . '/docker-compose.new.yml'; + $dockerComposeStr = $this->updateDockerCompose($dockerComposePath, $env['AZURACAST_STATION_PORTS']); + + if ($io->isVerbose()) { + $io->section(basename($dockerComposePath)); + $io->block($dockerComposeStr); + } + + $io->success( + __('Server configuration complete!') + ); + return 0; + } + + protected function updateDockerCompose( + string $dockerComposePath, + string $ports + ): string { + // Parse port listing and convert into YAML format. + $yamlPorts = []; + foreach (explode(',', $ports) as $port) { + $yamlPorts[] = $port . ':' . $port; + } + + // Attempt to parse Docker Compose YAML file + $sampleFile = $this->environment->getBaseDirectory() . '/docker-compose.sample.yml'; + $yaml = Yaml::parseFile($sampleFile); + + $yaml['services']['stations']['ports'] = $yamlPorts; + + $yamlRaw = Yaml::dump($yaml, PHP_INT_MAX); + file_put_contents($dockerComposePath, $yamlRaw); + + return $yamlRaw; + } +} diff --git a/src/Installer/EnvFiles/AbstractEnvFile.php b/src/Installer/EnvFiles/AbstractEnvFile.php new file mode 100644 index 000000000..882a41fd6 --- /dev/null +++ b/src/Installer/EnvFiles/AbstractEnvFile.php @@ -0,0 +1,185 @@ +path; + } + + public function getBasename(): string + { + return basename($this->path); + } + + public function setFromDefaults(): void + { + $currentVars = array_filter($this->data); + + $defaults = []; + foreach (static::getConfiguration() as $key => $keyInfo) { + if (isset($keyInfo['default'])) { + $defaults[$key] = $keyInfo['default'] ?? null; + } + } + + $this->data = array_merge($defaults, $currentVars); + } + + public function offsetExists($offset): bool + { + return isset($this->data[$offset]); + } + + public function offsetGet($offset): mixed + { + return $this->data[$offset]; + } + + public function offsetSet($offset, $value): void + { + $this->data[$offset] = $value; + } + + public function offsetUnset($offset): void + { + unset($this->data[$offset]); + } + + public function writeToFile(): string + { + $values = array_filter($this->data); + + $envFile = [ + '# ' . __('This file was automatically generated by AzuraCast.'), + '# ' . __('You can modify it as necessary. To apply changes, restart the Docker containers.'), + '# ' . __('Remove the leading "#" symbol from lines to uncomment them.'), + '', + ]; + + foreach (static::getConfiguration() as $key => $keyInfo) { + $envFile[] = '# ' . ($keyInfo['name'] ?? $key); + + if (!empty($keyInfo['description'])) { + $desc = Strings::mbWordwrap($keyInfo['description']); + + foreach (explode("\n", $desc) as $descPart) { + $envFile[] = '# ' . $descPart; + } + } + + if (!empty($keyInfo['options'])) { + $options = array_map( + fn($val) => $this->getEnvValue($val), + $keyInfo['options'], + ); + + $envFile[] = '# ' . __('Valid options: %s', implode(', ', $options)); + } + + if (isset($values[$key])) { + $value = $this->getEnvValue($values[$key]); + unset($values[$key]); + } else { + $value = null; + } + + if (!empty($keyInfo['default'])) { + $default = $this->getEnvValue($keyInfo['default']); + $envFile[] = '# ' . __('Default: %s', $default); + } else { + $default = ''; + } + + if ((null === $value || $default === $value) && Environment::LANG !== $key) { + $value ??= $default; + $envFile[] = '# ' . $key . '=' . $value; + } else { + $envFile[] = $key . '=' . $value; + } + + $envFile[] = ''; + } + + // Add in other environment vars that were missed or previously present. + if (!empty($values)) { + $envFile[] = '# ' . __('Additional Environment Variables'); + + foreach ($values as $key => $value) { + $envFile[] = $key . '=' . $this->getEnvValue($value); + } + } + + $envFileStr = implode("\n", $envFile); + file_put_contents($this->path, $envFileStr); + + return $envFileStr; + } + + protected function getEnvValue( + mixed $value + ): string { + if (is_null($value)) { + return ''; + } + if (is_bool($value)) { + return $value ? 'true' : 'false'; + } + if (is_int($value)) { + return (string)$value; + } + if (is_array($value)) { + return implode(',', $value); + } + + if (str_contains($value, ' ')) { + $value = '"' . $value . '"'; + } + + return $value; + } + + /** + * @return mixed[] + */ + abstract public static function getConfiguration(): array; + + abstract public static function buildPathFromBase(string $baseDir): string; + + public static function fromEnvFile(string $path): static + { + $data = []; + if (is_file($path)) { + $fileContents = file_get_contents($path); + if (!empty($fileContents)) { + try { + $data = Dotenv::parse($fileContents); + } catch (ExceptionInterface $e) { + throw new InvalidArgumentException( + sprintf( + 'Encountered an error parsing %s: "%s". Resetting to default configuration.', + basename($path), + $e->getMessage() + ) + ); + } + } + } + + return new static($path, $data); + } +} diff --git a/src/Installer/EnvFiles/AzuraCastEnvFile.php b/src/Installer/EnvFiles/AzuraCastEnvFile.php new file mode 100644 index 000000000..2906fcae4 --- /dev/null +++ b/src/Installer/EnvFiles/AzuraCastEnvFile.php @@ -0,0 +1,216 @@ +toArray(); + + $langOptions = []; + foreach (Locale::SUPPORTED_LOCALES as $locale => $localeName) { + $langOptions[] = Locale::stripLocaleEncoding($locale); + } + + $config = [ + Environment::LANG => [ + 'name' => __( + 'The locale to use for CLI commands.', + ), + 'options' => $langOptions, + 'default' => Locale::stripLocaleEncoding(Locale::DEFAULT_LOCALE), + ], + Environment::APP_ENV => [ + 'name' => __( + 'The application environment.', + ), + 'options' => [ + Environment::ENV_PRODUCTION, + Environment::ENV_DEVELOPMENT, + Environment::ENV_TESTING, + ], + ], + Environment::LOG_LEVEL => [ + 'name' => __( + 'Manually modify the logging level.', + ), + 'description' => __( + 'This allows you to log debug-level errors temporarily (for problem-solving) or reduce the volume of logs that are produced by your installation, without needing to modify whether your installation is a production or development instance.' + ), + 'options' => [ + LogLevel::DEBUG, + LogLevel::INFO, + LogLevel::NOTICE, + LogLevel::WARNING, + LogLevel::ERROR, + LogLevel::CRITICAL, + LogLevel::ALERT, + LogLevel::EMERGENCY, + ], + ], + 'COMPOSER_PLUGIN_MODE' => [ + 'name' => __('Composer Plugin Mode'), + 'description' => __( + 'Enable the composer "merge" functionality to combine the main application\'s composer.json file with any plugin composer files. This can have performance implications, so you should only use it if you use one or more plugins with their own Composer dependencies.', + ), + 'options' => [true, false], + 'default' => false, + ], + Environment::AUTO_ASSIGN_PORT_MIN => [ + 'name' => __( + 'Minimum Port for Station Port Assignment' + ), + 'description' => __( + 'Modify this if your stations are listening on nonstandard ports.', + ), + ], + Environment::AUTO_ASSIGN_PORT_MAX => [ + 'name' => __( + 'Maximum Port for Station Port Assignment' + ), + 'description' => __( + 'Modify this if your stations are listening on nonstandard ports.', + ), + ], + Environment::DB_HOST => [ + 'name' => __('MariaDB Host'), + 'description' => __( + 'Do not modify this after installation.', + ), + ], + Environment::DB_PORT => [ + 'name' => __('MariaDB Port'), + 'description' => __( + 'Do not modify this after installation.', + ), + ], + Environment::DB_USER => [ + 'name' => __('MariaDB Username'), + 'description' => __( + 'Do not modify this after installation.', + ), + ], + Environment::DB_PASSWORD => [ + 'name' => __('MariaDB Password'), + 'description' => __( + 'Do not modify this after installation.', + ), + ], + Environment::DB_NAME => [ + 'name' => __('MariaDB Database Name'), + 'description' => __( + 'Do not modify this after installation.', + ), + ], + 'MYSQL_RANDOM_ROOT_PASSWORD' => [ + 'name' => __('Auto-generate Random MariaDB Root Password'), + 'description' => __( + 'Do not modify this after installation.', + ), + 'default' => 'yes', + ], + 'MYSQL_SLOW_QUERY_LOG' => [ + 'name' => __('Enable MariaDB Slow Query Log'), + 'description' => __( + 'Log slower queries to diagnose possible database issues. Only turn this on if needed.', + ), + 'default' => 0, + ], + 'MYSQL_MAX_CONNECTIONS' => [ + 'name' => __('MariaDB Maximum Connections'), + 'description' => __( + 'Set the amount of allowed connections to the database. This value should be increased if you are seeing the "Too many connections" error in the logs.', + ), + 'default' => 100, + ], + Environment::ENABLE_REDIS => [ + 'name' => __('Enable Redis'), + 'description' => __( + 'Disable to use a flatfile cache instead of Redis.', + ), + ], + Environment::REDIS_HOST => [ + 'name' => __('Redis Host'), + ], + Environment::REDIS_PORT => [ + 'name' => __('Redis Port'), + ], + Environment::REDIS_DB => [ + 'name' => __('Redis Database Index'), + 'options' => range(0, 15), + ], + 'PHP_MAX_FILE_SIZE' => [ + 'name' => __('PHP Maximum POST File Size'), + 'default' => '25M', + ], + 'PHP_MEMORY_LIMIT' => [ + 'name' => __('PHP Memory Limit'), + 'default' => '128M', + ], + 'PHP_MAX_EXECUTION_TIME' => [ + 'name' => __('PHP Script Maximum Execution Time'), + 'description' => __('(in seconds)'), + 'default' => 30, + ], + Environment::SYNC_SHORT_EXECUTION_TIME => [ + 'name' => __('Short Sync Task Execution Time'), + 'description' => __( + 'The maximum execution time (and lock timeout) for the 15-second, 1-minute and 5-minute synchronization tasks.' + ), + ], + Environment::SYNC_LONG_EXECUTION_TIME => [ + 'name' => __('Long Sync Task Execution Time'), + 'description' => __( + 'The maximum execution time (and lock timeout) for the 1-hour synchronization task.', + ), + ], + 'PHP_FPM_MAX_CHILDREN' => [ + 'name' => __('Maximum PHP-FPM Worker Processes'), + 'default' => 5, + ], + Environment::PROFILING_EXTENSION_ENABLED => [ + 'name' => __('Enable Performance Profiling Extension'), + 'description' => __( + 'Profiling data can be viewed by visiting %s.', + 'http://your-azuracast-site/?SPX_KEY=dev&SPX_UI_URI=/', + ), + ], + Environment::PROFILING_EXTENSION_ALWAYS_ON => [ + 'name' => __('Profile Performance on All Requests'), + 'description' => __( + 'This will have a significant performance impact on your installation.', + ), + ], + Environment::PROFILING_EXTENSION_HTTP_KEY => [ + 'name' => __('Profiling Extension HTTP Key'), + 'description' => __( + 'The value for the "SPX_KEY" parameter for viewing profiling pages.', + ), + ], + 'PROFILING_EXTENSION_HTTP_IP_WHITELIST' => [ + 'name' => __('Profiling Extension IP Allow List'), + 'options' => ['127.0.0.1', '*'], + 'default' => '127.0.0.1', + ], + ]; + + foreach ($config as $key => &$keyInfo) { + $keyInfo['default'] ??= $defaults[$key] ?? null; + } + + return $config; + } + + public static function buildPathFromBase(string $baseDir): string + { + return $baseDir . DIRECTORY_SEPARATOR . 'azuracast.env'; + } +} diff --git a/src/Installer/EnvFiles/EnvFile.php b/src/Installer/EnvFiles/EnvFile.php new file mode 100644 index 000000000..88efcb95b --- /dev/null +++ b/src/Installer/EnvFiles/EnvFile.php @@ -0,0 +1,93 @@ + [ + 'name' => __( + '(Docker Compose) All Docker containers are prefixed by this name. Do not change this after installation.' + ), + 'default' => 'azuracast', + ], + 'COMPOSE_HTTP_TIMEOUT' => [ + 'name' => __( + '(Docker Compose) The amount of time to wait before a Docker Compose operation fails. Increase this on lower performance computers.' + ), + 'default' => 300, + ], + 'AZURACAST_VERSION' => [ + 'name' => __('AzuraCast Release Channel'), + 'options' => ['latest', 'stable'], + 'default' => 'latest', + ], + 'AZURACAST_HTTP_PORT' => [ + 'name' => __('HTTP Port'), + 'description' => __( + 'The main port AzuraCast listens to for insecure HTTP connections.', + ), + 'default' => 80, + ], + 'AZURACAST_HTTPS_PORT' => [ + 'name' => __('HTTPS Port'), + 'description' => __( + 'The main port AzuraCast listens to for secure HTTPS connections.', + ), + 'default' => 443, + ], + 'AZURACAST_SFTP_PORT' => [ + 'name' => __('SFTP Port'), + 'description' => __( + 'The port AzuraCast listens to for SFTP file management connections.', + ), + 'default' => 2022, + ], + 'AZURACAST_STATION_PORTS' => [ + 'name' => __('Station Ports'), + 'description' => __( + 'The ports AzuraCast should listen to for station broadcasts and incoming DJ connections.', + ), + 'default' => implode(',', Configuration::enumerateDefaultPorts()), + ], + 'AZURACAST_PUID' => [ + 'name' => __('Docker User UID'), + 'description' => __( + 'Set the UID of the user running inside the Docker containers. Matching this with your host UID can fix permission issues.', + ), + 'default' => 1000, + ], + 'AZURACAST_PGID' => [ + 'name' => __('Docker User GID'), + 'description' => __( + 'Set the GID of the user running inside the Docker containers. Matching this with your host GID can fix permission issues.' + ), + 'default' => 1000, + ], + 'LETSENCRYPT_HOST' => [ + 'name' => __('LetsEncrypt Domain Name(s)'), + 'description' => __( + 'Domain name (example.com) or names (example.com,foo.bar) to use with LetsEncrypt.' + ), + ], + 'LETSENCRYPT_EMAIL' => [ + 'name' => __('LetsEncrypt E-mail Address'), + 'description' => __( + 'Optionally provide an e-mail address for updates from LetsEncrypt.', + ), + ], + ]; + } + + public static function buildPathFromBase(string $baseDir): string + { + return $baseDir . DIRECTORY_SEPARATOR . '.env'; + } +} diff --git a/src/Locale.php b/src/Locale.php index 840c51334..a79e490ef 100644 --- a/src/Locale.php +++ b/src/Locale.php @@ -10,53 +10,46 @@ class Locale { public const DEFAULT_LOCALE = 'en_US.UTF-8'; + public const SUPPORTED_LOCALES = [ + 'en_US.UTF-8' => 'English (Default)', + 'cs_CZ.UTF-8' => 'čeština', // Czech + 'de_DE.UTF-8' => 'Deutsch', // German + 'es_ES.UTF-8' => 'Español', // Spanish + 'fr_FR.UTF-8' => 'Français', // French + 'el_GR.UTF-8' => 'ελληνικά', // Greek + 'it_IT.UTF-8' => 'Italiano', // Italian + 'hu_HU.UTF-8' => 'magyar', // Hungarian + 'nl_NL.UTF-8' => 'Nederlands', // Dutch + 'pl_PL.UTF-8' => 'Polski', // Polish + 'pt_PT.UTF-8' => 'Português', // Portuguese + 'pt_BR.UTF-8' => 'Português do Brasil', // Brazilian Portuguese + 'ru_RU.UTF-8' => 'Русский язык', // Russian + 'sv_SE.UTF-8' => 'Svenska', // Swedish + 'tr_TR.UTF-8' => 'Türkçe', // Turkish + 'zh_CN.UTF-8' => '簡化字', // Simplified Chinese + 'ko_KR.UTF-8' => '한국어', // Korean (South Korean) + ]; + protected string $locale = self::DEFAULT_LOCALE; public function __construct( protected Environment $environment, - protected ?ServerRequestInterface $request = null + string|array $possibleLocales ) { - $this->locale = $this->determineLocale(); - } - - protected function determineLocale(): string - { - $possibleLocales = []; - - // Attempt to load from request if provided. - if ($this->request instanceof ServerRequestInterface) { - // Prefer user-based profile locale. - $user = $this->request->getAttribute(ServerRequest::ATTR_USER); - if (null !== $user && !empty($user->getLocale()) && 'default' !== $user->getLocale()) { - $possibleLocales[] = $user->getLocale(); - } - - $server_params = $this->request->getServerParams(); - $browser_locale = \Locale::acceptFromHttp($server_params['HTTP_ACCEPT_LANGUAGE'] ?? null); - - if (!empty($browser_locale)) { - if (2 === strlen($browser_locale)) { - $browser_locale = strtolower($browser_locale) . '_' . strtoupper($browser_locale); - } - - $possibleLocales[] = substr($browser_locale, 0, 5) . '.UTF-8'; - } + if (is_string($possibleLocales)) { + $possibleLocales = [$possibleLocales]; } - // Attempt to load from environment variable. - $envLocale = $this->environment->getLang(); - if (!empty($envLocale)) { - $possibleLocales[] = substr($envLocale, 0, 5) . '.UTF-8'; - } - - return $this->getValidLocale($possibleLocales); + $this->locale = $this->getValidLocale($possibleLocales); } protected function getValidLocale(array $possibleLocales): string { - $supportedLocales = $this->environment->getSupportedLocales(); + $supportedLocales = self::SUPPORTED_LOCALES; foreach ($possibleLocales as $locale) { + $locale = self::ensureLocaleEncoding($locale); + // Prefer exact match. if (isset($supportedLocales[$locale])) { return $locale; @@ -79,11 +72,11 @@ class Locale } /** - * @return string A shortened locale (minus .UTF-8) for use in Vue. + * @return string A shortened locale (minus .UTF-8). */ - public function getVueLocale(): string + public function getLocaleWithoutEncoding(): string { - return json_encode(substr($this->locale, 0, 5), JSON_THROW_ON_ERROR); + return self::stripLocaleEncoding($this->locale); } public function setLocale(string $newLocale = self::DEFAULT_LOCALE): void @@ -91,16 +84,23 @@ class Locale $this->locale = $newLocale; } - public function register(): void + public function createTranslator(): Translator { $translator = new Translator(); $localeBase = $this->environment->getBaseDirectory() . '/resources/locale/compiled'; $localePath = $localeBase . '/' . $this->locale . '.php'; + if (file_exists($localePath)) { $translator->loadTranslations($localePath); } + return $translator; + } + + public function register(): void + { + $translator = $this->createTranslator(); $translator->register(); // Register translation superglobal functions @@ -111,4 +111,55 @@ class Locale { return $this->locale; } + + public static function createFromRequest( + Environment $environment, + ServerRequestInterface $request + ): self { + $possibleLocales = []; + + // Prefer user-based profile locale. + $user = $request->getAttribute(ServerRequest::ATTR_USER); + if (null !== $user && !empty($user->getLocale()) && 'default' !== $user->getLocale()) { + $possibleLocales[] = $user->getLocale(); + } + + $server_params = $request->getServerParams(); + $browser_locale = \Locale::acceptFromHttp($server_params['HTTP_ACCEPT_LANGUAGE'] ?? null); + + if (!empty($browser_locale)) { + if (2 === strlen($browser_locale)) { + $browser_locale = strtolower($browser_locale) . '_' . strtoupper($browser_locale); + } + + $possibleLocales[] = substr($browser_locale, 0, 5) . '.UTF-8'; + } + + // Attempt to load from environment variable. + $possibleLocales[] = $environment->getLang(); + + return new self($environment, $possibleLocales); + } + + public static function createForCli( + Environment $environment + ): self { + return new self( + $environment, + $environment->getLang() + ); + } + + public static function stripLocaleEncoding(string $locale): string + { + if (str_contains($locale, '.')) { + return explode('.', $locale, 2)[0]; + } + return $locale; + } + + public static function ensureLocaleEncoding(string $locale): string + { + return self::stripLocaleEncoding($locale) . '.UTF-8'; + } } diff --git a/src/Radio/Configuration.php b/src/Radio/Configuration.php index 53abc65ea..63da776ac 100644 --- a/src/Radio/Configuration.php +++ b/src/Radio/Configuration.php @@ -15,6 +15,7 @@ class Configuration { public const DEFAULT_PORT_MIN = 8000; public const DEFAULT_PORT_MAX = 8499; + public const PROTECTED_PORTS = [8080, 80, 443, 2022]; public function __construct( protected EntityManagerInterface $em, @@ -295,7 +296,7 @@ class Configuration $used_ports = $this->getUsedPorts($station); // Iterate from port 8000 to 9000, in increments of 10 - $protected_ports = [8080]; + $protected_ports = self::PROTECTED_PORTS; $port_min = $this->environment->getAutoAssignPortMin(); $port_max = $this->environment->getAutoAssignPortMax(); @@ -437,4 +438,26 @@ class Configuration $this->reloadSupervisor(); } + + /** + * @return int[] + */ + public static function enumerateDefaultPorts( + int $rangeMin = self::DEFAULT_PORT_MIN, + int $rangeMax = self::DEFAULT_PORT_MAX, + ): array { + $defaultPorts = []; + + for ($i = $rangeMin; $i < $rangeMax; $i += 10) { + if (in_array($i, self::PROTECTED_PORTS, true)) { + continue; + } + + $defaultPorts[] = $i; + $defaultPorts[] = $i + 5; + $defaultPorts[] = $i + 6; + } + + return $defaultPorts; + } } diff --git a/util/docker/web/scripts/docker_installer b/util/docker/web/scripts/docker_installer new file mode 100644 index 000000000..bf3ab776b --- /dev/null +++ b/util/docker/web/scripts/docker_installer @@ -0,0 +1,4 @@ +#!/usr/bin/env bash + +cd /var/azuracast/www +php bin/installer "$@"