Skip to content

Commit efbc05d

Browse files
committed
Merge main into fix/574-trim-identifier
2 parents 140c1e7 + 6f07272 commit efbc05d

24 files changed

Lines changed: 636 additions & 20 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@ Every change that reaches `main` adds a line under **Unreleased**. When a versio
44

55
## Unreleased
66

7+
### Added
8+
- `emails.logo` (a Twig path, default the bundle's logo) and `emails.logo_width` (pixels, optional) set the logo at the top of every email the bundle sends, whose alt text is now the `website_name` instead of `Logo` ([#576](https://github.com/components-web-app/api-components-bundle/pull/576))
9+
710
### Fixed
11+
- A user referenced by IRI from another resource's write is no longer processed as a new registration, which reset their `emailAddressVerified` ([#575](https://github.com/components-web-app/api-components-bundle/pull/575))
12+
- A `plainPassword` set through the API is validated (6 to 4096 characters, 422 on `plainPassword`) before it is hashed, and setting a password no longer marks the email address verified; only a password reset does ([#577](https://github.com/components-web-app/api-components-bundle/pull/577))
813
- User lookups trim the identifier, so a username or email address with a trailing space (a mobile keyboard's) still signs in and still finds the user for password reset and resend verification ([#578](https://github.com/components-web-app/api-components-bundle/pull/578))
914

1015
## [2.0.0-alpha.39](https://github.com/components-web-app/api-components-bundle/compare/2.0.0-alpha.38...2.0.0-alpha.39) - 2026-10-30

‎CLAUDE.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -295,9 +295,12 @@ Resources are fetched and cached **individually**. Never embed related data outs
295295
### Security and users
296296

297297
- **A `PUT` on a user merges, never replaces** (#426). API Platform's `standard_put` defaults to `true` on 4.x and 5.x: the body is denormalized into a new object and every property is copied onto the stored one, including those nobody can write (password hash, tokens), so they took the constructor's defaults. `UserResourceMetadataCollectionFactory` sets `standard_put: false` on every `Put` of an `AbstractUser`, over any application default. `User\UserPutTest` pins it.
298+
- **`UserNormalizer` handles only a user payload (an array)** (#572). A user referenced by IRI from another resource's write reaches the serializer as a string; running `processChanges()` on it treated the stored user as a new registration and reset `emailAddressVerified`. `User\UserReferenceTest` pins it.
299+
- **A plain password is hashed only after validation, and never verifies the email address** (#570). `UserDataProcessor::processChanges()` does not hash: the API hashes in `UserPasswordStateProcessor` (on `state_processor.locator`, before the persist) and the form listener after `processChanges()`, so the `plainPassword` `Length` (in `Default` as well as `User:password:create`) sees the value on API writes; hashed during denormalization, it was already erased. Only a password reset, which proves the inbox, sets `emailAddressVerified`. `User\UserPasswordTest` pins it.
298300
- **User email links** (#316): `RefererUrlResolver` uses `Origin`, else `Referer`, reduced to `scheme://host[:port]`, only if it matches `user.email_links.allowed_origins` (anchored by the bundle as `{\A(?:p)\z}i`), else `default_origin`, else the link is refused. The request host is not implicitly trusted. Request input may only be a `RelativeUrlPath`. **Never build an outbound link from a request header without an allow-list.**
299301
- **A refused email link depends on which kind of email it is** (#326). Where the email is the job (password reset, both resends), the request is a 400 that changes nothing: build and send the email before flushing the token it carries. After a completed write (every email from `UserWriteNotifier::notify()`), the write keeps its status and `UserMailer::afterWrite()` logs the refusal at error; the verify and change-email emails serve both kinds, so the notifier must call their `…AfterWrite` variants. Only `UnparseableRequestHeaderException` is caught.
300302
- **A throttled email request is a 429 with `Retry-After`** (#331), never a 200 that sends nothing. `UserDataProcessor` throws `RequestLimitReachedException` carrying the seconds remaining; the action returns it with nothing sent or changed. Each flow has its own `repeat_ttl_seconds`: `password_reset` (86400), `new_email_confirmation` (300) and `email_verification` (300). Never share one throttle between flows.
303+
- **Every bundle email extends `emails/_template.html.twig`, so every sender's context carries `website_name`, `email_logo` and `email_logo_width`** (`SilverbackApiComponentsExtension::emailContext()`, #573). `emails.logo` is a Twig path, because `email.image()` loads it through the Twig loader; a filesystem path fails. **Find an embedded image from the `<img>`, never by attachment name**: twig-bridge 7.3 (the lowest-dependency legs) names the part with the whole Twig path and writes `cid:<that path>`, 7.4+ uses the basename and a generated content id, and the lowest CSS inliner puts the element's own `style` before the inlined rules. `Email\EmailLogoTest` follows the `src` to the part whose content id or filename it names.
301304
- **A failed send returns false whether or not a logger is wired** (#340): the logger is optional in `UserMailer`'s service locator, so check `has('logger')`; `get()` on a missing id throws.
302305
- **The `…RequestedAt` timestamp starts the throttle, so `UserMailer` sets it only after `send()` succeeds**, and flushes it with the token then. A failed send (503) saves nothing: the action refreshes the user, so the token is not replaced by one nobody received.
303306
- Actions calling `UserDataProcessor::findUserByUsername()` must catch its `InvalidArgumentException` and return 404; it throws rather than returning null.
@@ -466,7 +469,7 @@ The bundle does not set `use_symfony_listeners`. API Platform's default (`false`
466469
| `api_platform.state_provider.read`, negative priority, so it wraps the `security` access checkers (0, 10) | `POST_READ`, `PRE_DESERIALIZE` | deny access (-20), publishable merge (-30), component usage (-40), upload and download (-50), resource version (-60) |
467470
| `api_platform.state_provider.deserialize` | `POST_DESERIALIZE` | publication date guard (-10) |
468471
| `api_platform.state_provider.validate` | `POST_VALIDATE` | route generation (-10) |
469-
| `api_platform.state_processor.locator`, the persist/remove call | `PRE_WRITE` before the inner call, `POST_WRITE` after it | version conflict (5), publishable (10), user emails (15), uploadable (20), delete cascade (30), position removal (40), route redirects (50), position placement (60) |
472+
| `api_platform.state_processor.locator`, the persist/remove call | `PRE_WRITE` before the inner call, `POST_WRITE` after it | version conflict (5), publishable (10), user emails (15), user password hash (16), uploadable (20), delete cascade (30), position removal (40), route redirects (50), position placement (60) |
470473
| `api_platform.state_processor.serialize` | `PRE_SERIALIZE` | form (10), collection (20) |
471474

472475
- **Never decorate `state_processor.write` or `state_*.main`.** In controller mode, `write`'s inner call is serialize and respond, so its "after" runs after serialization. The `main` services do not exist in listener mode.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Silverback API Components Bundle Project
5+
*
6+
* (c) Daniel West <daniel@silverback.is>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Silverback\ApiComponentsBundle\DataProcessor\StateProcessor;
13+
14+
use ApiPlatform\Metadata\HttpOperation;
15+
use ApiPlatform\Metadata\Operation;
16+
use ApiPlatform\State\ProcessorInterface;
17+
use Silverback\ApiComponentsBundle\Entity\User\AbstractUser;
18+
use Silverback\ApiComponentsBundle\Helper\User\UserDataProcessor;
19+
20+
/**
21+
* @implements ProcessorInterface<mixed, mixed>
22+
*/
23+
final readonly class UserPasswordStateProcessor implements ProcessorInterface
24+
{
25+
/**
26+
* @param ProcessorInterface<mixed, mixed> $decorated
27+
*/
28+
public function __construct(
29+
private ProcessorInterface $decorated,
30+
private UserDataProcessor $userDataProcessor,
31+
) {
32+
}
33+
34+
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
35+
{
36+
if ($data instanceof AbstractUser && $operation instanceof HttpOperation) {
37+
$this->userDataProcessor->hashPassword($data);
38+
}
39+
40+
return $this->decorated->process($data, $operation, $uriVariables, $context);
41+
}
42+
}

‎src/DependencyInjection/Configuration.php‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,21 @@ public function getConfigTreeBuilder(): TreeBuilder
5050
->scalarNode('website_name')->isRequired()->end()
5151
->scalarNode('table_prefix')->defaultValue('_acb_')->end()
5252
->scalarNode('metadata_key')->defaultValue('_metadata')->end()
53+
->arrayNode('emails')
54+
->addDefaultsIfNotSet()
55+
->children()
56+
->scalarNode('logo')
57+
->info('The Twig path of the logo at the top of every email the bundle sends.')
58+
->cannotBeEmpty()
59+
->defaultValue('@SilverbackApiComponents/assets/images/email_logo.png')
60+
->end()
61+
->integerNode('logo_width')
62+
->info('The width in pixels the logo is shown at, so a 2x image renders sharp. Null shows it at its own width.')
63+
->defaultNull()
64+
->min(1)
65+
->end()
66+
->end()
67+
->end()
5368
->end();
5469

5570
$this->addMercureNode($rootNode);

‎src/DependencyInjection/SilverbackApiComponentsExtension.php‎

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ public function load(array $configs, ContainerBuilder $container): void
178178

179179
$definition = $container->findDefinition(OrphanedResourcesChangedEmailFactory::class);
180180
$definition->setArgument('$subject', $config['orphaned_resources']['notify']['subject']);
181-
$definition->setArgument('$context', ['website_name' => $config['website_name']]);
181+
$definition->setArgument('$context', self::emailContext($config));
182182

183183
$definition = $container->findDefinition(UploadableCacheControl::class);
184184
$definition->setArgument('$original', $config['uploadable']['cache_control']['original']);
@@ -319,15 +319,19 @@ private function setUserClassArguments(ContainerBuilder $container, string $user
319319
$definition->setArgument('$userClass', $userClass);
320320
}
321321

322+
private static function emailContext(array $config): array
323+
{
324+
return [
325+
'website_name' => $config['website_name'],
326+
'email_logo' => $config['emails']['logo'],
327+
'email_logo_width' => $config['emails']['logo_width'],
328+
];
329+
}
330+
322331
private function setMailerServiceArguments(ContainerBuilder $container, array $config): void
323332
{
324333
$definition = $container->findDefinition(UserMailer::class);
325-
$definition->setArgument(
326-
'$context',
327-
[
328-
'website_name' => $config['website_name'],
329-
]
330-
);
334+
$definition->setArgument('$context', self::emailContext($config));
331335

332336
$mapping = [
333337
PasswordChangedEmailFactory::class => 'password_changed',

‎src/Entity/User/AbstractUser.php‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ abstract class AbstractUser implements SymfonyUserInterface, PasswordAuthenticat
6767
protected string $password;
6868

6969
#[Assert\NotBlank(message: 'Please enter your desired password.', groups: ['User:password:create'])]
70-
#[Assert\Length(min: 6, max: 4096, minMessage: 'Your password must be more than 6 characters long.', maxMessage: 'Your password cannot be over 4096 characters', groups: ['User:password:create'])]
70+
#[Assert\Length(min: 6, max: 4096, minMessage: 'Your password must be more than 6 characters long.', maxMessage: 'Your password cannot be over 4096 characters', groups: ['Default', 'User:password:create'])]
7171
#[ApiProperty(readable: false)]
7272
#[Groups(['User:input'])]
7373
protected ?string $plainPassword = null;
@@ -225,7 +225,6 @@ public function setPlainPassword(?string $plainPassword): self
225225
{
226226
$this->plainPassword = $plainPassword;
227227
if ($plainPassword) {
228-
// Needs to update mapped field to trigger update event which will encode the plain password
229228
$this->passwordUpdatedAt = new \DateTime();
230229
}
231230

‎src/EventListener/Form/EntityPersistFormListener.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ public function __invoke(FormSuccessEvent $event): void
102102
}
103103

104104
$this->userDataProcessor->processChanges($data, $oldUser);
105+
$this->userDataProcessor->hashPassword($data);
105106
$this->userWriteNotifier->notify($data, $oldUser);
106107
}
107108

‎src/Helper/User/UserDataProcessor.php‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,14 +67,14 @@ public function passwordReset(string $username, string $token, string $newPasswo
6767
$user->setPlainPassword($newPassword);
6868
$user->setNewPasswordConfirmationToken(null);
6969
$user->setPasswordRequestedAt(null);
70+
$user->setEmailAddressVerified(true);
7071
$this->hashPassword($user);
7172

7273
return $user;
7374
}
7475

7576
public function processChanges(AbstractUser $user, ?AbstractUser $previousUser): void
7677
{
77-
$this->hashPassword($user);
7878
if (!$previousUser) {
7979
$user->setEmailAddressVerified($this->initialEmailVerifiedState);
8080
if (!$this->initialEmailVerifiedState && $this->verifyEmailOnRegister) {
@@ -151,14 +151,13 @@ private function setEmailAddressVerifyToken(AbstractUser $user): void
151151
$user->plainEmailAddressVerifyToken = $token;
152152
}
153153

154-
private function hashPassword(AbstractUser $entity): void
154+
public function hashPassword(AbstractUser $entity): void
155155
{
156156
if (!$entity->getPlainPassword()) {
157157
return;
158158
}
159159
$encoded = $this->passwordHasher->hashPassword($entity, $entity->getPlainPassword());
160160
$entity->setPassword($encoded);
161-
$entity->setEmailAddressVerified(true);
162161
$entity->eraseCredentials();
163162
}
164163
}

‎src/Resources/config/services.php‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
use Silverback\ApiComponentsBundle\DataProcessor\StateProcessor\RouteRedirectStateProcessor;
6767
use Silverback\ApiComponentsBundle\DataProcessor\StateProcessor\UploadableWriteStateProcessor;
6868
use Silverback\ApiComponentsBundle\DataProcessor\StateProcessor\UserNotificationStateProcessor;
69+
use Silverback\ApiComponentsBundle\DataProcessor\StateProcessor\UserPasswordStateProcessor;
6970
use Silverback\ApiComponentsBundle\DataProvider\PageDataProvider;
7071
use Silverback\ApiComponentsBundle\DataProvider\StateProvider\ComponentGroupOrderStateProvider;
7172
use Silverback\ApiComponentsBundle\DataProvider\StateProvider\ComponentGroupStateProvider;
@@ -1915,6 +1916,18 @@
19151916
);
19161917
$services->alias(UserNotificationStateProcessor::class, 'silverback.api_components.api_platform.state_processor.user_notification');
19171918

1919+
$services
1920+
->set('silverback.api_components.api_platform.state_processor.user_password')
1921+
->class(UserPasswordStateProcessor::class)
1922+
->decorate('api_platform.state_processor.locator', null, 16)
1923+
->args(
1924+
[
1925+
new Reference('silverback.api_components.api_platform.state_processor.user_password.inner'),
1926+
new Reference(UserDataProcessor::class),
1927+
]
1928+
);
1929+
$services->alias(UserPasswordStateProcessor::class, 'silverback.api_components.api_platform.state_processor.user_password');
1930+
19181931
$services
19191932
->set('silverback.api_components.factory.user')
19201933
->class(UserFactory::class)

‎src/Resources/views/emails/_template.html.twig‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
<spacer size="16"></spacer>
77

88
<center>
9-
<img src="{{ email.image('@SilverbackApiComponents/assets/images/email_logo.png') }}" alt="Logo" class="logo">
9+
<img src="{{ email.image(email_logo) }}" alt="{{ website_name }}" class="logo"{% if email_logo_width %} width="{{ email_logo_width }}" style="width: {{ email_logo_width }}px"{% endif %}>
1010
</center>
1111
</columns>
1212
</row>

0 commit comments

Comments
 (0)