commit
c0f500e79b
|
@ -13,5 +13,6 @@ APP_SECRET=xxx
|
|||
# Format described at http://docs.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url
|
||||
# For an SQLite database, use: "sqlite:///%kernel.project_dir%/var/data.db"
|
||||
# Configure your db driver and server_version in config/packages/doctrine.yaml
|
||||
DATABASE_URL=sqlite:////home/magnetico/.local/share/magneticod/database.sqlite3
|
||||
APP_DATABASE_URL=postgres://$PGUSER:$PGPASSWORD@127.0.0.1:5436/test?application_name=magnetico_web
|
||||
MAGNETICOD_DATABASE_URL=sqlite:///%kernel.project_dir%/tests/database/database.sqlite3
|
||||
###< doctrine/doctrine-bundle ###
|
||||
|
|
|
@ -18,13 +18,16 @@
|
|||
"sensio/framework-extra-bundle": "^5.1",
|
||||
"symfony/console": "^4.1",
|
||||
"symfony/flex": "^1.0",
|
||||
"symfony/form": "^4.1",
|
||||
"symfony/framework-bundle": "^4.1",
|
||||
"symfony/lts": "^4@dev",
|
||||
"symfony/monolog-bundle": "^3.3",
|
||||
"symfony/orm-pack": "^1.0",
|
||||
"symfony/security-bundle": "^4.1",
|
||||
"symfony/serializer-pack": "^1.0",
|
||||
"symfony/translation": "^4.1",
|
||||
"symfony/twig-bundle": "^4.1",
|
||||
"symfony/validator": "^4.1",
|
||||
"symfony/web-server-bundle": "^4.1",
|
||||
"symfony/yaml": "^4.1",
|
||||
"white-october/pagerfanta-bundle": "^1.2"
|
||||
|
|
754
composer.lock
generated
754
composer.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -11,4 +11,5 @@ return [
|
|||
WhiteOctober\PagerfantaBundle\WhiteOctoberPagerfantaBundle::class => ['all' => true],
|
||||
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true],
|
||||
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
|
||||
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
|
||||
];
|
||||
|
|
|
@ -3,15 +3,26 @@ parameters:
|
|||
# This allows you to run cache:warmup even if your
|
||||
# environment variables are not available yet.
|
||||
# You should not need to change this value.
|
||||
env(DATABASE_URL): ''
|
||||
env(APP_DATABASE_URL): ''
|
||||
env(MAGNETICOD_DATABASE_URL): ''
|
||||
|
||||
doctrine:
|
||||
dbal:
|
||||
# configure these for your database server
|
||||
default_connection: default
|
||||
connections:
|
||||
default:
|
||||
driver: 'pdo_pgsql'
|
||||
url: '%env(resolve:APP_DATABASE_URL)%'
|
||||
#server_version: 9.6
|
||||
magneticod:
|
||||
driver: 'pdo_sqlite'
|
||||
url: '%env(resolve:DATABASE_URL)%'
|
||||
url: '%env(resolve:MAGNETICOD_DATABASE_URL)%'
|
||||
orm:
|
||||
auto_generate_proxy_classes: '%kernel.debug%'
|
||||
default_entity_manager: default
|
||||
entity_managers:
|
||||
default:
|
||||
connection: default
|
||||
naming_strategy: doctrine.orm.naming_strategy.underscore
|
||||
auto_mapping: true
|
||||
mappings:
|
||||
|
@ -21,3 +32,13 @@ doctrine:
|
|||
dir: '%kernel.project_dir%/src/Entity'
|
||||
prefix: 'App\Entity'
|
||||
alias: App
|
||||
magneticod:
|
||||
connection: magneticod
|
||||
mappings:
|
||||
Magnetico:
|
||||
is_bundle: false
|
||||
type: annotation
|
||||
dir: '%kernel.project_dir%/src/Magnetico/Entity'
|
||||
prefix: 'App\Magnetico'
|
||||
alias: Magnetico
|
||||
|
||||
|
|
52
config/packages/security.yaml
Normal file
52
config/packages/security.yaml
Normal file
|
@ -0,0 +1,52 @@
|
|||
security:
|
||||
# https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers
|
||||
providers:
|
||||
default_provider:
|
||||
entity:
|
||||
class: App\Entity\User
|
||||
property: username
|
||||
manager_name: default
|
||||
api_token_provider:
|
||||
id: App\Security\ApiTokenUserProvider
|
||||
encoders:
|
||||
App\Entity\User:
|
||||
algorithm: 'argon2i'
|
||||
memory_cost: 16384
|
||||
time_cost: 2
|
||||
threads: 4
|
||||
firewalls:
|
||||
dev:
|
||||
pattern: ^/(_(profiler|wdt)|css|images|js)/
|
||||
security: false
|
||||
api:
|
||||
pattern: ^/api/
|
||||
anonymous: ~
|
||||
stateless: true
|
||||
simple_preauth:
|
||||
authenticator: App\Security\ApiTokenAuthenticator
|
||||
provider: api_token_provider
|
||||
main:
|
||||
pattern: ^/
|
||||
anonymous: ~
|
||||
provider: default_provider
|
||||
form_login:
|
||||
login_path: user_login
|
||||
check_path: user_login
|
||||
logout:
|
||||
path: user_logout
|
||||
target: /
|
||||
remember_me:
|
||||
secret: '%kernel.secret%'
|
||||
lifetime: 604800
|
||||
path: /
|
||||
always_remember_me: 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: ^/api/v1/login$, roles: IS_AUTHENTICATED_ANONYMOUSLY }
|
||||
- { path: ^/api/, roles: ROLE_USER }
|
||||
- { path: ^/$, roles: IS_AUTHENTICATED_ANONYMOUSLY }
|
||||
- { path: /login, roles: IS_AUTHENTICATED_ANONYMOUSLY }
|
||||
- { path: ^/, roles: ROLE_USER }
|
|
@ -2,3 +2,4 @@ twig:
|
|||
paths: ['%kernel.project_dir%/templates']
|
||||
debug: '%kernel.debug%'
|
||||
strict_variables: '%kernel.debug%'
|
||||
form_themes: ['bootstrap_4_layout.html.twig']
|
||||
|
|
|
@ -4,7 +4,7 @@ index:
|
|||
controller: App\Controller\MainController::index
|
||||
|
||||
torrents_search:
|
||||
path: /torrents/search
|
||||
path: /torrents
|
||||
controller: App\Controller\TorrentController::searchTorrent
|
||||
requirements:
|
||||
method: GET
|
||||
|
@ -16,7 +16,39 @@ torrents_show:
|
|||
method: GET
|
||||
id: '\d+'
|
||||
|
||||
user_register:
|
||||
path: /register/{inviteCode}
|
||||
controller: App\Controller\UserController::register
|
||||
requirements:
|
||||
method: GET
|
||||
inviteCode: \w{32}
|
||||
|
||||
user_login:
|
||||
path: /login
|
||||
controller: App\Controller\SecurityController::login
|
||||
|
||||
user_logout:
|
||||
path: /logout
|
||||
|
||||
# API
|
||||
api_v1_login:
|
||||
path: /api/v1/login
|
||||
controller: App\Api\V1\Controller\SecurityController::login
|
||||
defaults:
|
||||
_format: json
|
||||
requirements:
|
||||
method: POST
|
||||
_format: json
|
||||
|
||||
api_v1_logout:
|
||||
path: /api/v1/logout
|
||||
controller: App\Api\V1\Controller\SecurityController::logout
|
||||
defaults:
|
||||
_format: json
|
||||
requirements:
|
||||
method: GET
|
||||
_format: json
|
||||
|
||||
api_v1_torrents:
|
||||
path: /api/v1/torrents
|
||||
controller: App\Api\V1\Controller\TorrentController::search
|
||||
|
|
|
@ -17,12 +17,20 @@ services:
|
|||
|
||||
App\:
|
||||
resource: '../src/*'
|
||||
exclude: '../src/{Entity,Migrations,Tests,Kernel.php}'
|
||||
exclude: '../src/{Api/V1/{DTO},Magnetico/{Entity,Migrations},Entity,FormRequest,Migrations,Tests,Kernel.php}'
|
||||
# Use array in exclude config from Symfony 4.2
|
||||
#- '../src/Api/V1/{DTO}'
|
||||
#- '../src/Magnetico/{Entity,Migrations}'
|
||||
#- '../src/{Entity,FormRequest,Migrations,Tests,Kernel.php}'
|
||||
|
||||
App\Controller\:
|
||||
resource: '../src/Controller'
|
||||
tags: ['controller.service_arguments']
|
||||
|
||||
App\Api\V1\Controller\:
|
||||
resource: '../src/Api/V1/Controller'
|
||||
tags: ['controller.service_arguments']
|
||||
|
||||
# Fast normalizer for Symfony Serializer
|
||||
get_set_method_normalizer:
|
||||
class: Symfony\Component\Serializer\Normalizer\GetSetMethodNormalizer
|
||||
|
|
19
src/Api/V1/Controller/AbstractApiController.php
Normal file
19
src/Api/V1/Controller/AbstractApiController.php
Normal file
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
namespace App\Api\V1\Controller;
|
||||
|
||||
use App\Api\V1\DTO\ApiResponse;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
|
||||
use Symfony\Component\HttpFoundation\{JsonResponse, Response};
|
||||
|
||||
abstract class AbstractApiController extends Controller
|
||||
{
|
||||
protected const DEFAULT_SERIALIZER_GROUPS = ['api'];
|
||||
|
||||
protected function createJsonResponse($data, array $groups = [], int $code = Response::HTTP_OK, string $message = null, string $status = ''): JsonResponse
|
||||
{
|
||||
return $this->json(new ApiResponse($data, $code, $message, $status), $code, [], [
|
||||
'groups' => array_merge(self::DEFAULT_SERIALIZER_GROUPS,$groups),
|
||||
]);
|
||||
}
|
||||
}
|
71
src/Api/V1/Controller/SecurityController.php
Normal file
71
src/Api/V1/Controller/SecurityController.php
Normal file
|
@ -0,0 +1,71 @@
|
|||
<?php
|
||||
|
||||
namespace App\Api\V1\Controller;
|
||||
|
||||
use App\Entity\{ApiToken, User};
|
||||
use App\Repository\{ApiTokenRepository, UserRepository};
|
||||
use App\Security\Token\AuthenticatedApiToken;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpFoundation\{JsonResponse, Request};
|
||||
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
|
||||
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
|
||||
|
||||
class SecurityController extends AbstractApiController
|
||||
{
|
||||
public function login(
|
||||
Request $request,
|
||||
EntityManagerInterface $em,
|
||||
UserRepository $userRepo,
|
||||
ApiTokenRepository $tokenRepo,
|
||||
UserPasswordEncoderInterface $passwordEncoder
|
||||
): JsonResponse {
|
||||
$username = $request->request->get('username');
|
||||
$password = $request->request->get('password');
|
||||
|
||||
/** @var User $user */
|
||||
if (null === $user = $userRepo->findOneBy(['username' => $username])) {
|
||||
return $this->createJsonResponse(null, [], JsonResponse::HTTP_UNAUTHORIZED, 'User not found');
|
||||
}
|
||||
|
||||
if (!$passwordEncoder->isPasswordValid($user, $password)) {
|
||||
return $this->createJsonResponse(null, [], JsonResponse::HTTP_UNAUTHORIZED, 'Invalid password');
|
||||
}
|
||||
|
||||
$apiToken = new ApiToken($user);
|
||||
$tokenRepo->add($apiToken);
|
||||
|
||||
try {
|
||||
$em->flush();
|
||||
} catch (\Exception $ex) {
|
||||
return $this->createJsonResponse(null, [], JsonResponse::HTTP_INTERNAL_SERVER_ERROR, 'Token persisting error');
|
||||
}
|
||||
|
||||
return $this->createJsonResponse($apiToken->getKey());
|
||||
}
|
||||
|
||||
public function logout(TokenStorageInterface $tokenStorage, ApiTokenRepository $apiTokenRepo, EntityManagerInterface $em): JsonResponse
|
||||
{
|
||||
$token = $tokenStorage->getToken();
|
||||
|
||||
if (!$token instanceof AuthenticatedApiToken) {
|
||||
return $this->createJsonResponse(null,[],JsonResponse::HTTP_INTERNAL_SERVER_ERROR, 'Invalid session token type retrieved.');
|
||||
}
|
||||
if (null === $apiTokenKey = $token->getTokenKey()) {
|
||||
return $this->createJsonResponse(null,[],JsonResponse::HTTP_INTERNAL_SERVER_ERROR, 'Can\'t retrieve token key from session.');
|
||||
}
|
||||
|
||||
if (null === $apiToken = $apiTokenRepo->findOneBy(['key' => $apiTokenKey])) {
|
||||
return $this->createJsonResponse(null,[],JsonResponse::HTTP_INTERNAL_SERVER_ERROR, 'API token with such key not found in the database.');
|
||||
}
|
||||
|
||||
$em->remove($apiToken);
|
||||
|
||||
try {
|
||||
$em->flush();
|
||||
} catch (\Exception $ex) {
|
||||
return $this->createJsonResponse(null,[],JsonResponse::HTTP_INTERNAL_SERVER_ERROR, 'API token deauthentication failure.');
|
||||
}
|
||||
|
||||
return $this->createJsonResponse(null,[],JsonResponse::HTTP_OK, 'Successfully logged out.');
|
||||
}
|
||||
}
|
|
@ -2,22 +2,18 @@
|
|||
|
||||
namespace App\Api\V1\Controller;
|
||||
|
||||
use App\Api\V1\DTO\ApiResponse;
|
||||
use App\Api\V1\DTO\ListPage;
|
||||
use App\Entity\Torrent;
|
||||
use App\Repository\TorrentRepository;
|
||||
use App\Magnetico\Entity\Torrent;
|
||||
use App\Magnetico\Repository\TorrentRepository;
|
||||
use Pagerfanta\Adapter\DoctrineORMAdapter;
|
||||
use Pagerfanta\Pagerfanta;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
|
||||
use Symfony\Component\HttpFoundation\{Request, Response};
|
||||
use Symfony\Component\HttpFoundation\{JsonResponse, Request};
|
||||
|
||||
class TorrentController extends Controller
|
||||
class TorrentController extends AbstractApiController
|
||||
{
|
||||
private const DEFAULT_SERIALIZER_GROUPS = ['api_v1'];
|
||||
|
||||
private const PER_PAGE = 20;
|
||||
|
||||
public function search(Request $request, TorrentRepository $repo): Response
|
||||
public function search(Request $request, TorrentRepository $repo): JsonResponse
|
||||
{
|
||||
$query = $request->query->get('query', '');
|
||||
$page = (int) $request->query->get('page', '1');
|
||||
|
@ -29,16 +25,12 @@ class TorrentController extends Controller
|
|||
->setMaxPerPage(self::PER_PAGE)
|
||||
;
|
||||
|
||||
return $this->json(new ApiResponse(ListPage::createFromPager($pager)),Response::HTTP_OK, [], [
|
||||
'groups' => array_merge(self::DEFAULT_SERIALIZER_GROUPS,['api_v1_search']),
|
||||
]);
|
||||
return $this->createJsonResponse(ListPage::createFromPager($pager), ['api_v1_search']);
|
||||
}
|
||||
|
||||
public function show(Torrent $torrent): Response
|
||||
public function show(Torrent $torrent): JsonResponse
|
||||
{
|
||||
return $this->json(new ApiResponse($torrent), Response::HTTP_OK, [], [
|
||||
'groups' => array_merge(self::DEFAULT_SERIALIZER_GROUPS,['api_v1_show']),
|
||||
]);
|
||||
return $this->createJsonResponse($torrent, ['api_v1_show'], JsonResponse::HTTP_OK,null, '');
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -15,28 +15,28 @@ class ApiResponse
|
|||
/**
|
||||
* @var int HTTP response status code
|
||||
*
|
||||
* @Groups({"api_v1"})
|
||||
* @Groups({"api"})
|
||||
*/
|
||||
private $code;
|
||||
|
||||
/**
|
||||
* @var string Status text: 'success' (1xx-3xx), 'error' (4xx), 'fail' (5xx) or 'unknown'
|
||||
*
|
||||
* @Groups({"api_v1"})
|
||||
* @Groups({"api"})
|
||||
*/
|
||||
private $status;
|
||||
|
||||
/**
|
||||
* @var string|null Used for 'fail' and 'error'
|
||||
*
|
||||
* @Groups({"api_v1"})
|
||||
* @Groups({"api"})
|
||||
*/
|
||||
private $message;
|
||||
|
||||
/**
|
||||
* @var string|\object|array|null Response body. In case of 'error' or 'fail' contains cause or exception name.
|
||||
*
|
||||
* @Groups({"api_v1"})
|
||||
* @Groups({"api"})
|
||||
*/
|
||||
private $data;
|
||||
|
||||
|
|
|
@ -10,35 +10,35 @@ class ListPage
|
|||
/**
|
||||
* @var int
|
||||
*
|
||||
* @Serializer\Groups({"api_v1"})
|
||||
* @Serializer\Groups({"api"})
|
||||
*/
|
||||
private $numberOfPages;
|
||||
|
||||
/**
|
||||
* @var int
|
||||
*
|
||||
* @Serializer\Groups({"api_v1"})
|
||||
* @Serializer\Groups({"api"})
|
||||
*/
|
||||
private $currentPage;
|
||||
|
||||
/**
|
||||
* @var int
|
||||
*
|
||||
* @Serializer\Groups({"api_v1"})
|
||||
* @Serializer\Groups({"api"})
|
||||
*/
|
||||
private $numberOfResults;
|
||||
|
||||
/**
|
||||
* @var int
|
||||
*
|
||||
* @Serializer\Groups({"api_v1"})
|
||||
* @Serializer\Groups({"api"})
|
||||
*/
|
||||
private $maxPerPage;
|
||||
|
||||
/**
|
||||
* @var \Traversable
|
||||
*
|
||||
* @Serializer\Groups({"api_v1"})
|
||||
* @Serializer\Groups({"api"})
|
||||
*/
|
||||
protected $items;
|
||||
|
||||
|
|
98
src/Command/AddUserCommand.php
Normal file
98
src/Command/AddUserCommand.php
Normal file
|
@ -0,0 +1,98 @@
|
|||
<?php
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Entity\Invite;
|
||||
use App\Repository\{InviteRepository, UserRepository};
|
||||
use App\User\UserManager;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\QuestionHelper;
|
||||
use Symfony\Component\Console\Input\{InputArgument, InputInterface, InputOption};
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Question\Question;
|
||||
|
||||
class AddUserCommand extends Command
|
||||
{
|
||||
/** @var EntityManagerInterface */
|
||||
private $em;
|
||||
|
||||
/** @var UserManager */
|
||||
private $userManager;
|
||||
|
||||
/** @var UserRepository */
|
||||
private $userRepo;
|
||||
|
||||
/** @var InviteRepository */
|
||||
private $inviteRepo;
|
||||
|
||||
public function __construct(EntityManagerInterface $em, UserManager $userManager, UserRepository $userRepo, InviteRepository $inviterepo)
|
||||
{
|
||||
parent::__construct();
|
||||
|
||||
$this->em = $em;
|
||||
$this->userManager = $userManager;
|
||||
$this->userRepo = $userRepo;
|
||||
$this->inviteRepo = $inviterepo;
|
||||
}
|
||||
|
||||
protected function configure()
|
||||
{
|
||||
$this
|
||||
->setName('user:add')
|
||||
->addArgument('username', InputArgument::REQUIRED, 'Username')
|
||||
->addArgument('email', InputArgument::REQUIRED, 'Email')
|
||||
->addArgument('password', InputArgument::OPTIONAL, 'Password', null)
|
||||
->addOption('invites', 'i', InputOption::VALUE_OPTIONAL, 'Number of invites for user', 0)
|
||||
->addOption('role', 'r', InputOption::VALUE_IS_ARRAY | InputOption::VALUE_OPTIONAL, 'Role to add to the user')
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output)
|
||||
{
|
||||
$username = $input->getArgument('username');
|
||||
$email = $input->getArgument('email');
|
||||
$password = $input->getArgument('password');
|
||||
$invites = (int) $input->getOption('invites');
|
||||
$roles = (array) $input->getOption('role');
|
||||
|
||||
if (!$password) {
|
||||
/** @var QuestionHelper $questionHelper */
|
||||
$questionHelper = $this->getHelper('question');
|
||||
$question = new Question('Enter new user\'s password: ');
|
||||
$question->setHidden(true);
|
||||
$question->setHiddenFallback(false);
|
||||
|
||||
$password = $questionHelper->ask($input, $output, $question);
|
||||
}
|
||||
|
||||
if (!$password) {
|
||||
$output->writeln('User password cannot be empty.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($roles) {
|
||||
$user = $this->userManager->createUser($username, $password, $email, $roles);
|
||||
} else {
|
||||
$user = $this->userManager->createUser($username, $password, $email);
|
||||
}
|
||||
|
||||
|
||||
$this->userRepo->add($user);
|
||||
|
||||
if ($invites) {
|
||||
for ($i = 0; $i < $invites; $i++) {
|
||||
$invite = new Invite($user);
|
||||
$this->inviteRepo->add($invite);
|
||||
}
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
$output->writeln(sprintf('User \'%s\' registered, %d invites added.', $user->getUsername(), $invites));
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
}
|
|
@ -2,16 +2,29 @@
|
|||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Repository\TorrentRepository;
|
||||
use App\Form\LoginType;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class MainController extends Controller
|
||||
{
|
||||
public function index(TorrentRepository $repo): Response
|
||||
public function index(): Response
|
||||
{
|
||||
return $this->render('index.html.twig', [
|
||||
'torrentsCount' => $repo->getTorrentsTotalCount(),
|
||||
'loginForm' => $this->createLoginForm('')->createView(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function createLoginForm(string $username): FormInterface
|
||||
{
|
||||
$form = $this->createForm(LoginType::class, null, [
|
||||
'action' => $this->generateUrl('user_login'),
|
||||
]);
|
||||
$form->get('_username')->setData($username);
|
||||
$form->add('submit', SubmitType::class);
|
||||
|
||||
return $form;
|
||||
}
|
||||
}
|
40
src/Controller/SecurityController.php
Normal file
40
src/Controller/SecurityController.php
Normal file
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Form\LoginType;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use Symfony\Component\Form\{FormError, FormInterface};
|
||||
use Symfony\Component\HttpFoundation\{Request, Response};
|
||||
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
|
||||
use Symfony\Component\Translation\TranslatorInterface;
|
||||
|
||||
class SecurityController extends Controller
|
||||
{
|
||||
public function login(Request $request, AuthenticationUtils $authenticationUtils, TranslatorInterface $translator): Response
|
||||
{
|
||||
$lastError = $authenticationUtils->getLastAuthenticationError() ? $authenticationUtils->getLastAuthenticationError()->getMessage() : '';
|
||||
$lastUsername = $authenticationUtils->getLastUsername();
|
||||
|
||||
$form = $this->createLoginForm($lastUsername);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($lastError) {
|
||||
$form->addError(new FormError($lastError));
|
||||
}
|
||||
|
||||
return $this->render('Security/login.html.twig', ['form' => $form->createView()]);
|
||||
}
|
||||
|
||||
private function createLoginForm(string $username): FormInterface
|
||||
{
|
||||
$form = $this->createForm(LoginType::class, null, [
|
||||
'action' => $this->generateUrl('user_login'),
|
||||
]);
|
||||
$form->get('_username')->setData($username);
|
||||
$form->add('submit', SubmitType::class);
|
||||
|
||||
return $form;
|
||||
}
|
||||
}
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\Torrent;
|
||||
use App\Repository\TorrentRepository;
|
||||
use App\Magnetico\Entity\Torrent;
|
||||
use App\Magnetico\Repository\TorrentRepository;
|
||||
use Pagerfanta\Adapter\DoctrineORMAdapter;
|
||||
use Pagerfanta\Pagerfanta;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
|
||||
|
|
65
src/Controller/UserController.php
Normal file
65
src/Controller/UserController.php
Normal file
|
@ -0,0 +1,65 @@
|
|||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Form\{CreateUserRequestType};
|
||||
use App\FormRequest\CreateUserRequest;
|
||||
use App\Repository\{UserRepository};
|
||||
use App\User\Exception\InvalidInviteException;
|
||||
use App\User\UserManager;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use Symfony\Component\Form\{FormError, FormInterface};
|
||||
use Symfony\Component\HttpFoundation\{Request, Response};
|
||||
|
||||
class UserController extends Controller
|
||||
{
|
||||
public function register(
|
||||
string $inviteCode,
|
||||
Request $request,
|
||||
EntityManagerInterface $em,
|
||||
UserManager $userManager,
|
||||
UserRepository $userRepository
|
||||
): Response {
|
||||
$createUserRequest = new CreateUserRequest();
|
||||
$createUserRequest->inviteCode = $inviteCode;
|
||||
$form = $this->createRegisterForm($createUserRequest, $inviteCode);
|
||||
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
try {
|
||||
$user = $userManager->createUserByInviteCode(
|
||||
$createUserRequest->username,
|
||||
$createUserRequest->password,
|
||||
$createUserRequest->email,
|
||||
$createUserRequest->inviteCode
|
||||
);
|
||||
} catch (InvalidInviteException $ex) {
|
||||
// @FIXME refactor InvalidInviteException to proper validator
|
||||
$form->get('inviteCode')->addError(new FormError('Invalid invite code'));
|
||||
|
||||
return $this->render('User/register.html.twig', ['form' => $form->createView()]);
|
||||
}
|
||||
|
||||
$userRepository->add($user);
|
||||
$em->flush();
|
||||
|
||||
return $this->redirectToRoute('index');
|
||||
}
|
||||
|
||||
return $this->render('User/register.html.twig', ['form' => $form->createView()]);
|
||||
}
|
||||
|
||||
private function createRegisterForm(CreateUserRequest $createUserRequest, string $inviteCode): FormInterface
|
||||
{
|
||||
$form = $this->createForm(CreateUserRequestType::class, $createUserRequest, [
|
||||
'action' => $this->generateUrl('user_register', ['inviteCode' => $inviteCode]),
|
||||
]);
|
||||
|
||||
$form->add('submit', SubmitType::class);
|
||||
|
||||
return $form;
|
||||
}
|
||||
}
|
60
src/Entity/ApiToken.php
Normal file
60
src/Entity/ApiToken.php
Normal file
|
@ -0,0 +1,60 @@
|
|||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Annotation as Serializer;
|
||||
|
||||
/**
|
||||
* @ORM\Table(name="api_tokens", schema="users")
|
||||
* @ORM\Entity(repositoryClass="App\Repository\ApiTokenRepository", readOnly=true)
|
||||
*/
|
||||
class ApiToken
|
||||
{
|
||||
/**
|
||||
* @var User
|
||||
*
|
||||
* @ORM\ManyToOne(targetEntity="App\Entity\User")
|
||||
* @ORM\JoinColumn(name="user_id", nullable=false)
|
||||
*/
|
||||
private $user;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*
|
||||
* @Serializer\Groups({"api", "api_v1_login"})
|
||||
*
|
||||
* @ORM\Id()
|
||||
* @ORM\Column(name="key", type="string", length=32)
|
||||
*/
|
||||
private $key;
|
||||
|
||||
/**
|
||||
* @var \DateTime
|
||||
*
|
||||
* @ORM\Column(name="created_at", type="datetime")
|
||||
*/
|
||||
private $createdAt;
|
||||
|
||||
public function __construct(User $user)
|
||||
{
|
||||
$this->user = $user;
|
||||
$this->key = md5(random_bytes(100));
|
||||
$this->createdAt = new \DateTime();
|
||||
}
|
||||
|
||||
public function getUser(): User
|
||||
{
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
public function getKey(): string
|
||||
{
|
||||
return $this->key;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): \DateTime
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
}
|
84
src/Entity/Invite.php
Normal file
84
src/Entity/Invite.php
Normal file
|
@ -0,0 +1,84 @@
|
|||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
/**
|
||||
* @ORM\Table(name="invites", schema="users")
|
||||
* @ORM\Entity(repositoryClass="App\Repository\InviteRepository")
|
||||
*/
|
||||
class Invite
|
||||
{
|
||||
/**
|
||||
* @var int
|
||||
*
|
||||
* @ORM\Id()
|
||||
* @ORM\GeneratedValue(strategy="AUTO")
|
||||
* @ORM\Column(name="id", type="integer")
|
||||
*/
|
||||
private $id;
|
||||
|
||||
/**
|
||||
* @var User
|
||||
*
|
||||
* @ORM\ManyToOne(targetEntity="App\Entity\User")
|
||||
* @ORM\JoinColumn(name="user_id", nullable=false)
|
||||
*/
|
||||
private $user;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*
|
||||
* @ORM\Column(name="code", type="string", length=32, unique=true)
|
||||
*/
|
||||
private $code;
|
||||
|
||||
/**
|
||||
* @var User|null
|
||||
*
|
||||
* @ORM\ManyToOne(targetEntity="App\Entity\User")
|
||||
* @ORM\JoinColumn(name="used_by_id", nullable=true)
|
||||
*/
|
||||
private $usedBy;
|
||||
|
||||
public function __construct(User $forUser)
|
||||
{
|
||||
$this->user = $forUser;
|
||||
$this->code = md5(random_bytes(100));
|
||||
}
|
||||
|
||||
public function getId(): int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getUser(): User
|
||||
{
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
public function getCode(): string
|
||||
{
|
||||
return $this->code;
|
||||
}
|
||||
|
||||
public function getUsedBy(): ?User
|
||||
{
|
||||
return $this->usedBy;
|
||||
}
|
||||
|
||||
public function use(User $user): void
|
||||
{
|
||||
if ($this->usedBy) {
|
||||
throw new \RuntimeException(sprintf(
|
||||
'Invite #%d is already used by User#%d and can\'t be used by User#%d',
|
||||
$this->id,
|
||||
$this->usedBy->getId(),
|
||||
$user->getId()
|
||||
));
|
||||
}
|
||||
|
||||
$this->usedBy = $user;
|
||||
}
|
||||
}
|
152
src/Entity/User.php
Normal file
152
src/Entity/User.php
Normal file
|
@ -0,0 +1,152 @@
|
|||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
/**
|
||||
* @ORM\Table(name="users", schema="users")
|
||||
* @ORM\Entity(repositoryClass="App\Repository\UserRepository")
|
||||
*/
|
||||
class User implements UserInterface, \Serializable
|
||||
{
|
||||
/**
|
||||
* @var int
|
||||
*
|
||||
* @ORM\Id
|
||||
* @ORM\Column(name="id", type="integer")
|
||||
* @ORM\GeneratedValue(strategy="AUTO")
|
||||
*/
|
||||
private $id;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*
|
||||
* @ORM\Column(name="username", type="string", length=25, unique=true)
|
||||
*/
|
||||
private $username;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*
|
||||
* @ORM\Column(name="password", type="text")
|
||||
*/
|
||||
private $password;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*
|
||||
* @ORM\Column(name="email", type="string", length=254, unique=true)
|
||||
*/
|
||||
private $email;
|
||||
|
||||
/**
|
||||
* @var string[]
|
||||
*
|
||||
* @ORM\Column(name="roles", type="json")
|
||||
*/
|
||||
private $roles = [];
|
||||
|
||||
/**
|
||||
* @var \DateTime
|
||||
*
|
||||
* @ORM\Column(name="created_at", type="datetime")
|
||||
*/
|
||||
private $createdAt;
|
||||
|
||||
/**
|
||||
* @var Invite[]|ArrayCollection
|
||||
*
|
||||
* @ORM\OneToMany(targetEntity="App\Entity\Invite", mappedBy="user", fetch="EXTRA_LAZY")
|
||||
*/
|
||||
private $invites;
|
||||
|
||||
public function __construct(string $username, string $password, string $email, array $roles = [])
|
||||
{
|
||||
$this->username = $username;
|
||||
$this->password = $password;
|
||||
$this->email = $email;
|
||||
$this->roles = $roles ?: ['ROLE_USER'];
|
||||
$this->createdAt = new \DateTime();
|
||||
}
|
||||
|
||||
public function getId(): int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getUsername()
|
||||
{
|
||||
return $this->username;
|
||||
}
|
||||
|
||||
public function getPassword()
|
||||
{
|
||||
return $this->password;
|
||||
}
|
||||
|
||||
public function updatePassword(string $password): void
|
||||
{
|
||||
$this->password = $password;
|
||||
}
|
||||
|
||||
public function getSalt()
|
||||
{
|
||||
// Salt is not needed when using Argon2i
|
||||
// @see https://symfony.com/doc/current/reference/configuration/security.html#using-the-argon2i-password-encoder
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getEmail(): string
|
||||
{
|
||||
return $this->email;
|
||||
}
|
||||
|
||||
public function getRoles(): array
|
||||
{
|
||||
return $this->roles;
|
||||
}
|
||||
|
||||
public function addRole(string $role): void
|
||||
{
|
||||
$this->roles[] = $role;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): \DateTime
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function eraseCredentials()
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
/** @return Invite[]|ArrayCollection */
|
||||
public function getInvites(): \Traversable
|
||||
{
|
||||
return $this->invites;
|
||||
}
|
||||
|
||||
/** @see \Serializable::serialize() */
|
||||
public function serialize()
|
||||
{
|
||||
return serialize([
|
||||
$this->id,
|
||||
$this->username,
|
||||
$this->password,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @see \Serializable::unserialize() */
|
||||
public function unserialize($serialized)
|
||||
{
|
||||
list(
|
||||
$this->id,
|
||||
$this->username,
|
||||
$this->password
|
||||
) = unserialize($serialized, ['allowed_classes' => false]);
|
||||
}
|
||||
}
|
30
src/Form/CreateUserRequestType.php
Normal file
30
src/Form/CreateUserRequestType.php
Normal file
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\FormRequest\CreateUserRequest;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\{EmailType, PasswordType, TextType};
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class CreateUserRequestType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options)
|
||||
{
|
||||
$builder
|
||||
->add('username', TextType::class)
|
||||
->add('password', PasswordType::class)
|
||||
->add('email', EmailType::class)
|
||||
->add('inviteCode', TextType::class)
|
||||
;
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver)
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => CreateUserRequest::class,
|
||||
]);
|
||||
}
|
||||
|
||||
}
|
26
src/Form/LoginType.php
Normal file
26
src/Form/LoginType.php
Normal file
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\{PasswordType, TextType};
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
|
||||
class LoginType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options)
|
||||
{
|
||||
$builder
|
||||
->add('_username', TextType::class, ['mapped' => false])
|
||||
->add('_password', PasswordType::class, ['mapped' => false])
|
||||
;
|
||||
}
|
||||
|
||||
public function getBlockPrefix()
|
||||
{
|
||||
// Empty prefix for default UsernamePasswordFrormAuthenticationListener
|
||||
return '';
|
||||
}
|
||||
|
||||
|
||||
}
|
42
src/FormRequest/CreateUserRequest.php
Normal file
42
src/FormRequest/CreateUserRequest.php
Normal file
|
@ -0,0 +1,42 @@
|
|||
<?php
|
||||
|
||||
namespace App\FormRequest;
|
||||
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* @todo implement UniqueEntity constraint for DTO and use it here
|
||||
*/
|
||||
class CreateUserRequest
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*
|
||||
* @Assert\NotBlank()
|
||||
* @Assert\Length(min="2", max="25")
|
||||
*/
|
||||
public $username;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*
|
||||
* @Assert\NotBlank()
|
||||
* @Assert\Length(min="8", max="4096")
|
||||
*/
|
||||
public $password;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*
|
||||
* @Assert\Email()
|
||||
*/
|
||||
public $email;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*
|
||||
* @Assert\NotBlank()
|
||||
* @Assert\Length(min="32", max="32")
|
||||
*/
|
||||
public $inviteCode;
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
namespace App\Magnetico\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Annotation as Serializer;
|
||||
|
@ -24,7 +24,7 @@ class File
|
|||
/**
|
||||
* @var Torrent
|
||||
*
|
||||
* @ORM\ManyToOne(targetEntity="App\Entity\Torrent", inversedBy="files")
|
||||
* @ORM\ManyToOne(targetEntity="App\Magnetico\Entity\Torrent", inversedBy="files")
|
||||
* @ORM\JoinColumn(name="torrent_id")
|
||||
*/
|
||||
private $torrent;
|
|
@ -1,6 +1,6 @@
|
|||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
namespace App\Magnetico\Entity;
|
||||
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
@ -11,7 +11,7 @@ use Symfony\Component\Serializer\Annotation as Serializer;
|
|||
* @ORM\Index(name="discovered_on_index", columns={"discovered_on"}),
|
||||
* @ORM\Index(name="info_hash_index", columns={"info_hash"})
|
||||
* })
|
||||
* @ORM\Entity(readOnly=true, repositoryClass="App\Repository\TorrentRepository")
|
||||
* @ORM\Entity(readOnly=true, repositoryClass="App\Magnetico\Repository\TorrentRepository")
|
||||
*/
|
||||
class Torrent
|
||||
{
|
||||
|
@ -71,7 +71,7 @@ class Torrent
|
|||
*
|
||||
* @Serializer\Groups({"api_v1_show"})
|
||||
*
|
||||
* @ORM\OneToMany(targetEntity="App\Entity\File", fetch="EXTRA_LAZY", mappedBy="torrent")
|
||||
* @ORM\OneToMany(targetEntity="App\Magnetico\Entity\File", fetch="EXTRA_LAZY", mappedBy="torrent")
|
||||
*/
|
||||
private $files;
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<?php
|
||||
|
||||
namespace App\Repository;
|
||||
namespace App\Magnetico\Repository;
|
||||
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
|
@ -10,7 +10,7 @@ class TorrentRepository extends ServiceEntityRepository
|
|||
{
|
||||
public function __construct(RegistryInterface $registry)
|
||||
{
|
||||
parent::__construct($registry, \App\Entity\Torrent::class);
|
||||
parent::__construct($registry, \App\Magnetico\Entity\Torrent::class);
|
||||
}
|
||||
|
||||
public function getTorrentsTotalCount(): int
|
||||
|
@ -19,7 +19,11 @@ class TorrentRepository extends ServiceEntityRepository
|
|||
->select('COUNT(t.id)')
|
||||
;
|
||||
|
||||
try {
|
||||
return (int) $qb->getQuery()->getSingleScalarResult();
|
||||
} catch (\Exception $ex) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public function createFindLikeQueryBuilder(string $query): QueryBuilder
|
50
src/Migrations/Version20180626223216.php
Normal file
50
src/Migrations/Version20180626223216.php
Normal file
|
@ -0,0 +1,50 @@
|
|||
<?php declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Creates users schema with basic set of tables.
|
||||
*/
|
||||
final class Version20180626223216 extends AbstractMigration
|
||||
{
|
||||
public function up(Schema $schema) : void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'postgresql', 'Migration can only be executed safely on \'postgresql\'.');
|
||||
|
||||
$this->addSql('CREATE SCHEMA users');
|
||||
$this->addSql('CREATE SEQUENCE users.users_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
|
||||
$this->addSql('CREATE SEQUENCE users.invites_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
|
||||
$this->addSql('CREATE TABLE users.users (id INT NOT NULL, username VARCHAR(25) NOT NULL, password TEXT NOT NULL, email VARCHAR(254) NOT NULL, roles JSON NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))');
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_338ADFC4F85E0677 ON users.users (username)');
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_338ADFC4E7927C74 ON users.users (email)');
|
||||
$this->addSql('CREATE TABLE users.invites (id INT NOT NULL, user_id INT NOT NULL, used_by_id INT DEFAULT NULL, code VARCHAR(32) NOT NULL, PRIMARY KEY(id))');
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_93848FA877153098 ON users.invites (code)');
|
||||
$this->addSql('CREATE INDEX IDX_93848FA8A76ED395 ON users.invites (user_id)');
|
||||
$this->addSql('CREATE INDEX IDX_93848FA84C2B72A8 ON users.invites (used_by_id)');
|
||||
$this->addSql('CREATE TABLE users.api_tokens (key VARCHAR(32) NOT NULL, user_id INT NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(key))');
|
||||
$this->addSql('CREATE INDEX IDX_C46A74A9A76ED395 ON users.api_tokens (user_id)');
|
||||
$this->addSql('ALTER TABLE users.invites ADD CONSTRAINT FK_93848FA8A76ED395 FOREIGN KEY (user_id) REFERENCES users.users (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||
$this->addSql('ALTER TABLE users.invites ADD CONSTRAINT FK_93848FA84C2B72A8 FOREIGN KEY (used_by_id) REFERENCES users.users (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||
$this->addSql('ALTER TABLE users.api_tokens ADD CONSTRAINT FK_C46A74A9A76ED395 FOREIGN KEY (user_id) REFERENCES users.users (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||
}
|
||||
|
||||
public function down(Schema $schema) : void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'postgresql', 'Migration can only be executed safely on \'postgresql\'.');
|
||||
|
||||
$this->addSql('ALTER TABLE users.invites DROP CONSTRAINT FK_93848FA8A76ED395');
|
||||
$this->addSql('ALTER TABLE users.invites DROP CONSTRAINT FK_93848FA84C2B72A8');
|
||||
$this->addSql('ALTER TABLE users.api_tokens DROP CONSTRAINT FK_C46A74A9A76ED395');
|
||||
$this->addSql('DROP SEQUENCE users.users_id_seq CASCADE');
|
||||
$this->addSql('DROP SEQUENCE users.invites_id_seq CASCADE');
|
||||
$this->addSql('DROP TABLE users.users');
|
||||
$this->addSql('DROP TABLE users.invites');
|
||||
$this->addSql('DROP TABLE users.api_tokens');
|
||||
$this->addSql('DROP SCHEMA users');
|
||||
}
|
||||
}
|
35
src/Repository/ApiTokenRepository.php
Normal file
35
src/Repository/ApiTokenRepository.php
Normal file
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\{ApiToken, User};
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\ORM\Query\Expr\Join;
|
||||
use Symfony\Bridge\Doctrine\RegistryInterface;
|
||||
|
||||
class ApiTokenRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(RegistryInterface $registry)
|
||||
{
|
||||
parent::__construct($registry, ApiToken::class);
|
||||
}
|
||||
|
||||
public function add(ApiToken $token): void
|
||||
{
|
||||
$this->getEntityManager()->persist($token);
|
||||
}
|
||||
|
||||
public function findUserByTokenKey(string $tokenKey): ?User
|
||||
{
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb
|
||||
->select('u')
|
||||
->from(User::class, 'u')
|
||||
->innerJoin(ApiToken::class, 'at', Join::WITH, 'at.user = u')
|
||||
->where('at.key = :tokenKey')
|
||||
->setParameter('tokenKey', $tokenKey)
|
||||
;
|
||||
|
||||
return $qb->getQuery()->getOneOrNullResult();
|
||||
}
|
||||
}
|
20
src/Repository/InviteRepository.php
Normal file
20
src/Repository/InviteRepository.php
Normal file
|
@ -0,0 +1,20 @@
|
|||
<?php
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\Invite;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Symfony\Bridge\Doctrine\RegistryInterface;
|
||||
|
||||
class InviteRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(RegistryInterface $registry)
|
||||
{
|
||||
parent::__construct($registry, Invite::class);
|
||||
}
|
||||
|
||||
public function add(Invite $invite): void
|
||||
{
|
||||
$this->getEntityManager()->persist($invite);
|
||||
}
|
||||
}
|
20
src/Repository/UserRepository.php
Normal file
20
src/Repository/UserRepository.php
Normal file
|
@ -0,0 +1,20 @@
|
|||
<?php
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\User;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Symfony\Bridge\Doctrine\RegistryInterface;
|
||||
|
||||
class UserRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(RegistryInterface $registry)
|
||||
{
|
||||
parent::__construct($registry, User::class);
|
||||
}
|
||||
|
||||
public function add(User $user): void
|
||||
{
|
||||
$this->getEntityManager()->persist($user);
|
||||
}
|
||||
}
|
88
src/Security/ApiTokenAuthenticator.php
Normal file
88
src/Security/ApiTokenAuthenticator.php
Normal file
|
@ -0,0 +1,88 @@
|
|||
<?php
|
||||
|
||||
namespace App\Security;
|
||||
|
||||
use App\Api\V1\DTO\ApiResponse;
|
||||
use App\Security\Token\AuthenticatedApiToken;
|
||||
use Symfony\Component\HttpFoundation\{JsonResponse, Request};
|
||||
use Symfony\Component\Security\Core\Authentication\Token\{PreAuthenticatedToken, TokenInterface};
|
||||
use Symfony\Component\Security\Core\Exception\{AuthenticationException, BadCredentialsException, CustomUserMessageAuthenticationException};
|
||||
use Symfony\Component\Security\Core\User\UserProviderInterface;
|
||||
use Symfony\Component\Security\Http\Authentication\{AuthenticationFailureHandlerInterface, SimplePreAuthenticatorInterface};
|
||||
use Symfony\Component\Serializer\SerializerInterface;
|
||||
|
||||
class ApiTokenAuthenticator implements SimplePreAuthenticatorInterface, AuthenticationFailureHandlerInterface
|
||||
{
|
||||
public const TOKEN_HEADER = 'api-token';
|
||||
|
||||
/** @var SerializerInterface */
|
||||
private $serializer;
|
||||
|
||||
public function __construct(SerializerInterface $serializer)
|
||||
{
|
||||
$this->serializer = $serializer;
|
||||
}
|
||||
|
||||
/** Takes request data and creates token which will be ready to auth check */
|
||||
public function createToken(Request $request, $providerKey)
|
||||
{
|
||||
if (!($tokenKey = $request->headers->get(self::TOKEN_HEADER))) {
|
||||
// Throwing exception here will break anonymous authentication for login method
|
||||
//throw new BadCredentialsException(sprintf('\'%s\' is invalid or not defined', self::TOKEN_HEADER));
|
||||
return null;
|
||||
}
|
||||
|
||||
return new PreAuthenticatedToken(
|
||||
'anon.',
|
||||
$tokenKey,
|
||||
$providerKey
|
||||
);
|
||||
}
|
||||
|
||||
public function authenticateToken(TokenInterface $token, UserProviderInterface $userProvider, $providerKey)
|
||||
{
|
||||
if (!$userProvider instanceof ApiTokenUserProvider) {
|
||||
throw new \InvalidArgumentException(sprintf(
|
||||
'The user provider for providerKey = \'%s\' must be an instance of %s, %s given.',
|
||||
$providerKey,
|
||||
ApiTokenUserProvider::class,
|
||||
get_class($userProvider)
|
||||
));
|
||||
}
|
||||
|
||||
$apiTokenKey = $token->getCredentials();
|
||||
|
||||
$user = $userProvider->loadUserByUsername($apiTokenKey);
|
||||
|
||||
if (!$user) {
|
||||
throw new CustomUserMessageAuthenticationException(sprintf(
|
||||
'API token \'%s\' does not exist.', $apiTokenKey
|
||||
));
|
||||
}
|
||||
|
||||
return new AuthenticatedApiToken(
|
||||
$user,
|
||||
$apiTokenKey,
|
||||
$providerKey,
|
||||
$user->getRoles()
|
||||
);
|
||||
}
|
||||
|
||||
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): JsonResponse
|
||||
{
|
||||
// @todo Decouple with App\Api\V1\DTO
|
||||
$json = $this->serializer->serialize(
|
||||
new ApiResponse(null, JsonResponse::HTTP_UNAUTHORIZED, $exception->getMessage()),
|
||||
'json',
|
||||
['groups' => ['api_v1']]
|
||||
);
|
||||
|
||||
return new JsonResponse($json, JsonResponse::HTTP_UNAUTHORIZED,[], true);
|
||||
}
|
||||
|
||||
|
||||
public function supportsToken(TokenInterface $token, $providerKey)
|
||||
{
|
||||
return $token instanceof PreAuthenticatedToken && $token->getProviderKey() === $providerKey;
|
||||
}
|
||||
}
|
39
src/Security/ApiTokenUserProvider.php
Normal file
39
src/Security/ApiTokenUserProvider.php
Normal file
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
namespace App\Security;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Repository\ApiTokenRepository;
|
||||
use Symfony\Component\Security\Core\Exception\{UnsupportedUserException, UsernameNotFoundException};
|
||||
use Symfony\Component\Security\Core\User\{UserInterface, UserProviderInterface};
|
||||
|
||||
class ApiTokenUserProvider implements UserProviderInterface
|
||||
{
|
||||
/** @var ApiTokenRepository */
|
||||
private $userRepo;
|
||||
|
||||
public function __construct(ApiTokenRepository $userRepo)
|
||||
{
|
||||
$this->userRepo = $userRepo;
|
||||
}
|
||||
|
||||
public function loadUserByUsername($username): User
|
||||
{
|
||||
if (null === $user = $this->userRepo->findUserByTokenKey($username)) {
|
||||
throw new UsernameNotFoundException(sprintf('Token \'%s\' is not found.', $username));
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
public function refreshUser(UserInterface $user)
|
||||
{
|
||||
throw new UnsupportedUserException();
|
||||
}
|
||||
|
||||
public function supportsClass($class): bool
|
||||
{
|
||||
return User::class === $class;
|
||||
}
|
||||
|
||||
}
|
25
src/Security/Token/AuthenticatedApiToken.php
Normal file
25
src/Security/Token/AuthenticatedApiToken.php
Normal file
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace App\Security\Token;
|
||||
|
||||
use App\Entity\User;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\PreAuthenticatedToken;
|
||||
|
||||
/** This token stores ApiToken key even after eraseCredentials() called */
|
||||
class AuthenticatedApiToken extends PreAuthenticatedToken
|
||||
{
|
||||
/** @var string|null This token is stored only for this request and will not be erased by eraseCredentials() or serialized */
|
||||
private $tokenKey;
|
||||
|
||||
public function __construct(User $user, string $credentials, string $providerKey, array $roles = [])
|
||||
{
|
||||
parent::__construct($user, $credentials, $providerKey, $roles);
|
||||
// @todo probably separate constructor argument needed
|
||||
$this->tokenKey = $credentials;
|
||||
}
|
||||
|
||||
public function getTokenKey(): ?string
|
||||
{
|
||||
return $this->tokenKey;
|
||||
}
|
||||
}
|
8
src/User/Exception/InvalidInviteException.php
Normal file
8
src/User/Exception/InvalidInviteException.php
Normal file
|
@ -0,0 +1,8 @@
|
|||
<?php
|
||||
|
||||
namespace App\User\Exception;
|
||||
|
||||
class InvalidInviteException extends \Exception
|
||||
{
|
||||
protected $message = 'Invalid invite';
|
||||
}
|
57
src/User/UserManager.php
Normal file
57
src/User/UserManager.php
Normal file
|
@ -0,0 +1,57 @@
|
|||
<?php
|
||||
|
||||
namespace App\User;
|
||||
|
||||
use App\Entity\{Invite, User};
|
||||
use App\Repository\{InviteRepository, UserRepository};
|
||||
use App\User\Exception\InvalidInviteException;
|
||||
use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface;
|
||||
|
||||
class UserManager
|
||||
{
|
||||
private const DEFAULT_ROLES = ['ROLE_USER'];
|
||||
|
||||
/** @var UserRepository */
|
||||
private $userRepo;
|
||||
|
||||
/** @var InviteRepository */
|
||||
private $inviteRepo;
|
||||
|
||||
/** @var EncoderFactoryInterface */
|
||||
private $encoderFactory;
|
||||
|
||||
public function __construct(EncoderFactoryInterface $encoderFactory, UserRepository $userRepo, InviteRepository $inviteRepo)
|
||||
{
|
||||
$this->userRepo = $userRepo;
|
||||
$this->inviteRepo = $inviteRepo;
|
||||
$this->encoderFactory = $encoderFactory;
|
||||
}
|
||||
|
||||
public function createUser(string $username, string $password, string $email, array $roles = self::DEFAULT_ROLES): User
|
||||
{
|
||||
$encodedPassword = $this->encoderFactory->getEncoder(User::class)->encodePassword($password, null);
|
||||
|
||||
$user = new User(
|
||||
$username,
|
||||
$encodedPassword,
|
||||
$email,
|
||||
$roles
|
||||
);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
public function createUserByInviteCode(string $username, string $password, string $email, string $inviteCode, array $roles = self::DEFAULT_ROLES): User
|
||||
{
|
||||
/** @var Invite $invite */
|
||||
if (null === $invite = $this->inviteRepo->findOneBy(['code' => $inviteCode, 'usedBy' => null])) {
|
||||
throw new InvalidInviteException();
|
||||
}
|
||||
|
||||
$user = $this->createUser($username, $password, $email,$roles);
|
||||
|
||||
$invite->use($user);
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
27
symfony.lock
27
symfony.lock
|
@ -146,6 +146,9 @@
|
|||
"ref": "e921bdbfe20cdefa3b82f379d1cd36df1bc8d115"
|
||||
}
|
||||
},
|
||||
"symfony/form": {
|
||||
"version": "v4.1.0"
|
||||
},
|
||||
"symfony/framework-bundle": {
|
||||
"version": "3.3",
|
||||
"recipe": {
|
||||
|
@ -164,6 +167,9 @@
|
|||
"symfony/inflector": {
|
||||
"version": "v4.1.0"
|
||||
},
|
||||
"symfony/intl": {
|
||||
"version": "v4.1.0"
|
||||
},
|
||||
"symfony/lts": {
|
||||
"version": "4-dev"
|
||||
},
|
||||
|
@ -179,9 +185,15 @@
|
|||
"ref": "371d1a2b69984710646b09a1182ef1d4308c904f"
|
||||
}
|
||||
},
|
||||
"symfony/options-resolver": {
|
||||
"version": "v4.1.0"
|
||||
},
|
||||
"symfony/orm-pack": {
|
||||
"version": "v1.0.5"
|
||||
},
|
||||
"symfony/polyfill-intl-icu": {
|
||||
"version": "v1.8.0"
|
||||
},
|
||||
"symfony/polyfill-mbstring": {
|
||||
"version": "v1.8.0"
|
||||
},
|
||||
|
@ -206,6 +218,18 @@
|
|||
"ref": "cda8b550123383d25827705d05a42acf6819fe4e"
|
||||
}
|
||||
},
|
||||
"symfony/security": {
|
||||
"version": "v4.1.0"
|
||||
},
|
||||
"symfony/security-bundle": {
|
||||
"version": "3.3",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "master",
|
||||
"version": "3.3",
|
||||
"ref": "f8a63faa0d9521526499c0a8f403c9964ecb0527"
|
||||
}
|
||||
},
|
||||
"symfony/serializer": {
|
||||
"version": "v4.1.0"
|
||||
},
|
||||
|
@ -233,6 +257,9 @@
|
|||
"ref": "f75ac166398e107796ca94cc57fa1edaa06ec47f"
|
||||
}
|
||||
},
|
||||
"symfony/validator": {
|
||||
"version": "v4.1.0"
|
||||
},
|
||||
"symfony/var-dumper": {
|
||||
"version": "v4.1.0"
|
||||
},
|
||||
|
|
7
templates/Security/login.html.twig
Normal file
7
templates/Security/login.html.twig
Normal file
|
@ -0,0 +1,7 @@
|
|||
{% extends 'base.html.twig' %}
|
||||
|
||||
{% block content %}
|
||||
<div id="form-login">
|
||||
{{ form(form) }}
|
||||
</div>
|
||||
{% endblock %}
|
7
templates/User/register.html.twig
Normal file
7
templates/User/register.html.twig
Normal file
|
@ -0,0 +1,7 @@
|
|||
{% extends 'base.html.twig' %}
|
||||
|
||||
{% block content %}
|
||||
<div id="form-register">
|
||||
{{ form(form) }}
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -26,16 +26,25 @@
|
|||
</button>
|
||||
|
||||
<div class="collapse navbar-collapse" id="navbarsExampleDefault">
|
||||
{% if is_granted('IS_AUTHENTICATED_REMEMBERED') %}
|
||||
<ul class="navbar-nav mr-auto">
|
||||
<!--<li class="nav-item">
|
||||
<a class="nav-link" href="#">Item</a>
|
||||
</li>-->
|
||||
<li class="nav-item dropdown">
|
||||
<a href="#" class="nav-link dropdown-toggle" role="button" data-toggle="dropdown">
|
||||
{{ app.user.username }}
|
||||
</a>
|
||||
<div class="dropdown-menu">
|
||||
<a href="{{ path('user_logout') }}" class="dropdown-item">Logout</a>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% if is_granted('ROLE_USER') %}
|
||||
<form class="form-inline my-2 my-lg-0" action="{{ path('torrents_search') }}" method="get">
|
||||
<input name="query" class="form-control mr-sm-2" type="text" placeholder="Search" aria-label="Search"
|
||||
value="{% if searchQuery is defined %}{{ searchQuery }}{% endif %}">
|
||||
<button class="btn btn-outline-success my-2 my-sm-0" type="submit">Search</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
{% block content %}
|
||||
<div class="well">
|
||||
<p>Torrents indexed: {{ torrentsCount }}</p>
|
||||
{% if not is_granted('ROLE_USER') %}
|
||||
<a href="{{ path('user_login') }}" class="btn btn-lg btn-primary">Login</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -13,7 +13,7 @@
|
|||
<th scope="col">Discovered</th>
|
||||
</tr>
|
||||
</thead>
|
||||
{# @var torrent \App\Entity\Torrent #}
|
||||
{# @var torrent \App\Magnetico\Entity\Torrent #}
|
||||
{% for torrent in torrents %}
|
||||
<tr>
|
||||
<td><a href="{{ magnet(torrent.name, torrent.infoHash) }}">🔗</a></td>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{% extends 'base.html.twig' %}
|
||||
|
||||
{% block content %}
|
||||
{# @var torrent \App\Entity\Torrent #}
|
||||
{# @var torrent \App\Magnetico\Entity\Torrent #}
|
||||
<table class="table">
|
||||
<tr>
|
||||
<td>Name</td>
|
||||
|
@ -31,7 +31,7 @@
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{# @var file \App\Entity\File #}
|
||||
{# @var file \App\Magnetico\Entity\File #}
|
||||
{% for file in torrent.files | sort %}
|
||||
<tr>
|
||||
<td>{{ file.path }}</td>
|
||||
|
|
BIN
tests/database/database.sqlite3
Normal file
BIN
tests/database/database.sqlite3
Normal file
Binary file not shown.
Loading…
Reference in a new issue