Switch from FTP to SFTP on Docker installations.

This commit is contained in:
Buster "Silver Eagle" Neece 2020-01-05 15:29:56 -06:00
parent c8110fcce1
commit e9d8775af6
No known key found for this signature in database
GPG Key ID: 6D9E12FF03411F4E
32 changed files with 708 additions and 444 deletions

2
.env
View File

@ -3,4 +3,4 @@ COMPOSE_PROJECT_NAME=azuracast
AZURACAST_HTTP_PORT=80
AZURACAST_HTTPS_PORT=443
AZURACAST_FTP_PORT=21
AZURACAST_SFTP_PORT=2022

View File

@ -43,14 +43,9 @@ return function (Application $console) {
)->setDescription('Send upcoming song feedback from the AutoDJ back to AzuraCast.');
$console->command(
'azuracast:internal:ftp-auth',
Command\Internal\FtpAuthCommand::class
)->setDescription('Authenticate a user for PureFTPD');
$console->command(
'azuracast:internal:ftp-upload path',
Command\Internal\FtpUploadCommand::class
)->setDescription('Process a file uploaded in PureFTPD');
'azuracast:internal:sftp-upload action username path target-path ssh-cmd',
Command\Internal\SFtpUploadCommand::class
)->setDescription('Process a file uploaded via SFTP');
$console->command(
'azuracast:internal:nextsong station-id [as-autodj]',

View File

@ -0,0 +1,43 @@
<?php
return [
'elements' => [
'username' => [
'text',
[
'label' => __('Username'),
'class' => 'half-width',
'maxLength' => 8,
],
],
'password' => [
'password',
[
'label' => __('New Password'),
'description' => __('Leave blank to use the current password.'),
'autocomplete' => 'off',
'required' => false,
],
],
'publicKeys' => [
'textarea',
[
'label' => __('SSH Public Keys'),
'class' => 'text-preformatted',
'description' => __('Optionally supply SSH public keys this user can use to connect instead of a password. Enter one key per line.'),
'required' => false,
],
],
'submit' => [
'submit',
[
'type' => 'submit',
'label' => __('Save Changes'),
'class' => 'btn btn-lg btn-primary',
],
],
],
];

View File

@ -133,6 +133,12 @@ return function (\App\Event\BuildStationMenu $e) {
'label' => __('Utilities'),
'icon' => 'settings',
'items' => [
'sftp_users' => [
'label' => __('SFTP Users'),
'url' => $router->fromHere('stations:sftp_users:index'),
'visible' => \App\Service\SFTPGo::isSupported(),
'permission' => Acl::STATION_MEDIA,
],
'automation' => [
'label' => __('Automated Assignment'),
'url' => $router->fromHere('stations:automation:index'),

View File

@ -620,6 +620,22 @@ return function (App $app) {
})->add(new Middleware\Permissions(Acl::STATION_REPORTS, true));
$group->group('/sftp_users', function (RouteCollectorProxy $group) {
$group->get('', Controller\Stations\SFTPUsersController::class . ':indexAction')
->setName('stations:sftp_users:index');
$group->map(['GET', 'POST'], '/edit/{id}', Controller\Stations\SFTPUsersController::class . ':editAction')
->setName('stations:sftp_users:edit');
$group->map(['GET', 'POST'], '/add', Controller\Stations\SFTPUsersController::class . ':editAction')
->setName('stations:sftp_users:add');
$group->get('/delete/{id}/{csrf}', Controller\Stations\SFTPUsersController::class . ':deleteAction')
->setName('stations:sftp_users:delete');
})->add(new Middleware\Permissions(Acl::STATION_MEDIA, true));
$group->group('/streamers', function (RouteCollectorProxy $group) {
$group->get('', Controller\Stations\StreamersController::class . ':indexAction')

View File

@ -131,8 +131,10 @@ return [
App\Customization::class => DI\autowire(),
App\Version::class => DI\autowire(),
App\Service\AzuraCastCentral::class => DI\autowire(),
App\Service\Sentry::class => DI\autowire(),
App\Service\GeoLite::class => DI\autowire(),
App\Service\NChan::class => DI\autowire(),
App\Service\Sentry::class => DI\autowire(),
App\Service\SFTPGo::class => DI\autowire(),
App\Validator\Constraints\StationPortCheckerValidator::class => DI\autowire(),
// Message queue manager class
@ -178,9 +180,6 @@ return [
return $mq;
},
// MaxMind (IP Geolocation database for listener metadata)
App\Service\GeoLite::class => DI\autowire(),
// InfluxDB
InfluxDB\Database::class => function (Settings $settings) {
$opts = [
@ -271,6 +270,7 @@ return [
[ // Every 5 minutes tasks
$di->get(App\Sync\Task\Media::class),
$di->get(App\Sync\Task\CheckForUpdates::class),
$di->get(App\Sync\Task\SyncSFTPUsers::class),
],
[ // Every hour tasks
$di->get(App\Sync\Task\Analytics::class),
@ -292,6 +292,7 @@ return [
App\Sync\Task\RadioRequests::class => DI\autowire(),
App\Sync\Task\RelayCleanup::class => DI\autowire(),
App\Sync\Task\RotateLogs::class => DI\autowire(),
App\Sync\Task\SyncSFTPUsers::class => DI\autowire(),
/**
* Web Hooks
@ -341,46 +342,16 @@ return [
App\Notification\Manager::class => DI\autowire(),
/*
* Forms
* Class Groups
*/
App\Form\EntityFormManager::class => function (
EntityManager $em,
Symfony\Component\Serializer\Serializer $serializer,
Symfony\Component\Validator\Validator\ValidatorInterface $validator,
ContainerInterface $di
) {
$custom_forms = [
App\Entity\Station::class => $di->get(App\Form\StationForm::class),
App\Entity\User::class => $di->get(App\Form\UserForm::class),
App\Entity\RolePermission::class => $di->get(App\Form\PermissionsForm::class),
App\Entity\Settings::class => $di->get(App\Form\SettingsForm::class),
App\Entity\StationPlaylist::class => $di->get(App\Form\StationPlaylistForm::class),
App\Entity\StationMount::class => $di->get(App\Form\StationMountForm::class),
App\Entity\StationWebhook::class => $di->get(App\Form\StationWebhookForm::class),
];
return new App\Form\EntityFormManager($em, $serializer, $validator, $custom_forms);
},
App\Form\BackupSettingsForm::class => DI\autowire(),
App\Form\BrandingSettingsForm::class => DI\autowire(),
App\Form\PermissionsForm::class => DI\autowire(),
App\Form\SettingsForm::class => DI\autowire(),
App\Form\StationForm::class => DI\autowire(),
App\Form\StationCloneForm::class => DI\autowire(),
App\Form\StationMountForm::class => DI\autowire(),
App\Form\StationPlaylistForm::class => DI\autowire(),
App\Form\StationWebhookForm::class => DI\autowire(),
App\Form\UserForm::class => DI\autowire(),
App\Form\UserProfileForm::class => DI\autowire(),
'App\Form\*Form' => DI\autowire(),
'App\Entity\Fixture\*' => DI\autowire(),
/*
* Controller Groups
* Controller Classes
*/
'App\Entity\Fixture\*' => DI\autowire(),
'App\Controller\Admin\*Controller' => DI\autowire(),
'App\Controller\Api\*Controller' => DI\autowire(),

View File

@ -27,17 +27,17 @@ services:
env_file: azuracast.env
environment: &default-environment
LANG: ${LANG:-en_US.UTF-8}
AZURACAST_DC_REVISION: 6
AZURACAST_FTP_PORT: ${AZURACAST_FTP_PORT:-21}
AZURACAST_DC_REVISION: 7
AZURACAST_SFTP_PORT: ${AZURACAST_SFTP_PORT:-2022}
ports:
- '${AZURACAST_FTP_PORT:-21}:21'
- '${AZURACAST_FTP_PASV_RANGE:-30000-30049}:30000-30049' # FTP PASV support
- '${AZURACAST_SFTP_PORT:-2022}:2022'
volumes:
- .:/var/azuracast/www
- tmp_data:/var/azuracast/www_tmp
- station_data:/var/azuracast/stations
- shoutcast2_install:/var/azuracast/servers/shoutcast2
- geolite_install:/var/azuracast/geoip
- sftpgo_data:/var/azuracast/sftpgo/persist
- backups:/var/azuracast/backups
restart: always
ulimits: &default-ulimits
@ -143,6 +143,7 @@ volumes:
station_data: {}
shoutcast2_install: {}
geolite_install: {}
sftpgo_data: {}
tmp_data: {}
redis_data: {}
backups: {}

View File

@ -20,8 +20,7 @@ services:
ports:
- '${AZURACAST_HTTP_PORT:-80}:80'
- '${AZURACAST_HTTPS_PORT:-443}:443'
- '${AZURACAST_FTP_PORT:-21}:21'
- '30000-30049:30000-30049' # FTP PASV support
- '${AZURACAST_SFTP_PORT:-2022}:2022'
depends_on:
- mariadb
- influxdb
@ -30,8 +29,8 @@ services:
env_file: azuracast.env
environment: &default-environment
LANG: ${LANG:-en_US.UTF-8}
AZURACAST_DC_REVISION: 6
AZURACAST_FTP_PORT: ${AZURACAST_FTP_PORT:-21}
AZURACAST_DC_REVISION: 7
AZURACAST_SFTP_PORT: ${AZURACAST_SFTP_PORT:-2022}
volumes:
- letsencrypt:/etc/letsencrypt
- www_data:/var/azuracast/www
@ -39,6 +38,7 @@ services:
- station_data:/var/azuracast/stations
- shoutcast2_install:/var/azuracast/servers/shoutcast2
- geolite_install:/var/azuracast/geoip
- sftpgo_data:/var/azuracast/sftpgo/persist
- backups:/var/azuracast/backups
restart: always
ulimits: &default-ulimits
@ -247,6 +247,7 @@ volumes:
letsencrypt: {}
shoutcast2_install: {}
geolite_install: {}
sftpgo_data: {}
station_data: {}
tmp_data: {}
www_data: {}

View File

@ -1,23 +0,0 @@
<?php
namespace App\Console\Command\Internal;
use App\Service\Ftp;
use Azura\Console\Command\CommandAbstract;
use Symfony\Component\Console\Style\SymfonyStyle;
class FtpAuthCommand extends CommandAbstract
{
public function __invoke(
SymfonyStyle $io,
Ftp $ftp
) {
$username = getenv('AUTHD_ACCOUNT');
$password = getenv('AUTHD_PASSWORD');
$ftp_output = $ftp->auth($username, $password);
foreach ($ftp_output as $output_ln) {
$io->writeln($output_ln);
}
return null;
}
}

View File

@ -1,58 +0,0 @@
<?php
namespace App\Console\Command\Internal;
use App\Entity;
use App\Message;
use App\MessageQueue;
use App\Radio\Filesystem;
use Azura\Console\Command\CommandAbstract;
use Doctrine\ORM\EntityManager;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
class FtpUploadCommand extends CommandAbstract
{
public function __invoke(
SymfonyStyle $io,
EntityManager $em,
Entity\Repository\StationRepository $stationRepo,
LoggerInterface $logger,
Filesystem $filesystem,
MessageQueue $messageQueue,
string $path
) {
$logger->info('FTP file uploaded', ['path' => $path]);
// Working backwards from the media's path, find the associated station(s) to process.
$stations = [];
$all_stations = $stationRepo->fetchAll();
$parts = explode('/', dirname($path));
for ($i = count($parts); $i >= 1; $i--) {
$search_path = implode('/', array_slice($parts, 0, $i));
$stations = array_filter($all_stations, function (Entity\Station $station) use ($search_path) {
return $search_path === $station->getRadioMediaDir();
});
if (!empty($stations)) {
break;
}
}
foreach ($stations as $station) {
/** @var Entity\Station $station */
$fs = $filesystem->getForStation($station);
$fs->flushAllCaches();
$relative_path = str_replace($station->getRadioMediaDir() . '/', '', $path);
$message = new Message\AddNewMediaMessage;
$message->station_id = $station->getId();
$message->path = $relative_path;
$messageQueue->produce($message);
}
return null;
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace App\Console\Command\Internal;
use App\Entity;
use App\Message;
use App\MessageQueue;
use App\Radio\Filesystem;
use Azura\Console\Command\CommandAbstract;
use Doctrine\ORM\EntityManager;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
class SFtpUploadCommand extends CommandAbstract
{
public function __invoke(
SymfonyStyle $io,
EntityManager $em,
Entity\Repository\StationRepository $stationRepo,
LoggerInterface $logger,
Filesystem $filesystem,
MessageQueue $messageQueue,
string $action = null,
string $username = null,
string $path = null,
string $targetPath = null,
string $sshCmd = null
) {
$logger->info('SFTP file uploaded', ['path' => $path]);
// Determine which station the username belongs to.
$userRepo = $em->getRepository(Entity\SFTPUser::class);
$sftpUser = $userRepo->findOneBy([
'username' => $username,
]);
if (!$sftpUser instanceof Entity\SFTPUser) {
$logger->error('SFTP Username not found.', ['username' => $username]);
return;
}
$station = $sftpUser->getStation();
$fs = $filesystem->getForStation($station);
$fs->flushAllCaches();
$relative_path = str_replace($station->getRadioMediaDir() . '/', '', $path);
$message = new Message\AddNewMediaMessage;
$message->station_id = $station->getId();
$message->path = $relative_path;
$messageQueue->produce($message);
}
}

View File

@ -2,22 +2,16 @@
namespace App\Controller\Admin;
use App\Entity;
use App\Form\EntityFormManager;
use App\Form\CustomFieldForm;
use App\Http\Response;
use App\Http\ServerRequest;
use Azura\Config;
use Azura\Session\Flash;
use Psr\Http\Message\ResponseInterface;
class CustomFieldsController extends AbstractAdminCrudController
{
/**
* @param Config $config
* @param EntityFormManager $formManager
*/
public function __construct(Config $config, EntityFormManager $formManager)
public function __construct(CustomFieldForm $form)
{
$form = $formManager->getForm(Entity\CustomField::class, $config->get('forms/custom_field'));
parent::__construct($form);
$this->csrf_namespace = 'admin_custom_fields';

View File

@ -66,7 +66,7 @@ abstract class AbstractStationCrudController
return null;
}
$record = $this->record_repo->findOneBy(['id' => $id, 'station_id' => $station->getId()]);
$record = $this->record_repo->findOneBy(['id' => $id, 'station' => $station]);
if (!$record instanceof $this->entity_class) {
throw new NotFoundException(__('Record not found.'));

View File

@ -4,7 +4,7 @@ namespace App\Controller\Stations;
use App\Entity;
use App\Http\Response;
use App\Http\ServerRequest;
use App\Service\Ftp;
use App\Service\SFTPGo;
use Doctrine\ORM\EntityManager;
use Psr\Http\Message\ResponseInterface;
@ -13,8 +13,7 @@ class FilesController
public function __invoke(
ServerRequest $request,
Response $response,
EntityManager $em,
Ftp $ftp
EntityManager $em
): ResponseInterface {
$station = $request->getStation();
@ -45,7 +44,7 @@ class FilesController
}
return $request->getView()->renderToResponse($response, 'stations/files/index', [
'ftp_info' => $ftp->getInfo(),
'show_sftp' => SFTPGo::isSupported(),
'playlists' => $playlists,
'custom_fields' => $custom_fields,
'space_used' => $station->getStorageUsed(),

View File

@ -4,20 +4,17 @@ namespace App\Controller\Stations;
use App\Entity\Station;
use App\Entity\StationRemote;
use App\Exception\PermissionDeniedException;
use App\Form\EntityFormManager;
use App\Form\StationRemoteForm;
use App\Http\Response;
use App\Http\ServerRequest;
use Azura\Config;
use Azura\Session\Flash;
use Psr\Http\Message\ResponseInterface;
class RemotesController extends AbstractStationCrudController
{
public function __construct(EntityFormManager $formManager, Config $config)
public function __construct(StationRemoteForm $form)
{
$form = $formManager->getForm(StationRemote::class, $config->get('forms/remote'));
parent::__construct($form);
$this->csrf_namespace = 'stations_remotes';
}

View File

@ -0,0 +1,76 @@
<?php
namespace App\Controller\Stations;
use App\Exception\StationUnsupportedException;
use App\Form\SFTPUserForm;
use App\Http\Response;
use App\Http\ServerRequest;
use App\Service\AzuraCastCentral;
use App\Service\SFTPGo;
use Azura\Session\Flash;
use Psr\Http\Message\ResponseInterface;
class SFTPUsersController extends AbstractStationCrudController
{
protected AzuraCastCentral $ac_central;
public function __construct(SFTPUserForm $form, AzuraCastCentral $ac_central)
{
parent::__construct($form);
$this->ac_central = $ac_central;
$this->csrf_namespace = 'stations_sftp_users';
}
public function indexAction(ServerRequest $request, Response $response): ResponseInterface
{
if (!SFTPGo::isSupported()) {
throw new StationUnsupportedException(__('This feature is not currently supported on this station.'));
}
$station = $request->getStation();
$baseUrl = $request->getRouter()->getBaseUrl(false)
->withScheme('sftp')
->withPort(null);
$port = $_ENV['AZURACAST_SFTP_PORT'] ?? 2022;
$sftpInfo = [
'url' => (string)$baseUrl,
'ip' => $this->ac_central->getIp(),
'port' => $port,
];
return $request->getView()->renderToResponse($response, 'stations/sftp_users/index', [
'users' => $station->getSFTPUsers(),
'sftp_info' => $sftpInfo,
'csrf' => $request->getCsrf()->generate($this->csrf_namespace),
]);
}
public function editAction(ServerRequest $request, Response $response, $id = null): ResponseInterface
{
if (false !== $this->_doEdit($request, $id)) {
$request->getFlash()->addMessage('<b>' . __('Changes saved.') . '</b>', Flash::SUCCESS);
return $response->withRedirect($request->getRouter()->fromHere('stations:sftp_users:index'));
}
return $request->getView()->renderToResponse($response, 'system/form_page', [
'form' => $this->form,
'render_mode' => 'edit',
'title' => $id ? __('Edit SFTP User') : __('Add SFTP User'),
]);
}
public function deleteAction(
ServerRequest $request,
Response $response,
$id,
$csrf
): ResponseInterface {
$this->_doDelete($request, $id, $csrf);
$request->getFlash()->addMessage('<b>' . __('SFTP User deleted.') . '</b>', Flash::SUCCESS);
return $response->withRedirect($request->getRouter()->fromHere('stations:sftp_users:index'));
}
}

View File

@ -3,11 +3,10 @@ namespace App\Controller\Stations;
use App\Entity;
use App\Exception\StationUnsupportedException;
use App\Form\EntityFormManager;
use App\Form\StationStreamerForm;
use App\Http\Response;
use App\Http\ServerRequest;
use App\Service\AzuraCastCentral;
use Azura\Config;
use Azura\Session\Flash;
use Psr\Http\Message\ResponseInterface;
@ -18,12 +17,10 @@ class StreamersController extends AbstractStationCrudController
protected Entity\Repository\SettingsRepository $settingsRepo;
public function __construct(
EntityFormManager $formManager,
Config $config,
StationStreamerForm $form,
AzuraCastCentral $ac_central,
Entity\Repository\SettingsRepository $settingsRepo
) {
$form = $formManager->getForm(Entity\StationStreamer::class, $config->get('forms/streamer'));
parent::__construct($form);
$this->ac_central = $ac_central;

View File

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Entity\Migration;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20200105190343 extends AbstractMigration
{
public function getDescription() : string
{
return '';
}
public function up(Schema $schema) : void
{
// this up() migration is auto-generated, please modify it to your needs
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.');
$this->addSql('CREATE TABLE sftp_user (id INT AUTO_INCREMENT NOT NULL, station_id INT DEFAULT NULL, username VARCHAR(8) NOT NULL, password VARCHAR(255) NOT NULL, public_keys LONGTEXT DEFAULT NULL, INDEX IDX_3C32EA3421BDB235 (station_id), UNIQUE INDEX username_idx (username), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_general_ci` ENGINE = InnoDB');
$this->addSql('ALTER TABLE sftp_user ADD CONSTRAINT FK_3C32EA3421BDB235 FOREIGN KEY (station_id) REFERENCES station (id) ON DELETE CASCADE');
}
public function down(Schema $schema) : void
{
// this down() migration is auto-generated, please modify it to your needs
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.');
$this->addSql('DROP TABLE sftp_user');
}
}

124
src/Entity/SFTPUser.php Normal file
View File

@ -0,0 +1,124 @@
<?php
namespace App\Entity;
use App\Annotations\AuditLog\Auditable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @ORM\Table(name="sftp_user", uniqueConstraints={
* @ORM\UniqueConstraint(name="username_idx", columns={"username"})
* })
* @ORM\Entity()
*
* @Auditable()
*/
class SFTPUser
{
/**
* @ORM\Column(name="id", type="integer")
* @ORM\Id
* @ORM\GeneratedValue(strategy="AUTO")
*
* @var int
*/
protected $id;
/**
* @ORM\ManyToOne(targetEntity="Station", inversedBy="SFTPUsers")
* @ORM\JoinColumns({
* @ORM\JoinColumn(name="station_id", referencedColumnName="id", onDelete="CASCADE")
* })
* @var Station
*/
protected $station;
/**
* @ORM\Column(name="username", type="string", length=8, nullable=false)
* @var string
*
* @Assert\Length(min=1, max=8)
* @Assert\NotBlank
*/
protected $username;
/**
* @ORM\Column(name="password", type="string", length=255, nullable=false)
* @var string
*
* @Assert\NotBlank
*/
protected $password;
/**
* @ORM\Column(name="public_keys", type="text", nullable=true)
* @var string|null
*/
protected $publicKeys;
public function __construct(Station $station)
{
$this->station = $station;
}
public function getId(): int
{
return $this->id;
}
public function getStation(): Station
{
return $this->station;
}
public function getUsername(): string
{
return $this->username;
}
public function setUsername(string $username): void
{
$this->username = $username;
}
public function getPassword(): string
{
return '';
}
public function getHashedPassword(): string
{
return $this->password;
}
public function setPassword(?string $password): void
{
if (!empty($password)) {
$this->password = password_hash($password, \PASSWORD_ARGON2ID, [
'memory_cost' => 65535,
'time_cost' => 3,
'threads' => 2,
]);
}
}
public function getPublicKeys(): ?string
{
return $this->publicKeys;
}
public function getPublicKeysArray(): array
{
$pubKeysRaw = trim($this->publicKeys);
if (!empty($pubKeysRaw)) {
return explode("\n", $pubKeysRaw);
}
return [];
}
public function setPublicKeys(?string $publicKeys): void
{
$this->publicKeys = $publicKeys;
}
}

View File

@ -381,6 +381,12 @@ class Station
*/
protected $webhooks;
/**
* @ORM\OneToMany(targetEntity="SFTPUser", mappedBy="station")
* @var Collection
*/
protected $SFTPUsers;
public function __construct()
{
$this->history = new ArrayCollection;
@ -390,6 +396,7 @@ class Station
$this->remotes = new ArrayCollection;
$this->webhooks = new ArrayCollection;
$this->streamers = new ArrayCollection;
$this->SFTPUsers = new ArrayCollection;
}
/**
@ -1223,6 +1230,14 @@ class Station
return $this->webhooks;
}
/**
* @return Collection
*/
public function getSFTPUsers(): Collection
{
return $this->SFTPUsers;
}
/**
* Retrieve the API version of the object/array.
*

View File

@ -12,7 +12,6 @@ use OpenApi\Annotations as OA;
use OTPHP\Factory;
use Symfony\Component\Serializer\Annotation as Serializer;
use Symfony\Component\Validator\Constraints as Assert;
use const PASSWORD_ARGON2I;
use const PASSWORD_BCRYPT;
/**
@ -235,8 +234,8 @@ class User
*/
protected function _getPasswordAlgorithm(): array
{
if (defined('PASSWORD_ARGON2I')) {
return [PASSWORD_ARGON2I, []];
if (defined('PASSWORD_ARGON2ID')) {
return [PASSWORD_ARGON2ID, []];
}
return [PASSWORD_BCRYPT, []];

View File

@ -0,0 +1,23 @@
<?php
namespace App\Form;
use App\Entity;
use Azura\Config;
use Doctrine\ORM\EntityManager;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Validator\Validator\ValidatorInterface;
class CustomFieldForm extends EntityForm
{
public function __construct(
EntityManager $em,
Serializer $serializer,
ValidatorInterface $validator,
Config $config
) {
$form_config = $config->get('forms/custom_field');
parent::__construct($em, $serializer, $validator, $form_config);
$this->entityClass = Entity\CustomField::class;
}
}

View File

@ -1,53 +0,0 @@
<?php
namespace App\Form;
use Doctrine\ORM\EntityManager;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Validator\Validator\ValidatorInterface;
class EntityFormManager
{
protected EntityManager $em;
protected Serializer $serializer;
protected ValidatorInterface $validator;
/** @var EntityForm[] */
protected array $custom_forms;
public function __construct(
EntityManager $em,
Serializer $serializer,
ValidatorInterface $validator,
array $custom_forms
) {
$this->em = $em;
$this->serializer = $serializer;
$this->validator = $validator;
$this->custom_forms = $custom_forms;
}
/**
* Given a specified entity class and form configuration array, return
* a configured and initialized EntityForm.
*
* @param string $entity_class
* @param array|null $form_config
* @param array|null $defaults
*
* @return EntityForm
*/
public function getForm($entity_class, array $form_config = null, array $defaults = null): EntityForm
{
if (isset($this->custom_forms[$entity_class])) {
return $this->custom_forms[$entity_class];
}
$form = new EntityForm($this->em, $this->serializer, $this->validator, $form_config, $defaults);
$form->setEntityClass($entity_class);
return $form;
}
}

40
src/Form/SFTPUserForm.php Normal file
View File

@ -0,0 +1,40 @@
<?php
namespace App\Form;
use App\Entity;
use App\Http\ServerRequest;
use App\Service\SFTPGo;
use Azura\Config;
use Doctrine\ORM\EntityManager;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Validator\Validator\ValidatorInterface;
class SFTPUserForm extends EntityForm
{
protected SFTPGo $sftpgo;
public function __construct(
EntityManager $em,
Serializer $serializer,
ValidatorInterface $validator,
Config $config,
SFTPGo $sftpgo
) {
$form_config = $config->get('forms/sftp_user');
parent::__construct($em, $serializer, $validator, $form_config);
$this->sftpgo = $sftpgo;
$this->entityClass = Entity\SFTPUser::class;
}
public function process(ServerRequest $request, $record = null)
{
$result = parent::process($request, $record);
if (false !== $result) {
$this->sftpgo->sync();
}
return $result;
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Form;
use App\Entity;
use Azura\Config;
use Doctrine\ORM\EntityManager;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Validator\Validator\ValidatorInterface;
class StationRemoteForm extends EntityForm
{
public function __construct(
EntityManager $em,
Serializer $serializer,
ValidatorInterface $validator,
Config $config
) {
$form_config = $config->get('forms/remote');
parent::__construct($em, $serializer, $validator, $form_config);
$this->entityClass = Entity\StationRemote::class;
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Form;
use App\Entity;
use Azura\Config;
use Doctrine\ORM\EntityManager;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Validator\Validator\ValidatorInterface;
class StationStreamerForm extends EntityForm
{
public function __construct(
EntityManager $em,
Serializer $serializer,
ValidatorInterface $validator,
Config $config
) {
$form_config = $config->get('forms/streamer');
parent::__construct($em, $serializer, $validator, $form_config);
$this->entityClass = Entity\StationStreamer::class;
}
}

View File

@ -1,150 +0,0 @@
<?php
namespace App\Service;
use App\Acl;
use App\Entity;
use App\Http\Router;
use App\Settings;
use App\Utilities;
use Doctrine\ORM\EntityManager;
use Psr\Log\LoggerInterface;
use RuntimeException;
class Ftp
{
protected AzuraCastCentral $ac_central;
protected Acl $acl;
protected Settings $app_settings;
protected EntityManager $em;
protected Entity\Repository\UserRepository $user_repo;
protected Entity\Repository\SettingsRepository $settings_repo;
protected LoggerInterface $logger;
protected Router $router;
public function __construct(
AzuraCastCentral $ac_central,
Acl $acl,
Settings $app_settings,
EntityManager $em,
Entity\Repository\SettingsRepository $settings_repo,
Entity\Repository\UserRepository $user_repo,
LoggerInterface $logger,
Router $router
) {
$this->ac_central = $ac_central;
$this->acl = $acl;
$this->app_settings = $app_settings;
$this->em = $em;
$this->logger = $logger;
$this->router = $router;
$this->user_repo = $user_repo;
$this->settings_repo = $settings_repo;
}
/**
* Given a username and password, handle a PureFTPD authentication request.
*
* @param string $username
* @param string $password
*
* @return array
*/
public function auth(string $username, string $password): array
{
$error = ['auth_ok:-1', 'end'];
if (!$this->isEnabled()) {
return $error;
}
// Some FTP clients URL Encode the username, particularly the '@' of the e-mail address.
$username = urldecode($username);
$this->logger->info('FTP Authentication attempt.', [
'username' => $username,
]);
$user = $this->user_repo->authenticate($username, $password);
if (!($user instanceof Entity\User)) {
return $error;
}
// Create a temporary directory with symlinks to every station that user can manage.
$ftp_dir = '/tmp/azuracast_ftp_directories/user_' . $user->getId();
Utilities::rmdirRecursive($ftp_dir);
if (!mkdir($ftp_dir) && !is_dir($ftp_dir)) {
throw new RuntimeException(sprintf('Directory "%s" was not created', $ftp_dir));
}
$stations = $this->em->getRepository(Entity\Station::class)->findAll();
$has_any_stations = false;
foreach ($stations as $station) {
/** @var Entity\Station $station */
if ($this->acl->userAllowed($user, Acl::STATION_MEDIA, $station->getId())) {
$has_any_stations = true;
$station_media_dir = $station->getRadioMediaDir();
$symlink_path = $ftp_dir . '/' . $station->getShortName();
symlink($station_media_dir, $symlink_path);
}
}
if (!$has_any_stations) {
return $error;
}
return [
'auth_ok:1',
'uid:1000',
'gid:1000',
'dir:' . $ftp_dir . '/./',
'end',
];
}
/**
* @return bool Whether FTP services are enabled for this installation.
*/
public function isEnabled(): bool
{
if (!$this->app_settings->isDocker() || $_ENV['AZURACAST_DC_REVISION'] < 6) {
return false;
}
return (bool)$this->settings_repo->getSetting(Entity\Settings::ENABLE_FTP_SERVER, true);
}
/**
* @return array|null FTP connection information, if FTP is enabled.
*/
public function getInfo(): ?array
{
if (!$this->isEnabled()) {
return null;
}
$base_url = $this->router->getBaseUrl(false)
->withScheme('ftp')
->withPort(null);
$port = $_ENV['AZURACAST_FTP_PORT'] ?? 21;
return [
'url' => (string)$base_url,
'ip' => $this->ac_central->getIp(),
'port' => $port,
];
}
}

76
src/Service/SFTPGo.php Normal file
View File

@ -0,0 +1,76 @@
<?php
namespace App\Service;
use App\Entity\SFTPUser;
use App\Settings;
use Doctrine\ORM\EntityManager;
use GuzzleHttp\Client;
class SFTPGo
{
protected EntityManager $em;
protected Client $client;
public function __construct(EntityManager $em)
{
$this->em = $em;
$this->client = new Client([
'base_uri' => 'http://localhost:10080/api/v1/',
'timeout' => 2.0,
]);
}
public function sync(): void
{
$users = $this->em->createQuery(/** @lang DQL */ '
SELECT su, s
FROM App\Entity\SFTPUser su JOIN su.station s
')->execute();
$export = [];
foreach ($users as $user) {
/** @var SFTPUser $user */
$row = [
'id' => $user->getId(),
'username' => $user->getUsername(),
'expiration_date' => 0,
'status' => 1,
'password' => $user->getHashedPassword(),
'public_keys' => $user->getPublicKeysArray(),
'home_dir' => $user->getStation()->getRadioMediaDir(),
'uid' => 0,
'gid' => 0,
'permissions' => [
'/' => ['*'],
],
];
$export[] = $row;
}
$backupContents = json_encode(['users' => $export], \JSON_THROW_ON_ERROR);
$backupDir = '/var/azuracast/sftpgo/backups';
$backupFileName = time() . '.json';
$backupPath = $backupDir . '/' . $backupFileName;
file_put_contents($backupPath, $backupContents);
$this->client->get('loaddata', [
'query' => [
'input_file' => $backupPath,
'scan_quota' => 2,
],
]);
}
public static function isSupported(): bool
{
$settings = Settings::getInstance();
return $settings->isDockerRevisionNewerThan(7);
}
}

View File

@ -21,5 +21,13 @@ class Settings extends \Azura\Settings
return $this->getParentDirectory() . '/stations';
}
public function isDockerRevisionNewerThan(int $version): bool
{
if (!$this->isDocker()) {
return false;
}
$compareVersion = (int)$this->get(self::DOCKER_REVISION, 0);
return ($compareVersion >= $version);
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Sync\Task;
use App\Entity;
use App\Service\SFTPGo;
use Doctrine\ORM\EntityManager;
class SyncSFTPUsers extends AbstractTask
{
protected SFTPGo $sftpgo;
public function __construct(
EntityManager $em,
Entity\Repository\SettingsRepository $settingsRepo,
SFTPGo $sftpgo
) {
parent::__construct($em, $settingsRepo);
$this->sftpgo = $sftpgo;
}
public function run($force = false): void
{
if (!SFTPGo::isSupported()) {
return;
}
$this->sftpgo->sync();
}
}

View File

@ -25,30 +25,31 @@ $assets
<div class="card-header bg-primary-dark">
<div class="row align-items-center">
<div class="col-md-7">
<h2 class="card-title"><?= __('Music Files') ?></h2>
<h2 class="card-title"><?=__('Music Files')?></h2>
</div>
<div class="col-md-5 text-right">
<div class="progress">
<div class="progress-bar" role="progressbar" aria-valuenow="<?= $space_percent ?>"
aria-valuemin="0" aria-valuemax="100" style="width: <?= $space_percent ?>%;">
<span class="sr-only"><?= $space_percent ?>%</span>
<div class="progress-bar" role="progressbar" aria-valuenow="<?=$space_percent?>"
aria-valuemin="0" aria-valuemax="100" style="width: <?=$space_percent?>%;">
<span class="sr-only"><?=$space_percent?>%</span>
</div>
</div>
<?= __('%s of %s Used (%d Files)', $space_used, $space_total, $files_count) ?>
<?=__('%s of %s Used (%d Files)', $space_used, $space_total, $files_count)?>
</div>
</div>
</div>
<?php if ($ftp_info): ?>
<?php if ($show_sftp): ?>
<div class="card-body alert-info d-flex align-items-center" role="alert">
<div class="flex-shrink-0 mr-2">
<i class="material-icons" aria-hidden="true">info</i>
</div>
<div class="flex-fill">
<p class="mb-0">
<?= __('You can also upload files in bulk via FTP.') ?><br>
<button type="button" class="btn btn-link p-0" data-toggle="modal" data-target="#ftpinfo">
<?= __('View connection instructions') ?>
</button>
<?=__('You can also upload files in bulk via SFTP.')?><br>
<a class="btn btn-link p-0" target="_blank"
href="<?=$router->fromHere('stations:sftp_users:index')?>">
<?=__('Manage SFTP Accounts')?>
</a>
</p>
</div>
</div>
@ -57,72 +58,4 @@ $assets
<div id="media-manager"></div>
</div>
</div>
</div>
<?php if ($ftp_info): ?>
<div id="ftpinfo" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><?= __('FTP Connection Information') ?></h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<table class="table table-striped">
<colgroup>
<col width="40%">
<col width="60%">
</colgroup>
<tbody>
<tr>
<td>
<?= __('Server') ?>
</td>
<td>
<?= $ftp_info['url'] ?><br>
<small><?= __('You may need to connect directly via your IP address, which is <code>%s</code>.',
$ftp_info['ip']) ?></small>
</td>
</tr>
<tr>
<td>
<?= __('Port') ?>
</td>
<td>
<?= $ftp_info['port'] ?>
</td>
</tr>
<tr>
<td>
<?= __('Protocol') ?>
</td>
<td>
<?= __('FTP with Explicit TLS (FTPS)') ?><br>
<small><?= __('Unencrypted FTP is also allowed, but not recommended.') ?></small>
</td>
</tr>
<tr>
<td>
<?= __('Username') ?>
</td>
<td>
<?= __('Your AzuraCast E-mail Address') ?>
</td>
</tr>
<tr>
<td>
<?= __('Password') ?>
</td>
<td>
<?= __('Your AzuraCast Password') ?>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<?php endif; ?>
</div>

View File

@ -0,0 +1,68 @@
<?php $this->layout('main', ['title' => __('SFTP Users'), 'manual' => true]) ?>
<div class="row">
<div class="col-md-8">
<div class="card">
<div class="card-header bg-primary-dark">
<h2 class="card-title"><?=__('SFTP Users')?></h2>
</div>
<div class="card-actions">
<a class="btn btn-outline-primary" role="button"
href="<?=$router->fromHere('stations:sftp_users:add')?>">
<i class="material-icons" aria-hidden="true">add</i>
<?=__('Add SFTP User')?>
</a>
</div>
<table class="table table-responsive-lg table-striped mb-0">
<colgroup>
<col width="25%">
<col width="75%">
</colgroup>
<thead>
<tr>
<th><?=__('Actions')?></th>
<th><?=__('Username')?></th>
</tr>
</thead>
<tbody>
<?php foreach ($users as $row): ?>
<?php /** @var App\Entity\SFTPUser $row */ ?>
<tr class="align-middle">
<td>
<div class="btn-group btn-group-sm">
<a class="btn btn-sm btn-primary" href="<?=$router->fromHere('stations:sftp_users:edit',
['id' => $row->getId()])?>"><?=__('Edit')?></a>
<a class="btn btn-sm btn-danger"
data-confirm-title="<?=$this->e(__('Delete SFTP User "%s"?', $row->getUsername()))?>"
href="<?=$router->fromHere('stations:sftp_users:delete',
['id' => $row->getId(), 'csrf' => $csrf])?>"><?=__('Delete')?></a>
</div>
</td>
<td><code><?=$this->e($row->getUsername())?></code></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header bg-primary-dark">
<h2 class="card-title"><?=__('Connection Information')?></h2>
</div>
<div class="card-body">
<dl>
<dt class="mb-1"><?=__('Server')?>:</dt>
<dd><code><?=$this->e($sftp_info['url'])?></code></dd>
<?php if ($sftp_info['ip']): ?>
<dd><?=__('You may need to connect directly via your IP address, which is <code>%s</code>.',
$sftp_info['ip'])?></dd>
<?php endif; ?>
<dt class="mb-1"><?=__('Port')?>:</dt>
<dd><code><?=(int)$sftp_info['port']?></code></dd>
</dl>
</div>
</div>
</div>
</div>