Compare commits

..

7 Commits

40 changed files with 885 additions and 84 deletions

View File

@@ -10,3 +10,22 @@ MERCURE_JWT_SECRET="%%mercure_jwt_secret%%"
JELLYFIN_URL=%%jellyfin_url%% JELLYFIN_URL=%%jellyfin_url%%
JELLYFIN_TOKEN=%%jellyfin_token%% JELLYFIN_TOKEN=%%jellyfin_token%%
REDIS_HOST="%%redis_host%%" REDIS_HOST="%%redis_host%%"
LDAP_HOST=
LDAP_PORT=
LDAP_ENCRYPTION=
LDAP_BASE_DN=
LDAP_BIND_USER=
LDAP_BIND_PASS=
LDAP_DN_STRING=
LDAP_UID_KEY="uid"
# LDAP group that identifies an Admin
# Users with this LDAP group will automatically
# get the admin role in this system.
LDAP_ADMIN_ROLE_DN="cn=admins,cn=groups,cn=accounts,dc=caldwell,dc=local"
LDAP_EMAIL_ATTRIBUTE=mail
LDAP_USERNAME_ATTRIBUTE=uid
LDAP_NAME_ATTRIBUTE=displayname

View File

@@ -1,4 +1,10 @@
FROM registry.caldwell.digital/library/php:8.4-apache FROM registry.caldwell.digital/library/php:8.4-apache
RUN apt-get update && \
apt-get install libldap2-dev -y && \
rm -rf /var/lib/apt/lists/* && \
docker-php-ext-configure ldap --with-libdir=lib/x86_64-linux-gnu/ && \
docker-php-ext-install ldap
COPY ./bash/vhost.conf /etc/apache2/sites-enabled/vhost.conf COPY ./bash/vhost.conf /etc/apache2/sites-enabled/vhost.conf
RUN rm /etc/apache2/sites-enabled/000-default.conf RUN rm /etc/apache2/sites-enabled/000-default.conf

View File

@@ -1,5 +1,11 @@
FROM registry.caldwell.digital/library/php:8.4-apache FROM registry.caldwell.digital/library/php:8.4-apache
RUN apt-get update && \
apt-get install libldap2-dev -y && \
rm -rf /var/lib/apt/lists/* && \
docker-php-ext-configure ldap --with-libdir=lib/x86_64-linux-gnu/ && \
docker-php-ext-install ldap
COPY --chown=www-data:www-data . /var/www COPY --chown=www-data:www-data . /var/www
COPY ./bash/vhost.conf /etc/apache2/sites-enabled/vhost.conf COPY ./bash/vhost.conf /etc/apache2/sites-enabled/vhost.conf
RUN rm /etc/apache2/sites-enabled/000-default.conf RUN rm /etc/apache2/sites-enabled/000-default.conf

View File

@@ -26,7 +26,7 @@ export default class extends Controller {
title: this.element.dataset['title'], title: this.element.dataset['title'],
filename: this.filenameValue, filename: this.filenameValue,
mediaType: this.mediaTypeValue, mediaType: this.mediaTypeValue,
imdbId: this.imdbIdValue imdbId: this.imdbIdValue,
}) })
}) })
.then(res => res.json()) .then(res => res.json())

View File

@@ -29,6 +29,7 @@
"symfony/flex": "^2", "symfony/flex": "^2",
"symfony/form": "7.2.*", "symfony/form": "7.2.*",
"symfony/framework-bundle": "7.2.*", "symfony/framework-bundle": "7.2.*",
"symfony/ldap": "7.2.*",
"symfony/mercure-bundle": "^0.3.9", "symfony/mercure-bundle": "^0.3.9",
"symfony/messenger": "7.2.*", "symfony/messenger": "7.2.*",
"symfony/runtime": "7.2.*", "symfony/runtime": "7.2.*",

77
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "7e29123297e1ac72cd417967d2a761b4", "content-hash": "c179718ee29dbe018b93ea7d46764931",
"packages": [ "packages": [
{ {
"name": "1tomany/rich-bundle", "name": "1tomany/rich-bundle",
@@ -5082,6 +5082,81 @@
], ],
"time": "2025-05-02T09:04:03+00:00" "time": "2025-05-02T09:04:03+00:00"
}, },
{
"name": "symfony/ldap",
"version": "v7.2.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/ldap.git",
"reference": "48013cfa9d394343162dae7da914112a6206b575"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/ldap/zipball/48013cfa9d394343162dae7da914112a6206b575",
"reference": "48013cfa9d394343162dae7da914112a6206b575",
"shasum": ""
},
"require": {
"ext-ldap": "*",
"php": ">=8.2",
"symfony/options-resolver": "^6.4|^7.0"
},
"conflict": {
"symfony/options-resolver": "<6.4",
"symfony/security-core": "<6.4"
},
"require-dev": {
"symfony/security-core": "^6.4|^7.0",
"symfony/security-http": "^6.4|^7.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Ldap\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Charles Sarrazin",
"email": "charles@sarraz.in"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides a LDAP client for PHP on top of PHP's ldap extension",
"homepage": "https://symfony.com",
"keywords": [
"active-directory",
"ldap"
],
"support": {
"source": "https://github.com/symfony/ldap/tree/v7.2.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2024-11-25T15:21:05+00:00"
},
{ {
"name": "symfony/mercure", "name": "symfony/mercure",
"version": "v0.6.5", "version": "v0.6.5",

56
config/dist/ldap.security.yaml vendored Normal file
View File

@@ -0,0 +1,56 @@
security:
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
providers:
users_in_memory: { memory: null }
app_local:
entity:
class: App\User\Framework\Entity\User
property: email
app_ldap:
id: App\User\Framework\Security\LdapUserProvider
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
lazy: true
provider: app_ldap
form_login_ldap:
login_path: app_login
check_path: app_login
enable_csrf: true
service: Symfony\Component\Ldap\Ldap
dn_string: '%env(LDAP_DN_STRING)%'
logout:
path: app_logout
# activate different ways to authenticate
# https://symfony.com/doc/current/security.html#the-firewall
# https://symfony.com/doc/current/security/impersonating_user.html
# switch_user: true
# Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used
access_control:
- { path: ^/register, roles: PUBLIC_ACCESS }
- { path: ^/login, roles: PUBLIC_ACCESS }
- { path: ^/, roles: ROLE_USER } # Or ROLE_ADMIN, ROLE_SUPER_ADMIN,
when@test:
security:
password_hashers:
# By default, password hashers are resource intensive and take time. This is
# important to generate secure password hashes. In tests however, secure hashes
# are not important, waste resources and increase test times. The following
# reduces the work factor to the lowest possible values.
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
algorithm: auto
cost: 4 # Lowest possible value for bcrypt
time_cost: 3 # Lowest possible value for argon
memory_cost: 10 # Lowest possible value for argon

54
config/dist/local.security.yaml vendored Normal file
View File

@@ -0,0 +1,54 @@
security:
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
providers:
users_in_memory: { memory: null }
app_local:
entity:
class: App\User\Framework\Entity\User
property: email
app_ldap:
id: App\User\Framework\Security\LdapUserProvider
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
lazy: true
provider: app_local
form_login:
login_path: app_login
check_path: app_login
enable_csrf: true
logout:
path: app_logout
# activate different ways to authenticate
# https://symfony.com/doc/current/security.html#the-firewall
# https://symfony.com/doc/current/security/impersonating_user.html
# switch_user: true
# Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used
access_control:
- { path: ^/register, roles: PUBLIC_ACCESS }
- { path: ^/login, roles: PUBLIC_ACCESS }
- { path: ^/, roles: ROLE_USER } # Or ROLE_ADMIN, ROLE_SUPER_ADMIN,
when@test:
security:
password_hashers:
# By default, password hashers are resource intensive and take time. This is
# important to generate secure password hashes. In tests however, secure hashes
# are not important, waste resources and increase test times. The following
# reduces the work factor to the lowest possible values.
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
algorithm: auto
cost: 4 # Lowest possible value for bcrypt
time_cost: 3 # Lowest possible value for argon
memory_cost: 10 # Lowest possible value for argon

View File

@@ -5,26 +5,29 @@ security:
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider # https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
providers: providers:
users_in_memory: { memory: null } users_in_memory: { memory: null }
app_user_provider: app_local:
entity: entity:
class: App\User\Framework\Entity\User class: App\User\Framework\Entity\User
property: email property: email
app_ldap:
id: App\User\Framework\Security\LdapUserProvider
firewalls: firewalls:
dev: dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/ pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false security: false
main: main:
lazy: true lazy: true
provider: app_user_provider provider: app_ldap
form_login: form_login_ldap:
login_path: app_login login_path: app_login
check_path: app_login check_path: app_login
enable_csrf: true enable_csrf: true
service: Symfony\Component\Ldap\Ldap
dn_string: '%env(LDAP_DN_STRING)%'
logout: logout:
path: app_logout path: app_logout
# where to redirect after logout
# target: app_any_route
# activate different ways to authenticate # activate different ways to authenticate
# https://symfony.com/doc/current/security.html#the-firewall # https://symfony.com/doc/current/security.html#the-firewall

61
config/security.yaml Normal file
View File

@@ -0,0 +1,61 @@
security:
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
providers:
users_in_memory: { memory: null }
app_local:
entity:
class: App\User\Framework\Entity\User
property: email
app_ldap:
id: App\User\Framework\Security\LdapUserProvider
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
lazy: true
provider: app_ldap
entry_point: form_login_ldap
form_login_ldap:
login_path: app_login
check_path: app_login
enable_csrf: true
service: Symfony\Component\Ldap\Ldap
dn_string: '%env(LDAP_DN_STRING)%'
form_login:
login_path: app_login
check_path: app_login
enable_csrf: true
logout:
path: app_logout
# activate different ways to authenticate
# https://symfony.com/doc/current/security.html#the-firewall
# https://symfony.com/doc/current/security/impersonating_user.html
# switch_user: true
# Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used
access_control:
- { path: ^/register, roles: PUBLIC_ACCESS }
- { path: ^/login, roles: PUBLIC_ACCESS }
- { path: ^/, roles: ROLE_USER } # Or ROLE_ADMIN, ROLE_SUPER_ADMIN,
when@test:
security:
password_hashers:
# By default, password hashers are resource intensive and take time. This is
# important to generate secure password hashes. In tests however, secure hashes
# are not important, waste resources and increase test times. The following
# reduces the work factor to the lowest possible values.
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
algorithm: auto
cost: 4 # Lowest possible value for bcrypt
time_cost: 3 # Lowest possible value for argon
memory_cost: 10 # Lowest possible value for argon

View File

@@ -28,6 +28,37 @@ services:
# please note that last definitions always *replace* previous ones # please note that last definitions always *replace* previous ones
App\Download\Downloader\DownloaderInterface: "@App\\Download\\Downloader\\ProcessDownloader" App\Download\Downloader\DownloaderInterface: "@App\\Download\\Downloader\\ProcessDownloader"
# Session
Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler: Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler:
arguments: arguments:
- '%env(DATABASE_URL)%' - '%env(DATABASE_URL)%'
# LDAP
App\User\Framework\Security\LdapUserProvider:
arguments:
$userRepository: '@App\User\Framework\Repository\UserRepository'
$ldap: '@Symfony\Component\Ldap\LdapInterface'
$baseDn: '%env(LDAP_BASE_DN)%'
$searchDn: '%env(LDAP_BIND_USER)%'
$searchPassword: '%env(LDAP_BIND_PASS)%'
$defaultRoles: ['ROLE_USER']
$uidKey: '%env(LDAP_UID_KEY)%'
# $passwordAttribute: '%env(LDAP_PASSWORD_ATTRIBUTE)%'
Symfony\Component\Ldap\LdapInterface: '@Symfony\Component\Ldap\Ldap'
Symfony\Component\Ldap\Ldap:
arguments: [ '@Symfony\Component\Ldap\Adapter\ExtLdap\Adapter' ]
tags:
- ldap
Symfony\Component\Ldap\Adapter\ExtLdap\Adapter:
arguments:
- host: '%env(LDAP_HOST)%'
port: '%env(LDAP_PORT)%'
encryption: '%env(LDAP_ENCRYPTION)%'
options:
protocol_version: 3
referrals: false

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250510185814 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->addSql(<<<'SQL'
ALTER TABLE user ADD username VARCHAR(255) DEFAULT NULL
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE user DROP username
SQL);
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250511050008 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->addSql(<<<'SQL'
ALTER TABLE download ADD user_id INT DEFAULT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE download ADD CONSTRAINT FK_781A8270A76ED395 FOREIGN KEY (user_id) REFERENCES user (id)
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX IDX_781A8270A76ED395 ON download (user_id)
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE download DROP FOREIGN KEY FK_781A8270A76ED395
SQL);
$this->addSql(<<<'SQL'
DROP INDEX IDX_781A8270A76ED395 ON download
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE download DROP user_id
SQL);
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'config:set',
description: 'Add a short description for your command',
)]
class ConfigSetCommand extends Command
{
public function __construct()
{
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('key', InputArgument::REQUIRED, 'Config key')
->addArgument('value', InputArgument::REQUIRED, 'Config value')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$key = $input->getArgument('key');
$handlers = [
'auth.method' => 'setAuthMethod',
];
$handler = $handlers[$key];
$this->$handler($input, $io);
$io->success('Success: "' . $input->getArgument('key') . '" set to "' . $input->getArgument('value') . '"');
return Command::SUCCESS;
}
private function setAuthMethod(InputInterface $input, SymfonyStyle $io)
{
$config = [
'local' => 'config/dist/local.security.yaml',
'ldap' => 'config/dist/ldap.security.yaml',
];
$authMethod = $input->getArgument('value');
$io->text('> Setting auth method to: ' . $authMethod);
copy($config[$authMethod], 'config/packages/security.yaml');
}
}

View File

@@ -6,6 +6,7 @@ use App\Download\Action\Input\DownloadMediaInput;
use App\Download\Framework\Repository\DownloadRepository; use App\Download\Framework\Repository\DownloadRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
@@ -21,6 +22,7 @@ class DownloadController extends AbstractController
DownloadMediaInput $input, DownloadMediaInput $input,
): Response { ): Response {
$download = $this->downloadRepository->insert( $download = $this->downloadRepository->insert(
$this->getUser(),
$input->url, $input->url,
$input->title, $input->title,
$input->filename, $input->filename,

View File

@@ -18,9 +18,10 @@ final class IndexController extends AbstractController
#[Route('/', name: 'app_index')] #[Route('/', name: 'app_index')]
public function index(): Response public function index(): Response
{ {
// dd($this->getUser()->getActiveDownloads());
return $this->render('index/index.html.twig', [ return $this->render('index/index.html.twig', [
'active_downloads' => $this->downloadRepository->getActivePaginated(), 'active_downloads' => $this->getUser()->getActiveDownloads(),
'recent_downloads' => $this->downloadRepository->latest(5), 'recent_downloads' => $this->getUser()->getDownloads(),
'popular_movies' => $this->tmdb->popularMovies(1, 6), 'popular_movies' => $this->tmdb->popularMovies(1, 6),
'popular_tvshows' => $this->tmdb->popularTvShows(1, 6), 'popular_tvshows' => $this->tmdb->popularTvShows(1, 6),
]); ]);

View File

@@ -34,24 +34,18 @@ final class SearchController extends AbstractController
#[Route('/result/{mediaType}/{tmdbId}', name: 'app_search_result')] #[Route('/result/{mediaType}/{tmdbId}', name: 'app_search_result')]
public function result( public function result(
GetMediaInfoInput $input, GetMediaInfoInput $input,
CacheInterface $cache
): Response { ): Response {
$cacheId = sprintf("page.%s.%s", $input->mediaType, $input->tmdbId); $result = $this->getMediaInfoHandler->handle($input->toCommand());
return $this->render('search/result.html.twig', [
// return $cache->get($cacheId, function (ItemInterface $item) use ($input) { 'results' => $result,
// $item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0)); 'filter' => [
$result = $this->getMediaInfoHandler->handle($input->toCommand()); 'resolution' => '',
return $this->render('search/result.html.twig', [ 'codec' => '',
'results' => $result, 'provider' => '',
'filter' => [ 'language' => '',
'resolution' => '', 'season' => 1,
'codec' => '', 'episode' => ''
'provider' => '', ]
'language' => '', ]);
'season' => 1,
'episode' => ''
]
]);
// });
} }
} }

View File

@@ -15,6 +15,7 @@ class DownloadMediaCommand implements CommandInterface
public string $filename, public string $filename,
public string $mediaType, public string $mediaType,
public string $imdbId, public string $imdbId,
public int $userId,
public ?int $downloadId = null, public ?int $downloadId = null,
) {} ) {}
} }

View File

@@ -6,6 +6,7 @@ use App\Download\Action\Command\DownloadMediaCommand;
use App\Download\Action\Result\DownloadMediaResult; use App\Download\Action\Result\DownloadMediaResult;
use App\Download\Framework\Repository\DownloadRepository; use App\Download\Framework\Repository\DownloadRepository;
use App\Download\Downloader\DownloaderInterface; use App\Download\Downloader\DownloaderInterface;
use App\User\Framework\Repository\UserRepository;
use OneToMany\RichBundle\Contract\CommandInterface; use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface; use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface; use OneToMany\RichBundle\Contract\ResultInterface;
@@ -17,12 +18,14 @@ readonly class DownloadMediaHandler implements HandlerInterface
public function __construct( public function __construct(
private DownloaderInterface $downloader, private DownloaderInterface $downloader,
private DownloadRepository $downloadRepository, private DownloadRepository $downloadRepository,
private UserRepository $userRepository,
) {} ) {}
public function handle(CommandInterface $command): ResultInterface public function handle(CommandInterface $command): ResultInterface
{ {
if (null === $command->downloadId) { if (null === $command->downloadId) {
$download = $this->downloadRepository->insert( $download = $this->downloadRepository->insert(
$this->userRepository->find($command->userId),
$command->url, $command->url,
$command->title, $command->title,
$command->filename, $command->filename,
@@ -34,7 +37,6 @@ readonly class DownloadMediaHandler implements HandlerInterface
$download = $this->downloadRepository->find($command->downloadId); $download = $this->downloadRepository->find($command->downloadId);
} }
try { try {
$this->downloadRepository->updateStatus($download->getId(), 'In Progress'); $this->downloadRepository->updateStatus($download->getId(), 'In Progress');

View File

@@ -26,6 +26,8 @@ class DownloadMediaInput implements InputInterface
#[SourceRequest('imdbId')] #[SourceRequest('imdbId')]
public string $imdbId, public string $imdbId,
public ?int $userId = null,
public ?int $downloadId = null, public ?int $downloadId = null,
) {} ) {}
@@ -38,6 +40,7 @@ class DownloadMediaInput implements InputInterface
$this->mediaType, $this->mediaType,
$this->imdbId, $this->imdbId,
$this->downloadId, $this->downloadId,
$this->userId
); );
} }
} }

View File

@@ -3,6 +3,7 @@
namespace App\Download\Framework\Entity; namespace App\Download\Framework\Entity;
use App\Download\Framework\Repository\DownloadRepository; use App\Download\Framework\Repository\DownloadRepository;
use App\User\Framework\Entity\User;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\UX\Turbo\Attribute\Broadcast; use Symfony\UX\Turbo\Attribute\Broadcast;
@@ -39,6 +40,9 @@ class Download
#[ORM\Column(length: 255, nullable: true)] #[ORM\Column(length: 255, nullable: true)]
private ?string $batchId = null; private ?string $batchId = null;
#[ORM\ManyToOne(inversedBy: 'downloads')]
private ?User $user = null;
public function getId(): ?int public function getId(): ?int
{ {
return $this->id; return $this->id;
@@ -146,4 +150,16 @@ class Download
return $this; return $this;
} }
public function getUser(): ?User
{
return $this->user;
}
public function setUser(?User $user): static
{
$this->user = $user;
return $this;
}
} }

View File

@@ -3,11 +3,12 @@
namespace App\Download\Framework\Repository; namespace App\Download\Framework\Repository;
use App\Download\Framework\Entity\Download; use App\Download\Framework\Entity\Download;
use App\ValueObject\DownloadRequest; use App\User\Framework\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ManagerRegistry;
use Knp\Component\Pager\Paginator; use Knp\Component\Pager\Paginator;
use Knp\Component\Pager\PaginatorInterface; use Knp\Component\Pager\PaginatorInterface;
use Symfony\Component\Security\Core\User\UserInterface;
/** /**
* @extends ServiceEntityRepository<Download> * @extends ServiceEntityRepository<Download>
@@ -51,6 +52,7 @@ class DownloadRepository extends ServiceEntityRepository
} }
public function insert( public function insert(
UserInterface $user,
string $url, string $url,
string $title, string $title,
string $filename, string $filename,
@@ -59,7 +61,9 @@ class DownloadRepository extends ServiceEntityRepository
string $batchId, string $batchId,
string $status = 'New' string $status = 'New'
): Download { ): Download {
/** @var User $user */
$download = (new Download()) $download = (new Download())
->setUser($user)
->setUrl($url) ->setUrl($url)
->setTitle($title) ->setTitle($title)
->setFilename($filename) ->setFilename($filename)
@@ -75,22 +79,6 @@ class DownloadRepository extends ServiceEntityRepository
return $download; return $download;
} }
public function insertFromDownloadRequest(DownloadRequest $request): Download
{
$download = (new Download())
->setUrl($request->downloadUrl)
->setTitle($request->seriesName)
->setFilename($request->filename)
->setImdbId($request->imdbCode)
->setMediaType($request->mediaType)
->setStatus('New');
$this->getEntityManager()->persist($download);
$this->getEntityManager()->flush();
return $download;
}
public function updateStatus(int $id, string $status): Download public function updateStatus(int $id, string $status): Download
{ {
$download = $this->find($id); $download = $this->find($id);
@@ -106,18 +94,6 @@ class DownloadRepository extends ServiceEntityRepository
$this->getEntityManager()->flush(); $this->getEntityManager()->flush();
} }
public function getPendingByBatchId(string $batchId): ?array
{
$query = $this->createQueryBuilder('d')
->andWhere('d.status IN (:statuses)')
->andWhere('d.batchId = :batchId')
->setParameter('statuses', ['New', 'In Progress'])
->setParameter('batchId', $batchId)
->getQuery();
return $query->getResult();
}
public function latest(int $limit = 1) public function latest(int $limit = 1)
{ {
return $this->createQueryBuilder('d') return $this->createQueryBuilder('d')

View File

@@ -7,7 +7,7 @@ use OneToMany\RichBundle\Contract\CommandInterface;
class AddMonitorCommand implements CommandInterface class AddMonitorCommand implements CommandInterface
{ {
public function __construct( public function __construct(
public string $userEmail, public string $userId,
public string $title, public string $title,
public string $imdbId, public string $imdbId,
public string $tmdbId, public string $tmdbId,

View File

@@ -22,7 +22,7 @@ readonly class AddMonitorHandler implements HandlerInterface
public function handle(CommandInterface $command): ResultInterface public function handle(CommandInterface $command): ResultInterface
{ {
$user = $this->userRepository->findOneBy(['email' => $command->userEmail]); $user = $this->userRepository->find($command->userId);
$monitor = (new Monitor()) $monitor = (new Monitor())
->setUser($user) ->setUser($user)
->setTmdbId($command->tmdbId) ->setTmdbId($command->tmdbId)

View File

@@ -2,9 +2,10 @@
namespace App\Monitor\Action\Handler; namespace App\Monitor\Action\Handler;
use App\Monitor\Action\Command\DownloadMediaCommand; use App\Download\Action\Command\DownloadMediaCommand;
use App\Monitor\Action\Command\MonitorMovieCommand; use App\Monitor\Action\Command\MonitorMovieCommand;
use App\Monitor\Action\Result\MonitorMovieResult; use App\Monitor\Action\Result\MonitorMovieResult;
use App\Monitor\Framework\Entity\Monitor;
use App\Monitor\Framework\Repository\MonitorRepository; use App\Monitor\Framework\Repository\MonitorRepository;
use App\Monitor\Service\MonitorOptionEvaluator; use App\Monitor\Service\MonitorOptionEvaluator;
use App\Torrentio\Action\Command\GetMovieOptionsCommand; use App\Torrentio\Action\Command\GetMovieOptionsCommand;
@@ -15,6 +16,7 @@ use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface; use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface; use OneToMany\RichBundle\Contract\ResultInterface;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Messenger\MessageBusInterface;
/** @implements HandlerInterface<MonitorMovieCommand> */ /** @implements HandlerInterface<MonitorMovieCommand> */
@@ -27,11 +29,13 @@ readonly class MonitorMovieHandler implements HandlerInterface
private EntityManagerInterface $entityManager, private EntityManagerInterface $entityManager,
private MessageBusInterface $bus, private MessageBusInterface $bus,
private LoggerInterface $logger, private LoggerInterface $logger,
private Security $security,
) {} ) {}
public function handle(CommandInterface $command): ResultInterface public function handle(CommandInterface $command): ResultInterface
{ {
$this->logger->info('> [MonitorMovieHandler] Executing MonitorMovieHandler'); $this->logger->info('> [MonitorMovieHandler] Executing MonitorMovieHandler');
/** @var Monitor $monitor */
$monitor = $this->movieMonitorRepository->find($command->movieMonitorId); $monitor = $this->movieMonitorRepository->find($command->movieMonitorId);
$monitor->setStatus('In Progress'); $monitor->setStatus('In Progress');
@@ -51,6 +55,7 @@ readonly class MonitorMovieHandler implements HandlerInterface
$result->filename, $result->filename,
'movies', 'movies',
$monitor->getImdbId(), $monitor->getImdbId(),
$monitor->getUser()->getId(),
)); ));
$monitor->setStatus('Complete'); $monitor->setStatus('Complete');
$monitor->setDownloadedAt(new DateTimeIMmutable()); $monitor->setDownloadedAt(new DateTimeIMmutable());
@@ -59,7 +64,7 @@ readonly class MonitorMovieHandler implements HandlerInterface
} }
$monitor->setLastSearch(new DateTimeImmutable()); $monitor->setLastSearch(new DateTimeImmutable());
$monitor->setSearchCount($monitor->getSearchCount() + 1); $monitor->incrementSearchCount();
$this->entityManager->flush(); $this->entityManager->flush();
return new MonitorMovieResult( return new MonitorMovieResult(

View File

@@ -58,6 +58,7 @@ readonly class MonitorTvEpisodeHandler implements HandlerInterface
$result->filename, $result->filename,
'tvshows', 'tvshows',
$monitor->getImdbId(), $monitor->getImdbId(),
$monitor->getUser()->getId(),
)); ));
$monitor->setStatus('Complete'); $monitor->setStatus('Complete');
$monitor->setDownloadedAt(new DateTimeImmutable()); $monitor->setDownloadedAt(new DateTimeImmutable());

