Merge branch 'main' into feat/v3-project-migration

This commit is contained in:
2024-04-08 14:12:05 +02:00
28 changed files with 1064 additions and 54 deletions
+1
View File
@@ -16,6 +16,7 @@
"nelmio/cors-bundle": "^2.3",
"phpdocumentor/reflection-docblock": "^5.3",
"phpstan/phpdoc-parser": "^1.24",
"stof/doctrine-extensions-bundle": "^1.11",
"subiabre/doctrine-snowflakes": "^2.0",
"symfony/asset": "6.4.*",
"symfony/console": "6.4.*",
Generated
+258 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "b558cc67d1647e5cfb49487057a3af3d",
"content-hash": "e65a6d86be5982961b544a7bfa7704c9",
"packages": [
{
"name": "api-platform/core",
@@ -172,6 +172,55 @@
},
"time": "2024-02-20T09:52:06+00:00"
},
{
"name": "behat/transliterator",
"version": "v1.5.0",
"source": {
"type": "git",
"url": "https://github.com/Behat/Transliterator.git",
"reference": "baac5873bac3749887d28ab68e2f74db3a4408af"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Behat/Transliterator/zipball/baac5873bac3749887d28ab68e2f74db3a4408af",
"reference": "baac5873bac3749887d28ab68e2f74db3a4408af",
"shasum": ""
},
"require": {
"php": ">=7.2"
},
"require-dev": {
"chuyskywalker/rolling-curl": "^3.1",
"php-yaoi/php-yaoi": "^1.0",
"phpunit/phpunit": "^8.5.25 || ^9.5.19"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.x-dev"
}
},
"autoload": {
"psr-4": {
"Behat\\Transliterator\\": "src/Behat/Transliterator"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Artistic-1.0"
],
"description": "String transliterator",
"keywords": [
"i18n",
"slug",
"transliterator"
],
"support": {
"issues": "https://github.com/Behat/Transliterator/issues",
"source": "https://github.com/Behat/Transliterator/tree/v1.5.0"
},
"time": "2022-03-30T09:27:43+00:00"
},
{
"name": "brick/math",
"version": "0.11.0",
@@ -1607,6 +1656,134 @@
},
"time": "2022-05-23T21:33:49+00:00"
},
{
"name": "gedmo/doctrine-extensions",
"version": "v3.15.0",
"source": {
"type": "git",
"url": "https://github.com/doctrine-extensions/DoctrineExtensions.git",
"reference": "2a89103f4984d8970f3855284c8c04e6e6a63c0f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/doctrine-extensions/DoctrineExtensions/zipball/2a89103f4984d8970f3855284c8c04e6e6a63c0f",
"reference": "2a89103f4984d8970f3855284c8c04e6e6a63c0f",
"shasum": ""
},
"require": {
"behat/transliterator": "^1.2",
"doctrine/collections": "^1.2 || ^2.0",
"doctrine/common": "^2.13 || ^3.0",
"doctrine/deprecations": "^1.0",
"doctrine/event-manager": "^1.2 || ^2.0",
"doctrine/persistence": "^2.2 || ^3.0",
"php": "^7.4 || ^8.0",
"psr/cache": "^1 || ^2 || ^3",
"psr/clock": "^1",
"symfony/cache": "^5.4 || ^6.0 || ^7.0"
},
"conflict": {
"doctrine/annotations": "<1.13 || >=3.0",
"doctrine/dbal": "<3.2 || >=4.0",
"doctrine/mongodb-odm": "<2.3 || >=3.0",
"doctrine/orm": "<2.14.0 || 2.16.0 || 2.16.1 || >=3.0"
},
"require-dev": {
"doctrine/annotations": "^1.13 || ^2.0",
"doctrine/cache": "^1.11 || ^2.0",
"doctrine/dbal": "^3.2",
"doctrine/doctrine-bundle": "^2.3",
"doctrine/mongodb-odm": "^2.3",
"doctrine/orm": "^2.14.0",
"friendsofphp/php-cs-fixer": "^3.14.0",
"nesbot/carbon": "^2.71 || ^3.0",
"phpstan/phpstan": "^1.10.2",
"phpstan/phpstan-doctrine": "^1.0",
"phpstan/phpstan-phpunit": "^1.0",
"phpunit/phpunit": "^9.6",
"rector/rector": "^0.19",
"symfony/console": "^5.4 || ^6.0 || ^7.0",
"symfony/phpunit-bridge": "^6.0 || ^7.0",
"symfony/yaml": "^5.4 || ^6.0 || ^7.0"
},
"suggest": {
"doctrine/mongodb-odm": "to use the extensions with the MongoDB ODM",
"doctrine/orm": "to use the extensions with the ORM"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "3.13-dev"
}
},
"autoload": {
"psr-4": {
"Gedmo\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Gediminas Morkevicius",
"email": "gediminas.morkevicius@gmail.com"
},
{
"name": "Gustavo Falco",
"email": "comfortablynumb84@gmail.com"
},
{
"name": "David Buchmann",
"email": "david@liip.ch"
}
],
"description": "Doctrine behavioral extensions",
"homepage": "http://gediminasm.org/",
"keywords": [
"Blameable",
"behaviors",
"doctrine",
"extensions",
"gedmo",
"loggable",
"nestedset",
"odm",
"orm",
"sluggable",
"sortable",
"timestampable",
"translatable",
"tree",
"uploadable"
],
"support": {
"email": "gediminas.morkevicius@gmail.com",
"issues": "https://github.com/doctrine-extensions/DoctrineExtensions/issues",
"source": "https://github.com/doctrine-extensions/DoctrineExtensions/tree/v3.15.0",
"wiki": "https://github.com/Atlantic18/DoctrineExtensions/tree/main/doc"
},
"funding": [
{
"url": "https://github.com/l3pp4rd",
"type": "github"
},
{
"url": "https://github.com/mbabker",
"type": "github"
},
{
"url": "https://github.com/phansys",
"type": "github"
},
{
"url": "https://github.com/stof",
"type": "github"
}
],
"time": "2024-02-12T15:17:22+00:00"
},
{
"name": "godruoyi/php-snowflake",
"version": "3.0.0",
@@ -2354,6 +2531,86 @@
},
"time": "2021-07-14T16:46:02+00:00"
},
{
"name": "stof/doctrine-extensions-bundle",
"version": "v1.11.0",
"source": {
"type": "git",
"url": "https://github.com/stof/StofDoctrineExtensionsBundle.git",
"reference": "9f7023e4c8a1c00a5627d41c1027a3f89e477630"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/stof/StofDoctrineExtensionsBundle/zipball/9f7023e4c8a1c00a5627d41c1027a3f89e477630",
"reference": "9f7023e4c8a1c00a5627d41c1027a3f89e477630",
"shasum": ""
},
"require": {
"gedmo/doctrine-extensions": "^3.15.0",
"php": "^7.4 || ^8.0",
"symfony/cache": "^5.4 || ^6.0 || ^7.0",
"symfony/config": "^5.4 || ^6.0 || ^7.0",
"symfony/dependency-injection": "^5.4 || ^6.0 || ^7.0",
"symfony/event-dispatcher": "^5.4 || ^6.0 || ^7.0",
"symfony/http-kernel": "^5.4 || ^6.0 || ^7.0"
},
"require-dev": {
"phpstan/phpstan": "^1.10",
"phpstan/phpstan-deprecation-rules": "^1.1",
"phpstan/phpstan-phpunit": "^1.3",
"phpstan/phpstan-strict-rules": "^1.5",
"phpstan/phpstan-symfony": "^1.3",
"symfony/mime": "^5.4 || ^6.0 || ^7.0",
"symfony/phpunit-bridge": "^v6.4.1 || ^7.0.1",
"symfony/security-core": "^5.4 || ^6.0 || ^7.0"
},
"suggest": {
"doctrine/doctrine-bundle": "to use the ORM extensions",
"doctrine/mongodb-odm-bundle": "to use the MongoDB ODM extensions",
"symfony/mime": "To use the Mime component integration for Uploadable"
},
"type": "symfony-bundle",
"extra": {
"branch-alias": {
"dev-main": "1.x-dev"
}
},
"autoload": {
"psr-4": {
"Stof\\DoctrineExtensionsBundle\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Christophe Coevoet",
"email": "stof@notk.org"
}
],
"description": "Integration of the gedmo/doctrine-extensions with Symfony",
"homepage": "https://github.com/stof/StofDoctrineExtensionsBundle",
"keywords": [
"behaviors",
"doctrine2",
"extensions",
"gedmo",
"loggable",
"nestedset",
"sluggable",
"sortable",
"timestampable",
"translatable",
"tree"
],
"support": {
"issues": "https://github.com/stof/StofDoctrineExtensionsBundle/issues",
"source": "https://github.com/stof/StofDoctrineExtensionsBundle/tree/v1.11.0"
},
"time": "2024-02-13T14:43:20+00:00"
},
{
"name": "subiabre/doctrine-snowflakes",
"version": "2.0",
+1
View File
@@ -11,4 +11,5 @@ return [
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
Zenstruck\Foundry\ZenstruckFoundryBundle::class => ['dev' => true, 'test' => true],
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
Stof\DoctrineExtensionsBundle\StofDoctrineExtensionsBundle::class => ['all' => true],
];
+6
View File
@@ -21,6 +21,12 @@ doctrine:
dir: '%kernel.project_dir%/src/Entity'
prefix: 'App\Entity'
alias: App
gedmo_loggable:
type: attribute
prefix: Gedmo\Loggable\Entity
dir: "%kernel.project_dir%/vendor/gedmo/doctrine-extensions/src/Loggable/Entity"
is_bundle: false
when@test:
doctrine:
@@ -0,0 +1,8 @@
# Read the documentation: https://symfony.com/doc/current/bundles/StofDoctrineExtensionsBundle/index.html
# See the official DoctrineExtensions documentation for more details: https://github.com/doctrine-extensions/DoctrineExtensions/tree/main/doc
stof_doctrine_extensions:
default_locale: en_US
orm:
default:
loggable: true
+6
View File
@@ -32,6 +32,12 @@ RUN apt install -y \
&& docker-php-ext-install \
zip
# Intl
RUN apt install -y \
libicu-dev \
&& docker-php-ext-install \
intl
# Composer
COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer
+101
View File
@@ -0,0 +1,101 @@
<?php
namespace App\ApiResource;
use ApiPlatform\Metadata as API;
use App\Filter\VersionResourceFilter;
use App\Filter\VersionResourceIdFilter;
use App\State\VersionStateProvider;
use Gedmo\Loggable\Entity\LogEntry;
/**
* Some resources are versioned. This means v4 keeps track of the changes performed in subsets of specific properties within these resources.\
* \
* This allows us to keep track of the flow and the evolution of records in the platform.
* Looking at the changes done between one version and the next one we can reconstruct how a resource was at a certain point in time.
*/
#[API\ApiFilter(VersionResourceFilter::class, properties: ['resource'])]
#[API\ApiFilter(VersionResourceIdFilter::class, properties: ['resourceId'])]
#[API\GetCollection(provider: VersionStateProvider::class)]
#[API\Get(provider: VersionStateProvider::class)]
class Version
{
public function __construct(
private readonly LogEntry $log,
private readonly object $entity
) {
}
/**
* The ID of the version record.
*/
public function getId(): ?int
{
return $this->log->getId();
}
/**
* The ID of the version for this specific resource.
*/
public function getVersion(): ?int
{
return $this->log->getVersion();
}
/**
* The type of action that performed the recorded changes.
*/
public function getAction(): ?string
{
return $this->log->getAction();
}
/**
* The type of the recorded resource.
*/
public function getResource(): string
{
$classPieces = explode('\\', $this->log->getObjectClass());
return end($classPieces);
}
/**
* The ID of the recorded resource.
*/
public function getResouceId(): int
{
return $this->log->getObjectId();
}
/**
* The full resource data, reconstructed from the current resource data merged with versioned data.
*/
public function getResourceData()
{
$entity = $this->entity;
foreach ($this->log->getData() as $changedProperty => $changedValue) {
$setter = sprintf("set%s", ucfirst($changedProperty));
$entity->$setter($changedValue);
}
return $entity;
}
/**
* The changed resource data, i.e the new values of the changed properties.
*/
public function getResourceChanges()
{
return $this->log->getData();
}
/**
* The date at which this version was created.
*/
public function getDateCreated(): ?\DateTimeInterface
{
return $this->log->getLoggedAt();
}
}
+97
View File
@@ -0,0 +1,97 @@
<?php
namespace App\Entity;
use ApiPlatform\Metadata as API;
use App\Repository\GatewayChargeRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: GatewayChargeRepository::class)]
class GatewayCharge
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
/**
* The money to be charged by the Gateway at checkout.
*/
#[Assert\NotBlank()]
#[ORM\Embedded(class: Money::class)]
private ?Money $money = null;
/**
* The Accounting that will receive the Transaction of this GatewayCharge after a successful checkout.
*/
#[Assert\NotBlank()]
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: false)]
private ?Accounting $target = null;
/**
* Custom data to be passed to the Gateway.\
* E.g: billing period, product name, custom metadata, etc.
*/
#[ORM\Column(nullable: true)]
private ?array $extradata = null;
#[API\ApiProperty(writable: false)]
#[ORM\ManyToOne(inversedBy: 'charges')]
#[ORM\JoinColumn(nullable: false)]
private ?GatewayCheckout $checkout = null;
public function getId(): ?int
{
return $this->id;
}
public function getMoney(): ?Money
{
return $this->money;
}
public function setMoney(Money $money): static
{
$this->money = $money;
return $this;
}
public function getTarget(): ?Accounting
{
return $this->target;
}
public function setTarget(?Accounting $target): static
{
$this->target = $target;
return $this;
}
public function getExtradata(): ?array
{
return $this->extradata;
}
public function setExtradata(?array $extradata): static
{
$this->extradata = $extradata;
return $this;
}
public function getCheckout(): ?GatewayCheckout
{
return $this->checkout;
}
public function setCheckout(?GatewayCheckout $checkout): static
{
$this->checkout = $checkout;
return $this;
}
}
+121
View File
@@ -0,0 +1,121 @@
<?php
namespace App\Entity;
use ApiPlatform\Metadata as API;
use App\Repository\GatewayCheckoutRepository;
use App\State\GatewayCheckoutProcessor;
use App\Validator\GatewayName;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
/**
* A GatewayCheckout bundles the data to perform a charge operation at a Gateway.
* Use it in order to create Transactions and have the transferred money be backed by a Gateway's payment processing.
*/
#[API\GetCollection()]
#[API\Post(processor: GatewayCheckoutProcessor::class)]
#[API\Get()]
#[ORM\Entity(repositoryClass: GatewayCheckoutRepository::class)]
class GatewayCheckout
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
/**
* The name of the Gateway implementation to checkout with.
*/
#[GatewayName]
#[Assert\NotBlank()]
#[ORM\Column(length: 255)]
private ?string $gateway = null;
/**
* The Accounting that will issue the Transactions of the GatewayCharges after a successful checkout.
*/
#[Assert\NotBlank()]
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: false)]
private ?Accounting $origin = null;
/**
* The charges to be made by the Gateway at checkout.\
* Each GatewayCharge will generate a separate Transaction after a successful checkout.
*/
#[Assert\NotBlank()]
#[Assert\Count(min: 1)]
#[ORM\OneToMany(
mappedBy: 'checkout',
targetEntity: GatewayCharge::class,
cascade: ['persist']
)]
private Collection $charges;
public function __construct()
{
$this->charges = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getGateway(): ?string
{
return $this->gateway;
}
public function setGateway(string $gateway): static
{
$this->gateway = $gateway;
return $this;
}
public function getOrigin(): ?Accounting
{
return $this->origin;
}
public function setOrigin(?Accounting $origin): static
{
$this->origin = $origin;
return $this;
}
/**
* @return Collection<int, GatewayCharge>
*/
public function getCharges(): Collection
{
return $this->charges;
}
public function addCharge(GatewayCharge $charge): static
{
if (!$this->charges->contains($charge)) {
$this->charges->add($charge);
$charge->setCheckout($this);
}
return $this;
}
public function removeCharge(GatewayCharge $charge): static
{
if ($this->charges->removeElement($charge)) {
// set the owning side to null (unless already changed)
if ($charge->getCheckout() === $this) {
$charge->setCheckout(null);
}
}
return $this;
}
}
@@ -0,0 +1,12 @@
<?php
namespace App\Entity\Interface;
use App\Entity\User;
interface UserOwnedInterface
{
public function getOwner(): ?User;
public function isOwnedBy(User $user): bool;
}
+3 -1
View File
@@ -2,6 +2,7 @@
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata as API;
use App\Repository\ProjectRepository;
use Doctrine\ORM\Mapping as ORM;
@@ -12,13 +13,14 @@ use Doctrine\ORM\Mapping as ORM;
* Since they can be recipients of funding, they are assigned an Accounting when created.
* A Project's Accounting represents how much money the Project has raised from the community.
*/
#[ORM\Entity(repositoryClass: ProjectRepository::class)]
#[API\GetCollection()]
#[API\Post(security: 'is_granted("ROLE_USER")')]
#[API\Get()]
#[API\Put(security: 'is_granted("AUTH_PROJECT_EDIT")')]
#[API\Delete(security: 'is_granted("AUTH_PROJECT_EDIT")')]
#[API\Patch(security: 'is_granted("AUTH_PROJECT_EDIT")')]
#[API\ApiFilter(filterClass: SearchFilter::class, properties: ['title' => 'partial'])]
#[ORM\Entity(repositoryClass: ProjectRepository::class)]
class Project
{
#[ORM\Id]
+25 -10
View File
@@ -4,8 +4,6 @@ namespace App\Entity;
use ApiPlatform\Metadata as API;
use App\Repository\TransactionRepository;
use App\State\TransactionStateProcessor;
use App\Validator\GatewayName;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
@@ -17,7 +15,6 @@ use Symfony\Component\Validator\Constraints as Assert;
*/
#[ORM\Entity(repositoryClass: TransactionRepository::class)]
#[API\GetCollection()]
#[API\Post(processor: TransactionStateProcessor::class)]
#[API\Get()]
class Transaction
{
@@ -27,7 +24,7 @@ class Transaction
private ?int $id = null;
/**
* The monetary value to be received at target and issued at origin.
* The monetary value received at target and issued at origin.
*/
#[Assert\Valid()]
#[Assert\NotBlank()]
@@ -51,12 +48,18 @@ class Transaction
private ?Accounting $target = null;
/**
* The Gateway processing this Transaction.
* The name of the Gateway implementation that secured the money in this Transaction.
*/
#[Assert\Valid()]
#[Assert\NotBlank()]
#[ORM\Embedded(class: TransactionGateway::class)]
private ?TransactionGateway $gateway = null;
#[ORM\Column(length: 255)]
private ?string $gateway = null;
/**
* An external identifier provided by the Gateway for this Transaction.
*/
#[Assert\NotBlank()]
#[ORM\Column(length: 255)]
private ?string $gatewayReference = null;
public function getId(): ?int
{
@@ -99,15 +102,27 @@ class Transaction
return $this;
}
public function getGateway(): ?TransactionGateway
public function getGateway(): ?string
{
return $this->gateway;
}
public function setGateway(TransactionGateway $gateway): static
public function setGateway(string $gateway): static
{
$this->gateway = $gateway;
return $this;
}
public function getGatewayReference(): ?string
{
return $this->gatewayReference;
}
public function setGatewayReference(string $gatewayReference): static
{
$this->gatewayReference = $gatewayReference;
return $this;
}
}
+28 -13
View File
@@ -2,11 +2,14 @@
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata as API;
use App\Entity\Interface\UserOwnedInterface;
use App\Repository\UserRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Gedmo\Mapping\Annotation as Gedmo;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
@@ -19,16 +22,21 @@ use Symfony\Component\Validator\Constraints as Assert;
* Users are the usual issuers of funding, however an User's Accounting can still be a Transaction recipient.
* This allows to keep an User's "wallet", witholding their non-raised fundings into their Accounting.
*/
#[Gedmo\Loggable()]
#[API\GetCollection()]
#[API\Post()]
#[API\Post(validationContext: ['groups' => ['default', 'postValidation']])]
#[API\Get()]
#[API\Put(security: 'is_granted("AUTH_USER_EDIT")')]
#[API\Delete(security: 'is_granted("AUTH_USER_EDIT")')]
#[API\Patch(security: 'is_granted("AUTH_USER_EDIT")')]
#[API\Put(security: 'is_granted("USER_EDIT", object)')]
#[API\Delete(security: 'is_granted("USER_EDIT", object)')]
#[API\Patch(security: 'is_granted("USER_EDIT", object)')]
#[API\ApiFilter(filterClass: SearchFilter::class, properties: [
'username' => 'partial',
'name' => 'partial'
])]
#[UniqueEntity(fields: ['username'], message: 'This usernames already exists.')]
#[UniqueEntity(fields: ['email'], message: 'This email address is already registered.')]
#[ORM\Entity(repositoryClass: UserRepository::class)]
class User implements UserInterface, PasswordAuthenticatedUserInterface
class User implements UserInterface, UserOwnedInterface, PasswordAuthenticatedUserInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
@@ -38,6 +46,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
/**
* Human readable, non white space, unique string.
*/
#[Gedmo\Versioned]
#[Assert\NotBlank()]
#[Assert\Length(min: 4, max: 30)]
#[Assert\Regex('/^[a-z0-9_]+$/')]
@@ -61,12 +70,13 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
/**
* Plain-text, will be hashed by the platform.
*/
#[Assert\NotBlank()]
#[Assert\NotBlank(['groups' => ['postValidation']])]
#[Assert\Length(min: 12)]
#[API\ApiProperty(writable: true, readable: false)]
#[API\ApiProperty(writable: true, readable: false, required: true)]
#[SerializedName('password')]
private ?string $plainPassword = null;
#[Gedmo\Versioned]
#[Assert\NotBlank()]
#[Assert\Email()]
#[ORM\Column(length: 255)]
@@ -80,8 +90,8 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
/**
* The UserTokens owned by this user. Owner only property.
*/
#[API\ApiProperty(writable: false, readableLink: true, security: 'is_granted("AUTH_OWNER", object)')]
#[ORM\OneToMany(mappedBy: 'ownedBy', targetEntity: UserToken::class, orphanRemoval: true)]
#[API\ApiProperty(writable: false, security: 'is_granted("USER_OWNED", object)')]
#[ORM\OneToMany(mappedBy: 'owner', targetEntity: UserToken::class, orphanRemoval: true)]
private Collection $tokens;
#[ORM\Column]
@@ -152,6 +162,11 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return (string) $this->username;
}
public function getOwner(): ?User
{
return $this;
}
public function isOwnedBy(User $user): bool
{
return $this->getUserIdentifier() === $user->getUserIdentifier();
@@ -201,7 +216,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this;
}
public function getPlainPassword(): string
public function getPlainPassword(): ?string
{
return $this->plainPassword;
}
@@ -258,7 +273,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
{
if (!$this->tokens->contains($token)) {
$this->tokens->add($token);
$token->setOwnedBy($this);
$token->setOwner($this);
}
return $this;
@@ -268,8 +283,8 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
{
if ($this->tokens->removeElement($token)) {
// set the owning side to null (unless already changed)
if ($token->getOwnedBy() === $this) {
$token->setOwnedBy(null);
if ($token->getOwner() === $this) {
$token->setOwner(null);
}
}
+10 -8
View File
@@ -4,6 +4,7 @@ namespace App\Entity;
use ApiPlatform\Metadata as API;
use App\Dto\UserTokenLoginDto;
use App\Entity\Interface\UserOwnedInterface;
use App\Repository\UserTokenRepository;
use App\State\UserTokenLoginProcessor;
use Doctrine\ORM\Mapping as ORM;
@@ -18,9 +19,10 @@ use Doctrine\ORM\Mapping as ORM;
* `pat_` means the token was created via a login flow.
*/
#[API\Post(input: UserTokenLoginDto::class, processor: UserTokenLoginProcessor::class)]
#[API\Delete(security: 'is_granted("AUTH_OWNER")')]
#[API\Get(security: 'is_granted("USER_OWNED", object)')]
#[API\Delete(security: 'is_granted("USER_OWNED", object)')]
#[ORM\Entity(repositoryClass: UserTokenRepository::class)]
class UserToken
class UserToken implements UserOwnedInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
@@ -38,7 +40,7 @@ class UserToken
*/
#[ORM\ManyToOne(inversedBy: 'tokens')]
#[ORM\JoinColumn(nullable: false)]
private ?User $ownedBy = null;
private ?User $owner = null;
public function getId(): ?int
{
@@ -57,20 +59,20 @@ class UserToken
return $this;
}
public function getOwnedBy(): ?User
public function getOwner(): ?User
{
return $this->ownedBy;
return $this->owner;
}
public function setOwnedBy(?User $ownedBy): static
public function setOwner(?User $owner): static
{
$this->ownedBy = $ownedBy;
$this->owner = $owner;
return $this;
}
public function isOwnedBy(User $user): bool
{
return $this->ownedBy->getUserIdentifier() === $user->getUserIdentifier();
return $this->owner->getUserIdentifier() === $user->getUserIdentifier();
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace App\Filter;
use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
use Psr\Log\LoggerInterface;
use Symfony\Component\PropertyInfo\Type;
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
final class VersionResourceFilter extends AbstractFilter
{
public function __construct(
protected ManagerRegistry $managerRegistry,
?LoggerInterface $logger = null,
protected ?array $properties = null,
protected ?NameConverterInterface $nameConverter = null
) {
parent::__construct($managerRegistry, $logger, $properties, $nameConverter);
}
protected function filterProperty(
string $property,
$value,
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
Operation $operation = null,
array $context = []
): void {
return;
}
public function getDescription(string $resourceClass): array
{
$description = [];
foreach ($this->properties as $property => $strategy) {
$description["$property"] = [
'property' => $property,
'type' => Type::BUILTIN_TYPE_STRING,
'required' => true,
'description' => 'The name of the resource.',
'schema' => [
'type' => Type::BUILTIN_TYPE_STRING,
'enum' => ['User']
],
'openapi' => [
'allowEmptyValue' => false,
],
];
}
return $description;
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace App\Filter;
use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\PropertyInfo\Type;
final class VersionResourceIdFilter extends AbstractFilter
{
protected function filterProperty(
string $property,
$value,
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
Operation $operation = null,
array $context = []
): void {
return;
}
public function getDescription(string $resourceClass): array
{
$description = [];
foreach ($this->properties as $property => $strategy) {
$description["$property"] = [
'property' => $property,
'type' => Type::BUILTIN_TYPE_INT,
'required' => true,
'description' => 'The ID of the named resource.',
'openapi' => [
'allowEmptyValue' => false,
],
];
}
return $description;
}
}
+1 -1
View File
@@ -30,7 +30,7 @@ class UsersPump implements PumpInterface
foreach ($data as $key => $userData) {
$username = $this->normalizeUsername($userData['id']);
if (!$username) {
continue;
$username = $this->normalizeUsername($userData['email']);
}
$user = new User;
@@ -2,7 +2,7 @@
namespace App\Library\Economy\Payment;
use App\Entity\Transaction;
use App\Entity\GatewayCheckout;
interface GatewayInterface
{
@@ -12,11 +12,11 @@ interface GatewayInterface
public static function getName(): string;
/**
* When processing a Transaction a Gateway must validate it using it's own means.
* When processing a GatewayCheckout a Gateway must validate it using it's own means.
*
* Gateways are trusted to have secured the funds in the Transaction.
* @param Transaction
* @return Transaction
* Gateways are trusted to have secured the funds in the GatewayCheckout.
* @param GatewayCheckout $checkout
* @return GatewayCheckout
*/
public function process(Transaction $transaction): Transaction;
public function process(GatewayCheckout $checkout): GatewayCheckout;
}
+10 -5
View File
@@ -2,7 +2,7 @@
namespace App\Library\Economy\Payment;
use App\Entity\Transaction;
use App\Entity\GatewayCheckout;
class GatewayLocator
{
@@ -135,12 +135,17 @@ class GatewayLocator
}
/**
* @param Transaction $transaction
* @param GatewayCheckout $checkout
* @return GatewayInterface
* @throws \Exception When the $transaction::gateway::name does not match to that of an implemented Gateway
* @throws \Exception When the $checkout::gateway does not match to that of an implemented Gateway
*/
public function getGatewayOf(Transaction $transaction): GatewayInterface
public function getGatewayOf(GatewayCheckout $checkout): GatewayInterface
{
return $this->getGateway($transaction->getGateway()->getName());
$gateway = $checkout->getGateway();
if (!$gateway) {
throw new \Exception("The given GatewayCheckout does not specify a Gateway");
}
return $this->getGateway($gateway);
}
}
@@ -0,0 +1,48 @@
<?php
namespace App\Repository;
use App\Entity\GatewayCharge;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<GatewayCharge>
*
* @method GatewayCharge|null find($id, $lockMode = null, $lockVersion = null)
* @method GatewayCharge|null findOneBy(array $criteria, array $orderBy = null)
* @method GatewayCharge[] findAll()
* @method GatewayCharge[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class GatewayChargeRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, GatewayCharge::class);
}
// /**
// * @return GatewayCharge[] Returns an array of GatewayCharge objects
// */
// public function findByExampleField($value): array
// {
// return $this->createQueryBuilder('g')
// ->andWhere('g.exampleField = :val')
// ->setParameter('val', $value)
// ->orderBy('g.id', 'ASC')
// ->setMaxResults(10)
// ->getQuery()
// ->getResult()
// ;
// }
// public function findOneBySomeField($value): ?GatewayCharge
// {
// return $this->createQueryBuilder('g')
// ->andWhere('g.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
}
@@ -0,0 +1,48 @@
<?php
namespace App\Repository;
use App\Entity\GatewayCheckout;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<GatewayCheckout>
*
* @method GatewayCheckout|null find($id, $lockMode = null, $lockVersion = null)
* @method GatewayCheckout|null findOneBy(array $criteria, array $orderBy = null)
* @method GatewayCheckout[] findAll()
* @method GatewayCheckout[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class GatewayCheckoutRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, GatewayCheckout::class);
}
// /**
// * @return GatewayCheckout[] Returns an array of GatewayCheckout objects
// */
// public function findByExampleField($value): array
// {
// return $this->createQueryBuilder('g')
// ->andWhere('g.exampleField = :val')
// ->setParameter('val', $value)
// ->orderBy('g.id', 'ASC')
// ->setMaxResults(10)
// ->getQuery()
// ->getResult()
// ;
// }
// public function findOneBySomeField($value): ?GatewayCheckout
// {
// return $this->createQueryBuilder('g')
// ->andWhere('g.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
}
+1 -1
View File
@@ -21,6 +21,6 @@ class AccessTokenHandler implements AccessTokenHandlerInterface
throw new BadCredentialsException();
}
return new UserBadge($token->getOwnedBy()->getUserIdentifier());
return new UserBadge($token->getOwner()->getUserIdentifier());
}
}
@@ -2,21 +2,28 @@
namespace App\Security\Voter;
use App\Entity\Interface\UserOwnedInterface;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\User\UserInterface;
class AuthOwnerVoter extends Voter
class UserOwnedVoter extends Voter
{
public const OWNER = 'AUTH_OWNER';
public const OWNED = 'USER_OWNED';
protected function supports(string $attribute, mixed $subject): bool
{
return in_array($attribute, [self::OWNER])
&& $subject !== null
&& method_exists($subject, 'isOwnedBy');
if (!in_array($attribute, [self::OWNED])) {
return false;
}
return $subject instanceof UserOwnedInterface;
}
/**
* @param User|UserOwnedInterface $subject
*/
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
@@ -27,7 +34,5 @@ class AuthOwnerVoter extends Voter
}
return $subject->isOwnedBy($user);
return false;
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace App\Security\Voter;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\User\UserInterface;
class UserVoter extends Voter
{
public const EDIT = 'USER_EDIT';
public const VIEW = 'USER_VIEW';
protected function supports(string $attribute, mixed $subject): bool
{
return in_array($attribute, [self::EDIT, self::VIEW])
&& $subject instanceof User;
}
/**
* @param User $subject
*/
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
if (!$user instanceof UserInterface) {
return false;
}
switch ($attribute) {
case self::EDIT:
return $user->getUserIdentifier() === $subject->getUserIdentifier()
|| \in_array('ROLE_ADMIN', $user->getRoles());
case self::VIEW:
return true;
}
return false;
}
}
+1 -1
View File
@@ -28,7 +28,7 @@ class AuthService
{
$token = new UserToken;
$token->setOwnedBy($user);
$token->setOwner($user);
$token->setToken(sprintf('%s%s', $type->value, hash(
self::TOKEN_HASH_ALGO,
join('', [
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\GatewayCheckout;
use App\Library\Economy\Payment\GatewayLocator;
use Doctrine\ORM\EntityManagerInterface;
class GatewayCheckoutProcessor implements ProcessorInterface
{
public function __construct(
private GatewayLocator $gatewayLocator,
private EntityManagerInterface $entityManager
) {
}
/**
* @param GatewayCheckout $data
*/
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): GatewayCheckout
{
$gateway = $this->gatewayLocator->getGatewayOf($data);
$this->entityManager->persist($data);
$this->entityManager->flush();
$gateway->process($data);
return $data;
}
}
+73
View File
@@ -0,0 +1,73 @@
<?php
namespace App\State;
use ApiPlatform\Metadata as API;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\Version;
use Doctrine\ORM\EntityManagerInterface;
use Gedmo\Loggable\Entity\LogEntry;
use Gedmo\Loggable\Entity\Repository\LogEntryRepository;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class VersionStateProvider implements ProviderInterface
{
private LogEntryRepository $versionRepository;
public function __construct(
private EntityManagerInterface $entityManager
) {
$this->versionRepository = $this->entityManager->getRepository(LogEntry::class);
}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
{
switch ($operation::class) {
case API\Get::class:
return $this->getVersion($uriVariables['id']);
case API\GetCollection::class:
return $this->getVersions(
$context['request']->query->get('resource'),
$context['request']->query->get('resourceId')
);
}
}
/**
* @throws NotFoundHttpException
*/
private function getVersion(int $id): Version
{
$log = $this->versionRepository->find($id);
if (!$log) {
throw new NotFoundHttpException("Not Found");
}
$entity = $this->entityManager->find($log->getObjectClass(), $log->getObjectId());
return new Version($log, $entity);
}
/**
* @return Version[]
*/
private function getVersions(string $resource, int $resourceId): array
{
$entity = $this->entityManager->find($this->resourceToEntity($resource), $resourceId);
$logs = $this->versionRepository->getLogEntries($entity);
$versions = [];
foreach ($logs as $log) {
$versions[] = new Version($log, $entity);
}
return $versions;
}
private function resourceToEntity(string $resource): string
{
return sprintf("App\\Entity\\%s", ucfirst(strtolower($resource)));
}
}
+12
View File
@@ -66,6 +66,18 @@
"tests/bootstrap.php"
]
},
"stof/doctrine-extensions-bundle": {
"version": "1.11",
"recipe": {
"repo": "github.com/symfony/recipes-contrib",
"branch": "main",
"version": "1.2",
"ref": "e805aba9eff5372e2d149a9ff56566769e22819d"
},
"files": [
"config/packages/stof_doctrine_extensions.yaml"
]
},
"symfony/console": {
"version": "6.3",
"recipe": {