Compare commits

..

2 Commits

Author SHA1 Message Date
skylord123 4faae84839 - Domain entity unique
- Domain controller re-written to be completely API driven
- API Auth is now stateless and required on all ^/api routes
- Deleted DomainApiController
- API Auth failure code is now 401
2024-02-13 23:01:26 -07:00
skylord123 7c45f64a73 - Added support for authenticating via access tokens
- update symfony to 6.4.*
- some other minor stuff
2024-02-12 23:39:11 -07:00
22 changed files with 5101 additions and 8261 deletions
+34 -32
View File
@@ -8,42 +8,44 @@
"ext-ctype": "*",
"ext-iconv": "*",
"ext-intl": "*",
"doctrine/annotations": "^2.0",
"doctrine/doctrine-bundle": "^2.10",
"doctrine/doctrine-migrations-bundle": "^3.2",
"doctrine/orm": "^2.16",
"gedmo/doctrine-extensions": "^3.13",
"phpdocumentor/reflection-docblock": "^5.3",
"phpstan/phpdoc-parser": "^1.24",
"symfony/asset": "6.3.*",
"symfony/console": "6.3.*",
"symfony/doctrine-messenger": "6.3.*",
"symfony/dotenv": "6.3.*",
"symfony/expression-language": "6.3.*",
"symfony/asset": "6.4.*",
"symfony/console": "6.4.*",
"symfony/doctrine-messenger": "6.4.*",
"symfony/dotenv": "6.4.*",
"symfony/expression-language": "6.4.*",
"symfony/flex": "^2",
"symfony/form": "6.3.*",
"symfony/framework-bundle": "6.3.*",
"symfony/http-client": "6.3.*",
"symfony/intl": "6.3.*",
"symfony/lock": "6.3.*",
"symfony/mailer": "6.3.*",
"symfony/mime": "6.3.*",
"symfony/form": "6.4.*",
"symfony/framework-bundle": "6.4.*",
"symfony/http-client": "6.4.*",
"symfony/intl": "6.4.*",
"symfony/lock": "6.4.*",
"symfony/mailer": "6.4.*",
"symfony/mime": "6.4.*",
"symfony/monolog-bundle": "^3.0",
"symfony/notifier": "6.3.*",
"symfony/process": "6.3.*",
"symfony/property-access": "6.3.*",
"symfony/property-info": "6.3.*",
"symfony/rate-limiter": "6.3.*",
"symfony/runtime": "6.3.*",
"symfony/security-bundle": "6.3.*",
"symfony/serializer": "6.3.*",
"symfony/notifier": "6.4.*",
"symfony/process": "6.4.*",
"symfony/property-access": "6.4.*",
"symfony/property-info": "6.4.*",
"symfony/rate-limiter": "6.4.*",
"symfony/runtime": "6.4.*",
"symfony/security-bundle": "6.4.*",
"symfony/serializer": "6.4.*",
"symfony/stimulus-bundle": "^2.11",
"symfony/string": "6.3.*",
"symfony/translation": "6.3.*",
"symfony/twig-bundle": "6.3.*",
"symfony/validator": "6.3.*",
"symfony/web-link": "6.3.*",
"symfony/string": "6.4.*",
"symfony/translation": "6.4.*",
"symfony/twig-bundle": "6.4.*",
"symfony/uid": "6.4.*",
"symfony/validator": "6.4.*",
"symfony/web-link": "6.4.*",
"symfony/webpack-encore-bundle": "^2.0",
"symfony/yaml": "6.3.*",
"symfony/yaml": "6.4.*",
"twig/extra-bundle": "^2.12|^3.0",
"twig/twig": "^2.12|^3.0"
},
@@ -92,17 +94,17 @@
"extra": {
"symfony": {
"allow-contrib": false,
"require": "6.3.*"
"require": "6.4.*"
}
},
"require-dev": {
"phpunit/phpunit": "^9.5",
"symfony/browser-kit": "6.3.*",
"symfony/css-selector": "6.3.*",
"symfony/debug-bundle": "6.3.*",
"symfony/browser-kit": "6.4.*",
"symfony/css-selector": "6.4.*",
"symfony/debug-bundle": "6.4.*",
"symfony/maker-bundle": "^1.0",
"symfony/phpunit-bridge": "^6.3",
"symfony/stopwatch": "6.3.*",
"symfony/web-profiler-bundle": "6.3.*"
"symfony/stopwatch": "6.4.*",
"symfony/web-profiler-bundle": "6.4.*"
}
}
Generated
+1294 -1125
View File
File diff suppressed because it is too large Load Diff
+12 -2
View File
@@ -20,6 +20,7 @@ services:
$globalFactory: '@limiter.ip_login'
# localFactory is the limiter for username+IP
$localFactory: '@limiter.username_ip_login'
$secret: '%env(APP_SECRET)%'
security:
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
@@ -36,6 +37,15 @@ security:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
api:
pattern: ^/api/
provider: app_user_provider
access_denied_handler: App\Security\AccessTokenDeniedHandler
stateless: true
access_token:
token_handler: App\Security\AccessTokenHandler
failure_handler: App\Security\AccessTokenDeniedHandler
main:
lazy: true
provider: app_user_provider
@@ -62,8 +72,8 @@ security:
# 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: ^/admin, roles: ROLE_ADMIN }
# - { path: ^/profile, roles: ROLE_USER }
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
- { path: ^/api/login, roles: PUBLIC_ACCESS }
when@test:
security:
+4
View File
@@ -0,0 +1,4 @@
framework:
uid:
default_uuid_version: 7
time_based_uuid_version: 7
+7 -1
View File
@@ -26,4 +26,10 @@ services:
tags:
- { name: doctrine.event_subscriber, connection: default }
calls:
- [ setAnnotationReader, [ '@annotation_reader' ] ]
- [ setAnnotationReader, [ '@annotation_reader' ] ]
Symfony\Component\Uid\Command\GenerateUlidCommand: ~
Symfony\Component\Uid\Command\GenerateUuidCommand: ~
Symfony\Component\Uid\Command\InspectUlidCommand: ~
Symfony\Component\Uid\Command\InspectUuidCommand: ~
Symfony\Component\Serializer\Normalizer\FormErrorNormalizer: ~
+1 -1
View File
@@ -4,7 +4,7 @@ services:
###> doctrine/doctrine-bundle ###
database:
ports:
- "5432"
- "5432:5432"
###< doctrine/doctrine-bundle ###
###> symfony/mailer ###
+2 -1
View File
@@ -4,6 +4,7 @@ namespace App\Command;
use App\Entity\User;
use App\Repository\UserRepository;
use App\Utils\UserValidator;
use Doctrine\ORM\EntityManagerInterface;
use Exception;
use Symfony\Component\Console\Attribute\AsCommand;
@@ -51,7 +52,7 @@ final class AddUserCommand extends Command
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly UserPasswordHasherInterface $passwordHasher,
private readonly ValidatorInterface $validator,
private readonly UserValidator $validator,
private readonly UserRepository $users
) {
parent::__construct();
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Controller;
use App\Entity\Domain;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Validator\ValidatorInterface;
class DomainCommentController extends AbstractController
{
#[Route('/api/domain/{id}/comment', name: 'app_api_comment_add')]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function addCommentAPI(ValidatorInterface $validator)
{
$validator->validate([
'' => new NotBlank()
]);
}
#[Route('/domain/{id}/comment', name: 'app_domain_comments')]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function index(Domain $domain): Response
{
return $this->render('domain_comment/index.html.twig', [
'domain' => $domain,
'controller_name' => 'DomainCommentController',
]);
}
}
+85 -40
View File
@@ -7,79 +7,124 @@ use App\Form\DomainType;
use App\Repository\DomainRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Serializer\Exception\ExceptionInterface;
use Symfony\Component\Serializer\Normalizer\FormErrorNormalizer;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\String\ByteString;
#[Route('/domain')]
#[Route('/api')]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class DomainController extends AbstractController
{
#[Route('/', name: 'app_domain_index', methods: ['GET'])]
public function index(DomainRepository $domainRepository): Response
public function __construct(
protected SerializerInterface $serializer,
protected FormErrorNormalizer $errorNormalizer
)
{
return $this->render('domain/index.html.twig', [
'domains' => $domainRepository->findAll(),
}
#[Route('/domains', name: 'app_api_domain_list', methods: ['GET'])]
public function getDomains(DomainRepository $domainRepository): JsonResponse
{
return new JsonResponse([
'domains' => $this->serializer->normalize($domainRepository->findAll())
]);
}
#[Route('/new', name: 'app_domain_new', methods: ['GET', 'POST'])]
public function new(Request $request, EntityManagerInterface $entityManager): Response
/**
* @throws ExceptionInterface
* @throws \HttpInvalidParamException
*/
#[Route('/domains', name: 'app_api_add_domain', methods: ['POST'])]
public function addDomain(
DomainRepository $domainRepository,
Request $request,
EntityManagerInterface $entityManager,
): JsonResponse
{
$data = json_decode($request->getContent(), true);
if(json_last_error() !== JSON_ERROR_NONE) {
return new JsonResponse([
'errors' => [ 'Invalid payload' ]
], Response::HTTP_BAD_REQUEST);
}
$domain = new Domain();
$domain->setOwnerToken(ByteString::fromRandom(64)->toString());
$form = $this->createForm(DomainType::class, $domain);
$form->handleRequest($request);
$data['defaultSortPolicy'] = $data['default_sort_policy'] ?? Domain::SORT_POLICIES[array_key_first(Domain::SORT_POLICIES)];
$form = $this->createForm(DomainType::class, $domain, [
'csrf_protection' => false,
'validation_groups' => 'create'
]);
$form->submit($data);
if ($form->isSubmitted() && $form->isValid()) {
$entityManager->persist($domain);
$entityManager->flush();
return $this->redirectToRoute('app_domain_index', [], Response::HTTP_SEE_OTHER);
if(!$form->isValid()) {
return new JsonResponse([
'errors' => $this->errorNormalizer->normalize($form)
], Response::HTTP_BAD_REQUEST);
}
return $this->render('domain/new.html.twig', [
'domain' => $domain,
'form' => $form,
$entityManager->persist($domain);
$entityManager->flush();
return new JsonResponse([
'domain' => $this->serializer->normalize($domain)
]);
}
#[Route('/{id}', name: 'app_domain_show', methods: ['GET'])]
public function show(Domain $domain): Response
/**
* @throws ExceptionInterface
*/
#[Route('/domains/{id}', name: 'app_api_get_domain', methods: ['GET'])]
public function getDomain(
Domain $domain
)
{
return $this->render('domain/show.html.twig', [
'domain' => $domain,
return new JsonResponse([
'domain' => $this->serializer->normalize($domain)
]);
}
#[Route('/{id}/edit', name: 'app_domain_edit', methods: ['GET', 'POST'])]
public function edit(Request $request, Domain $domain, EntityManagerInterface $entityManager): Response
#[Route('/domains/{id}', name: 'app_api_edit_domain', methods: ['PUT'])]
public function editDomain(
Request $request,
Domain $domain,
): Response
{
$form = $this->createForm(DomainType::class, $domain);
$form->handleRequest($request);
$data = json_decode($request->getContent(), true);
if(json_last_error() !== JSON_ERROR_NONE) {
return new JsonResponse([
'errors' => [ 'Invalid payload' ]
], Response::HTTP_BAD_REQUEST);
}
$form = $this->createForm(DomainType::class, $domain, [
'csrf_protection' => false,
'validation_groups' => 'update'
]);
$form->submit($data, false);
if ($form->isSubmitted() && $form->isValid()) {
$entityManager->flush();
return $this->redirectToRoute('app_domain_index', [], Response::HTTP_SEE_OTHER);
if(!$form->isValid()) {
return new JsonResponse([
'errors' => $this->errorNormalizer->normalize($form)
], Response::HTTP_BAD_REQUEST);
}
return $this->render('domain/edit.html.twig', [
'domain' => $domain,
'form' => $form,
return new JsonResponse([
'domain' => $this->serializer->normalize($domain)
]);
}
#[Route('/{id}', name: 'app_domain_delete', methods: ['POST'])]
public function delete(Request $request, Domain $domain, EntityManagerInterface $entityManager): Response
#[Route('/domains/{id}', name: 'app_api_delete_domain', methods: ['DELETE'])]
public function deleteDomain(Request $request, Domain $domain, EntityManagerInterface $entityManager): Response
{
if ($this->isCsrfTokenValid('delete'.$domain->getId(), $request->request->get('_token'))) {
$entityManager->remove($domain);
$entityManager->flush();
}
return $this->redirectToRoute('app_domain_index', [], Response::HTTP_SEE_OTHER);
$entityManager->remove($domain);
$entityManager->flush();
return new JsonResponse([]);
}
}
+4
View File
@@ -9,8 +9,11 @@ use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Gedmo\Mapping\Annotation\Timestampable;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Serializer\Annotation\Ignore;
#[ORM\Entity(repositoryClass: DomainRepository::class)]
#[UniqueEntity('domain', "This domain has already been added.")]
class Domain
{
const SORT_POLICIES = [
@@ -49,6 +52,7 @@ class Domain
private ?DateTimeInterface $createdAt = null;
#[ORM\OneToMany(mappedBy: 'domain', targetEntity: DomainPage::class)]
#[Ignore]
private Collection $domainPages;
public function __construct()
+122
View File
@@ -0,0 +1,122 @@
<?php
namespace App\Entity;
use App\Repository\UserAccessTokenRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UlidType;
use Symfony\Component\Uid\Ulid;
#[ORM\Entity(repositoryClass: UserAccessTokenRepository::class)]
class UserAccessToken
{
#[ORM\Id]
#[ORM\Column(type: UlidType::NAME, unique: true)]
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
#[ORM\CustomIdGenerator(class: 'doctrine.ulid_generator')]
private ?string $id = null;
#[ORM\Column(length: 255)]
private ?string $token = null;
#[ORM\Column]
private ?\DateTimeImmutable $createdAt = null;
#[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)]
private ?\DateTimeInterface $updatedAt = null;
#[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)]
private ?\DateTimeInterface $deletedAt = null;
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: false)]
private ?User $owner = null;
#[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)]
private ?\DateTimeInterface $expiresAt = null;
public function getId(): ?int
{
return $this->id;
}
public function setId(Ulid $id): static
{
$this->id = $id;
return $this;
}
public function getToken(): ?string
{
return $this->token;
}
public function setToken(string $token): static
{
$this->token = $token;
return $this;
}
public function getCreatedAt(): ?\DateTimeImmutable
{
return $this->createdAt;
}
public function setCreatedAt(\DateTimeImmutable $createdAt): static
{
$this->createdAt = $createdAt;
return $this;
}
public function getUpdatedAt(): ?\DateTimeInterface
{
return $this->updatedAt;
}
public function setUpdatedAt(?\DateTimeInterface $updatedAt): static
{
$this->updatedAt = $updatedAt;
return $this;
}
public function getDeletedAt(): ?\DateTimeInterface
{
return $this->deletedAt;
}
public function setDeletedAt(?\DateTimeInterface $deletedAt): static
{
$this->deletedAt = $deletedAt;
return $this;
}
public function getOwner(): ?User
{
return $this->owner;
}
public function setOwner(?User $owner): static
{
$this->owner = $owner;
return $this;
}
public function getExpiresAt(): ?\DateTimeInterface
{
return $this->expiresAt;
}
public function setExpiresAt(?\DateTimeInterface $expiresAt): static
{
$this->expiresAt = $expiresAt;
return $this;
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\EventListener;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Core\Exception\CredentialsExpiredException;
final class ApiAuthListener
{
#[AsEventListener(event: KernelEvents::EXCEPTION, priority: 2)]
public function onKernelException(ExceptionEvent $event): void
{
$exception = $event->getThrowable();
if (!$exception instanceof BadCredentialsException && !$exception instanceof CredentialsExpiredException) {
return;
}
// ... perform some action (e.g. logging)
if($event->getRequest()->isXmlHttpRequest()) {
$event->setResponse(new JsonResponse(['error' => $exception->getMessage()], 401));
}
// optionally set the custom response
// or stop propagation (prevents the next exception listeners from being called)
// $event->stopPropagation();
}
}
+13 -3
View File
@@ -9,27 +9,37 @@ use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
class DomainType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
public function buildForm(FormBuilderInterface $builder, array $options, $CREATE = true): void
{
$builder
->add('enabled', CheckboxType::class, [
'attr' => ['checked' => 'checked', 'value' => '1']
'attr' => ['checked' => 'checked', 'value' => '1'],
])
->add('domain', TextType::class, [
'attr' => [
'placeholder' => 'example.com'
],
'constraints' => [
new NotBlank(groups: ['create']),
new Length(min: 1, max: 255),
]
])
->add('name', TextType::class, [
'attr' => [
'placeholder' => "John Doe's Blog"
],
'constraints' => [
new NotBlank(groups: ['create']),
new Length(min: 1, max: 255)
]
])
->add('defaultSortPolicy', ChoiceType::class, [
'choices' => Domain::SORT_POLICIES,
'choices' => Domain::SORT_POLICIES
])
;
}
@@ -0,0 +1,48 @@
<?php
namespace App\Repository;
use App\Entity\UserAccessToken;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<UserAccessTokenRepository>
*
* @method UserAccessTokenRepository|null find($id, $lockMode = null, $lockVersion = null)
* @method UserAccessTokenRepository|null findOneBy(array $criteria, array $orderBy = null)
* @method UserAccessTokenRepository[] findAll()
* @method UserAccessTokenRepository[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class UserAccessTokenRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, UserAccessToken::class);
}
// /**
// * @return UserAccessToken[] Returns an array of UserAccessToken objects
// */
// public function findByExampleField($value): array
// {
// return $this->createQueryBuilder('u')
// ->andWhere('u.exampleField = :val')
// ->setParameter('val', $value)
// ->orderBy('u.id', 'ASC')
// ->setMaxResults(10)
// ->getQuery()
// ->getResult()
// ;
// }
// public function findOneBySomeField($value): ?UserAccessToken
// {
// return $this->createQueryBuilder('u')
// ->andWhere('u.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Security;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface;
use Symfony\Component\Security\Http\Authorization\AccessDeniedHandlerInterface;
class AccessTokenDeniedHandler implements AccessDeniedHandlerInterface, AuthenticationFailureHandlerInterface
{
public function handle(Request $request, AccessDeniedException $accessDeniedException): ?Response
{
return new Response('AccessTokenDeniedHandler', 403);
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response
{
return new JsonResponse([
'result' => 'error',
'error' => $exception->getMessage()
], Response::HTTP_UNAUTHORIZED);
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace App\Security;
use App\Entity\UserAccessToken;
use App\Repository\UserAccessTokenRepository;
use App\Repository\UserRepository;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Core\Exception\CredentialsExpiredException;
use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
class AccessTokenHandler implements AccessTokenHandlerInterface
{
public function __construct(
private UserAccessTokenRepository $repository
) {
}
public function getUserBadgeFrom(string $accessToken): UserBadge
{
/** @var $token UserAccessToken|null */
if (!$accessToken || !$token = $this->repository->findOneBy(['token' => $accessToken])) {
throw new BadCredentialsException('Invalid credentials.');
}
if($token->getDeletedAt() || ( $token->getExpiresAt() && $token->getExpiresAt() <= new \DateTime() ) ) {
throw new CredentialsExpiredException('Token expired.');
}
// and return a UserBadge object containing the user identifier from the found token
return new UserBadge($token->getOwner()->getId(), function() use($token){
return $token->getOwner();
});
}
}
+12
View File
@@ -253,6 +253,18 @@
"templates/base.html.twig"
]
},
"symfony/uid": {
"version": "6.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.2",
"ref": "d294ad4add3e15d7eb1bae0221588ca89b38e558"
},
"files": [
"config/packages/uid.yaml"
]
},
"symfony/validator": {
"version": "6.3",
"recipe": {
+1 -5
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html data-bs-theme="dark">
<html data-bs-theme="dark" lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -28,10 +28,6 @@
<i class="fas fa-tachometer-alt fa-fw me-3"></i>
<span>Dashboard</span>
</a>
<a href="#" class="list-group-item list-group-item-action py-2 ripple">
<i class="fas fa-comments fa-fw me-3"></i>
<span>Comments</span>
</a>
<a href="{{ path('app_domain_index') }}" class="list-group-item list-group-item-action py-2 ripple{{ app.request.pathInfo starts with path('app_domain_index') ? ' active' : ''}}">
<i class="fas fa-globe fa-fw me-3"></i>
<span>Domains</span>
+1
View File
@@ -27,6 +27,7 @@
<td>{{ domain.updatedAt ? domain.updatedAt|date('Y-m-d H:i:s') : '' }}</td>
<td>{{ domain.createdAt ? domain.createdAt|date('Y-m-d H:i:s') : '' }}</td>
<td>
<a href="{{ path('app_domain_comments', {'id': domain.id}) }}">comments</a>
<a href="{{ path('app_domain_show', {'id': domain.id}) }}">show</a>
<a href="{{ path('app_domain_edit', {'id': domain.id}) }}">edit</a>
</td>
+44
View File
@@ -0,0 +1,44 @@
{% extends 'base.html.twig' %}
{% block title %}Comment List{% endblock %}
{% block body %}
<h1>{{ domain.domain }} Comment List</h1>
<table class="table">
<thead>
<tr>
<th>Domain</th>
<th>Name</th>
<th>Enabled</th>
<th>DefaultSortPolicy</th>
<th>UpdatedAt</th>
<th>CreatedAt</th>
<th>actions</th>
</tr>
</thead>
<tbody>
{% for comment in [] %}
<tr>
<td>{{ domain.domain }}</td>
<td>{{ domain.name }}</td>
<td>{{ domain.enabled ? 'Yes' : 'No' }}</td>
<td>{{ domain.getDefaultSortPolicyName }}</td>
<td>{{ domain.updatedAt ? domain.updatedAt|date('Y-m-d H:i:s') : '' }}</td>
<td>{{ domain.createdAt ? domain.createdAt|date('Y-m-d H:i:s') : '' }}</td>
<td>
<a href="{{ path('app_domain_comments', {'id': domain.id}) }}">comments</a>
<a href="{{ path('app_domain_show', {'id': domain.id}) }}">show</a>
<a href="{{ path('app_domain_edit', {'id': domain.id}) }}">edit</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="9">no records found</td>
</tr>
{% endfor %}
</tbody>
</table>
<a href="{{ path('app_domain_new') }}">Create new</a>
{% endblock %}
+3289 -7051
View File
File diff suppressed because it is too large Load Diff