View File

@@ -12,7 +12,7 @@ class AddMonitorInput implements InputInterface
{ {
public function __construct( public function __construct(
#[SourceSecurity] #[SourceSecurity]
public string $userEmail, public int|string $userId,
#[SourceRequest('tmdbId')] #[SourceRequest('tmdbId')]
public string $tmdbId, public string $tmdbId,
@@ -36,7 +36,7 @@ class AddMonitorInput implements InputInterface
public function toCommand(): CommandInterface public function toCommand(): CommandInterface
{ {
return new AddMonitorCommand( return new AddMonitorCommand(
$this->userEmail, $this->userId,
$this->title, $this->title,
$this->imdbId, $this->imdbId,
$this->tmdbId, $this->tmdbId,

View File

@@ -5,6 +5,7 @@ namespace App\Monitor\Framework\Controller;
use App\Monitor\Action\Handler\AddMonitorHandler; use App\Monitor\Action\Handler\AddMonitorHandler;
use App\Monitor\Action\Input\AddMonitorInput; use App\Monitor\Action\Input\AddMonitorInput;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Mercure\HubInterface; use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update; use Symfony\Component\Mercure\Update;
@@ -17,6 +18,7 @@ class ApiController extends AbstractController
#[Autowire(service: 'twig')] #[Autowire(service: 'twig')]
private readonly Environment $renderer, private readonly Environment $renderer,
private readonly HubInterface $hub, private readonly HubInterface $hub,
private readonly Security $security,
) {} ) {}
#[Route('/api/monitor', name: 'api_monitor', methods: ['POST'])] #[Route('/api/monitor', name: 'api_monitor', methods: ['POST'])]
@@ -25,7 +27,9 @@ class ApiController extends AbstractController
AddMonitorHandler $handler, AddMonitorHandler $handler,
HubInterface $hub, HubInterface $hub,
) { ) {
$response = $handler->handle($input->toCommand()); $command = $input->toCommand();
$command->userId = $this->security->getUser()->getId();
$response = $handler->handle($command);
$hub->publish(new Update( $hub->publish(new Update(
'alerts', 'alerts',

View File

@@ -11,7 +11,7 @@ use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Scheduler\Attribute\AsCronTask; use Symfony\Component\Scheduler\Attribute\AsCronTask;
#[AsCronTask('*/10 * * * *', schedule: 'monitor')] #[AsCronTask('* * * * *', schedule: 'monitor')]
class MonitorDispatcher class MonitorDispatcher
{ {
public function __construct( public function __construct(
@@ -27,7 +27,7 @@ class MonitorDispatcher
'movie' => MonitorMovieCommand::class, 'movie' => MonitorMovieCommand::class,
'tvepisode' => MonitorTvEpisodeCommand::class, 'tvepisode' => MonitorTvEpisodeCommand::class,
'tvseason' => MonitorTvSeasonCommand::class, 'tvseason' => MonitorTvSeasonCommand::class,
'tvshow' => MonitorTvShowCommand::class, 'tvshows' => MonitorTvShowCommand::class,
]; ];
$monitors = $this->monitorRepository->findBy(['status' => ['New', 'Active']]); $monitors = $this->monitorRepository->findBy(['status' => ['New', 'Active']]);

View File

@@ -3,12 +3,13 @@
namespace App\Twig\Components; namespace App\Twig\Components;
use App\Download\Framework\Repository\DownloadRepository; use App\Download\Framework\Repository\DownloadRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction; use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\DefaultActionTrait; use Symfony\UX\LiveComponent\DefaultActionTrait;
#[AsLiveComponent] #[AsLiveComponent]
final class ActiveDownloadList final class ActiveDownloadList extends AbstractController
{ {
use DefaultActionTrait; use DefaultActionTrait;
@@ -19,6 +20,6 @@ final class ActiveDownloadList
#[LiveAction] #[LiveAction]
public function getDownloads() public function getDownloads()
{ {
return $this->downloadRepository->getActivePaginated(); return $this->getUser()->getActiveDownloads();
} }
} }

View File

@@ -3,6 +3,7 @@
namespace App\User\Framework\Entity; namespace App\User\Framework\Entity;
use Aimeos\Map; use Aimeos\Map;
use App\Download\Framework\Entity\Download;
use App\Monitor\Framework\Entity\Monitor; use App\Monitor\Framework\Entity\Monitor;
use App\User\Framework\Repository\UserRepository; use App\User\Framework\Repository\UserRepository;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
@@ -22,6 +23,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\Column(type: 'integer')] #[ORM\Column(type: 'integer')]
private int $id; private int $id;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $username;
#[ORM\Column(type: 'string', length: 180, unique: true)] #[ORM\Column(type: 'string', length: 180, unique: true)]
private ?string $email; private ?string $email;
@@ -46,10 +50,17 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\OneToMany(targetEntity: Monitor::class, mappedBy: 'user', orphanRemoval: true)] #[ORM\OneToMany(targetEntity: Monitor::class, mappedBy: 'user', orphanRemoval: true)]
private Collection $yes; private Collection $yes;
/**
* @var Collection<int, Download>
*/
#[ORM\OneToMany(targetEntity: Download::class, mappedBy: 'user')]
private Collection $downloads;
public function __construct() public function __construct()
{ {
$this->userPreferences = new ArrayCollection(); $this->userPreferences = new ArrayCollection();
$this->yes = new ArrayCollection(); $this->yes = new ArrayCollection();
$this->downloads = new ArrayCollection();
} }
public function getId(): ?int public function getId(): ?int
@@ -88,7 +99,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
*/ */
public function getUserIdentifier(): string public function getUserIdentifier(): string
{ {
return (string) $this->email; return (string) $this->username ?? $this->email;
} }
/** /**
@@ -241,4 +252,54 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this; return $this;
} }
public function getUsername(): ?string
{
return $this->username;
}
public function setUsername(?string $username): static
{
$this->username = $username;
return $this;
}
/**
* @return Collection<int, Download>
*/
public function getDownloads(): Collection
{
return $this->downloads;
}
/**
* @return Collection<int, Download>
*/
public function getActiveDownloads(): Collection
{
return $this->downloads->filter(fn(Download $download) => in_array($download->getStatus(), ['New', 'In Progress']));
}
public function addDownload(Download $download): static
{
if (!$this->downloads->contains($download)) {
$this->downloads->add($download);
$download->setUser($this);
}
return $this;
}
public function removeDownload(Download $download): static
{
if ($this->downloads->removeElement($download)) {
// set the owning side to null (unless already changed)
if ($download->getUser() === $this) {
$download->setUser(null);
}
}
return $this;
}
} }

View File

@@ -0,0 +1,73 @@
<?php
namespace App\User\Framework\Security;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
/**
* @see https://symfony.com/doc/current/security/custom_authenticator.html
*/
class LdapAuthenticator extends AbstractAuthenticator
{
/**
* Called on every request to decide if this authenticator should be
* used for the request. Returning `false` will cause this authenticator
* to be skipped.
*/
public function supports(Request $request): ?bool
{
// return $request->headers->has('X-AUTH-TOKEN');
}
public function authenticate(Request $request): Passport
{
// $apiToken = $request->headers->get('X-AUTH-TOKEN');
// if (null === $apiToken) {
// The token header was empty, authentication fails with HTTP Status
// Code 401 "Unauthorized"
// throw new CustomUserMessageAuthenticationException('No API token provided');
// }
// implement your own logic to get the user identifier from `$apiToken`
// e.g. by looking up a user in the database using its API key
// $userIdentifier = /** ... */;
// return new SelfValidatingPassport(new UserBadge($userIdentifier));
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
// on success, let the request continue
return null;
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
$data = [
// you may want to customize or obfuscate the message first
'message' => strtr($exception->getMessageKey(), $exception->getMessageData()),
// or to translate this message
// $this->translator->trans($exception->getMessageKey(), $exception->getMessageData())
];
return new JsonResponse($data, Response::HTTP_UNAUTHORIZED);
}
// public function start(Request $request, ?AuthenticationException $authException = null): Response
// {
// /*
// * If you would like this class to control what happens when an anonymous user accesses a
// * protected page (e.g. redirect to /login), uncomment this method and make this class
// * implement Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface.
// *
// * For more details, see https://symfony.com/doc/current/security/experimental_authenticators.html#configuring-the-authentication-entry-point
// */
// }
}

View File

@@ -0,0 +1,203 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\User\Framework\Security;
use App\User\Framework\Entity\User;
use App\User\Framework\Repository\UserRepository;
use Symfony\Component\Ldap\Entry;
use Symfony\Component\Ldap\Exception\ExceptionInterface;
use Symfony\Component\Ldap\Exception\InvalidCredentialsException;
use Symfony\Component\Ldap\Exception\InvalidSearchCredentialsException;
use Symfony\Component\Ldap\LdapInterface;
use Symfony\Component\Security\Core\Exception\InvalidArgumentException;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Ldap\Security\LdapUser;
/**
* LdapUserProvider is a simple user provider on top of LDAP.
*
* @author Grégoire Pineau <lyrixx@lyrixx.info>
* @author Charles Sarrazin <charles@sarraz.in>
* @author Robin Chalas <robin.chalas@gmail.com>
*
* @template-implements UserProviderInterface<LdapUser>
*/
class LdapUserProvider implements UserProviderInterface, PasswordUpgraderInterface
{
private string $uidKey;
private string $defaultSearch;
private string $usernameAttribute;
private string $emailAttribute;
private string $displayNameAttribute;
public function __construct(
private UserRepository $userRepository,
private LdapInterface $ldap,
private string $baseDn,
private ?string $searchDn = null,
#[\SensitiveParameter] private ?string $searchPassword = null,
private array $defaultRoles = [],
?string $uidKey = null,
?string $filter = null,
private ?string $passwordAttribute = null,
private array $extraFields = [],
string $usernameAttribute = 'uid',
string $emailAttribute = 'mail',
string $displayNameAttribute = 'displayName',
) {
$uidKey ??= 'sAMAccountName';
$filter ??= '({uid_key}={user_identifier})';
$this->uidKey = $uidKey;
$this->defaultSearch = str_replace('{uid_key}', $uidKey, $filter);
$this->usernameAttribute = $usernameAttribute;
$this->emailAttribute = $emailAttribute;
$this->displayNameAttribute = $displayNameAttribute;
}
public function loadUserByIdentifier(string $identifier): UserInterface
{
try {
$this->ldap->bind($this->searchDn, $this->searchPassword);
} catch (InvalidCredentialsException) {
throw new InvalidSearchCredentialsException();
}
$identifier = $this->ldap->escape($identifier, '', LdapInterface::ESCAPE_FILTER);
$query = str_replace('{user_identifier}', $identifier, $this->defaultSearch);
$search = $this->ldap->query($this->baseDn, $query, ['filter' => 0 == \count($this->extraFields) ? '*' : $this->extraFields]);
$entries = $search->execute();
$count = \count($entries);
if (!$count) {
$e = new UserNotFoundException(\sprintf('User "%s" not found.', $identifier));
$e->setUserIdentifier($identifier);
throw $e;
}
if ($count > 1) {
$e = new UserNotFoundException('More than one user found.');
$e->setUserIdentifier($identifier);
throw $e;
}
$entry = $entries[0];
try {
$identifier = $this->getAttributeValue($entry, $this->uidKey);
} catch (InvalidArgumentException) {
// This is empty
}
return $this->loadUser($identifier, $entry);
}
public function refreshUser(UserInterface $user): UserInterface
{
if (!$user instanceof LdapUser) {
throw new UnsupportedUserException(\sprintf('Instances of "%s" are not supported.', get_debug_type($user)));
}
return new LdapUser($user->getEntry(), $user->getUserIdentifier(), $user->getPassword(), $user->getRoles(), $user->getExtraFields());
}
/**
* @final
*/
public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void
{
if (!$user instanceof LdapUser) {
throw new UnsupportedUserException(\sprintf('Instances of "%s" are not supported.', get_debug_type($user)));
}
if (null === $this->passwordAttribute) {
return;
}
try {
$user->getEntry()->setAttribute($this->passwordAttribute, [$newHashedPassword]);
$this->ldap->getEntryManager()->update($user->getEntry());
$user->setPassword($newHashedPassword);
} catch (ExceptionInterface) {
// ignore failed password upgrades
}
}
public function supportsClass(string $class): bool
{
return User::class === $class;
}
/**
* Loads a user from an LDAP entry.
*/
protected function loadUser(string $identifier, Entry $entry): UserInterface
{
$extraFields = [];
foreach ($this->extraFields as $field) {
$extraFields[$field] = $this->getAttributeValue($entry, $field);
}
$dbUser = $this->getDbUser($identifier);
if (null === $dbUser) {
$dbUser = new User();
$dbUser->setPassword("test");
}
$dbUser
->setName($entry->getAttribute($this->displayNameAttribute)[0] ?? null)
->setEmail($entry->getAttribute($this->emailAttribute)[0] ?? null)
->setUsername($entry->getAttribute($this->usernameAttribute)[0] ?? null);
$this->userRepository->getEntityManager()->persist($dbUser);
$this->userRepository->getEntityManager()->flush();
return $dbUser;
}
private function getDbUser(string $identifier): ?UserInterface
{
if (in_array($this->uidKey, ['mail', 'email'])) {
return $this->userRepository->findOneBy(['email' => $identifier]);
} else {
return $this->userRepository->findOneBy(['username' => $identifier]);
}
}
private function getAttributeValue(Entry $entry, string $attribute): mixed
{
if (!$entry->hasAttribute($attribute)) {
throw new InvalidArgumentException(\sprintf('Missing attribute "%s" for user "%s".', $attribute, $entry->getDn()));
}
$values = $entry->getAttribute($attribute);
if (!\in_array($attribute, [$this->uidKey, $this->passwordAttribute])) {
return $values;
}
if (1 !== \count($values)) {
throw new InvalidArgumentException(\sprintf('Attribute "%s" has multiple values.', $attribute));
}
return $values[0];
}
}

View File

@@ -12,15 +12,14 @@
{% block importmap %}{{ importmap('app') }}{% endblock %} {% block importmap %}{{ importmap('app') }}{% endblock %}
{% endblock %} {% endblock %}
</head> </head>
<body class="bg-neutral-700 flex flex-col"> <body class="bg-neutral-700 flex flex-col backdrop-filter backdrop-blur-sm bg-opacity-100">
<twig:Header />
<div class="grid grid-cols-6"> <div class="grid grid-cols-6">
<div class="col-span-1"> <div class="col-span-1 h-screen">
<twig:NavBar /> <twig:NavBar />
</div> </div>
<div class="col-span-5"> <div class="col-span-5 h-screen overflow-y-scroll">
<h2 class="p-4 mb-2 text-3xl font-bold text-gray-50">{% block h2 %}{% endblock %}</h2> <twig:Header />
<h2 class="px-2 mb-2 text-3xl font-bold text-gray-50">{% block h2 %}{% endblock %}</h2>
{% block body %}{% endblock %} {% block body %}{% endblock %}
</div> </div>
</div> </div>

View File

@@ -1,4 +1,9 @@
<li {{ attributes }} id="alert_{{ alert_id }}" class="alert p-4 text-green-800 border border-green-300 rounded-lg bg-green-50 dark:bg-gray-800 dark:text-green-400 dark:border-green-800" role="alert"> <li {{ attributes }} id="alert_{{ alert_id }}" class="
text-white bg-green-950 text-sm min-w-[250px]
hover:bg-green-900 border border-green-500 px-4 py-3
rounded-md z-40"
role="alert"
>
<div class="flex items-center"> <div class="flex items-center">
<svg class="shrink-0 w-4 h-4 me-2" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20"> <svg class="shrink-0 w-4 h-4 me-2" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5ZM9.5 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3ZM12 15H8a1 1 0 0 1 0-2h1v-3H8a1 1 0 0 1 0-2h2a1 1 0 0 1 1 1v4h1a1 1 0 0 1 0 2Z"/> <path d="M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5ZM9.5 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3ZM12 15H8a1 1 0 0 1 0-2h1v-3H8a1 1 0 0 1 0-2h2a1 1 0 0 1 1 1v4h1a1 1 0 0 1 0 2Z"/>

View File

@@ -1,5 +1,7 @@
<div{{ attributes }}> <div{{ attributes }}>
<div class="flex flex-col bg-white border border-gray-200 border-t-4 border-t-orange-500 shadow-2xs rounded-xl dark:bg-slate-600 dark:border-neutral-700 dark:border-t-orange-500 dark:shadow-neutral-700/70"> <div class="flex flex-col bg-white border border-gray-200 border-t-4 border-t-orange-500 shadow-2xs rounded-xl dark:bg-sky-950 dark:border-neutral-700 dark:border-t-orange-500 dark:shadow-neutral-700/70
dark:backdrop-filter dark:backdrop-blur-md dark:bg-opacity-40
">
<div class="p-4 md:p-5"> <div class="p-4 md:p-5">
<h3 class="mb-4 text-lg font-bold text-gray-800 dark:text-white"> <h3 class="mb-4 text-lg font-bold text-gray-800 dark:text-white">
{{ title }} {{ title }}

View File

@@ -1,7 +1,6 @@
<header {{ attributes }} class="bg-cyan-950"> <header {{ attributes }} class="bg-cyan-950 z-40">
<div class="px-4 sm:px-6 lg:px-8"> <div class="px-4 sm:px-6 lg:px-8">
<div class="h-16 flex flex-row items-center justify-between"> <div class="h-16 flex flex-row items-center justify-between">
<h1 class="text-3xl font-extrabold text-orange-500">Torsearch</h1>
<twig:SearchBar /> <twig:SearchBar />
<div class="md:flex md:items-center md:gap-12"> <div class="md:flex md:items-center md:gap-12">
<nav aria-label="Global" class="md:block"> <nav aria-label="Global" class="md:block">

View File

@@ -1,6 +1,7 @@
<nav {{ attributes }} {{ stimulus_controller('navbar') }} {{ stimulus_action('navbar', 'setActive')}} class="flex h-screen flex-col justify-between bg-cyan-950"> <nav {{ attributes }} {{ stimulus_controller('navbar') }} {{ stimulus_action('navbar', 'setActive')}} class="flex h-screen flex-col justify-between bg-cyan-950">
<div class="px-4 py-6"> <div class="px-4 py-4 flex flex-col gap-12">
<ul class="mt-6 space-y-1"> <h1 class="text-3xl font-extrabold text-orange-500 mb-3">Torsearch</h1>
<ul class="space-y-1">
<li> <li>
<a href="{{ path('app_index') }}" <a href="{{ path('app_index') }}"
class="block rounded-lg class="block rounded-lg

View File

@@ -3,7 +3,7 @@
{% block title %}Dashboard &mdash; Torsearch{% endblock %} {% block title %}Dashboard &mdash; Torsearch{% endblock %}
{% block body %} {% block body %}
<div class="p-4 flex flex-col grow gap-4"> <div class="p-4 flex flex-col grow gap-4 z-30">
<h2 class="mb-2 text-3xl font-bold text-gray-50">Dashboard</h2> <h2 class="mb-2 text-3xl font-bold text-gray-50">Dashboard</h2>
<div class="flex flex-row gap-4"> <div class="flex flex-row gap-4">
<twig:Card title="Active Downloads" class="w-full"> <twig:Card title="Active Downloads" class="w-full">

View File

@@ -17,8 +17,8 @@
{% endif %} {% endif %}
<label for="username" class="mb-2 flex flex-col"> <label for="username" class="mb-2 flex flex-col">
Email User
<input type="email" <input type=""
value="{{ last_username }}" value="{{ last_username }}"
name="_username" name="_username"
id="username" id="username"