Merge branch '5.4' into 6.0

* 5.4:
  expand uninitialized session tests
  [Lock] Release PostgreSqlStore connection lock on failure
  [DomCrawler] Fix HTML5 parser charset option
  cs fix
  [HttpKernel] Do not attempt to register enum arguments in controller service locator
  [Mime] Fix missing sprintf in DkimSigner
  [Translation] [LocoProvider] Use rawurlencode and separate tag setting
  [Security] fix unserializing session payloads from v4
  [Cache] Don't lock when doing nested computations
  [Messenger] fix Redis support on 32b arch
  [HttpFoundation] Fix notice when HTTP_PHP_AUTH_USER passed without pass
  [Security] Add getting started example to README
This commit is contained in:
Nicolas Grekas 2021-12-28 18:21:00 +01:00
commit bcc5b4b81b
22 changed files with 375 additions and 78 deletions

View File

@ -22,6 +22,8 @@ install:
- cd ext
- appveyor DownloadFile https://github.com/symfony/binary-utils/releases/download/v0.1/php_apcu-5.1.21-8.0-ts-vs16-x86.zip
- 7z x php_apcu-5.1.21-8.0-ts-vs16-x86.zip -y >nul
- appveyor DownloadFile https://github.com/symfony/binary-utils/releases/download/v0.1/php_redis-5.3.5-8.0-ts-vs16-x86.zip
- 7z x php_redis-5.3.5-8.0-ts-vs16-x86.zip -y >nul
- cd ..
- copy /Y php.ini-development php.ini-min
- echo memory_limit=-1 >> php.ini-min
@ -37,6 +39,7 @@ install:
- echo opcache.enable_cli=1 >> php.ini-max
- echo extension=php_openssl.dll >> php.ini-max
- echo extension=php_apcu.dll >> php.ini-max
- echo extension=php_redis.dll >> php.ini-max
- echo apc.enable_cli=1 >> php.ini-max
- echo extension=php_intl.dll >> php.ini-max
- echo extension=php_mbstring.dll >> php.ini-max
@ -55,6 +58,7 @@ install:
- SET COMPOSER_ROOT_VERSION=%SYMFONY_VERSION%.x-dev
- php composer.phar update --no-progress --ansi
- php phpunit install
- choco install memurai-developer
test_script:
- SET X=0

View File

@ -157,7 +157,6 @@ jobs:
- name: Run tests
run: ./phpunit --group integration -v
env:
REDIS_HOST: localhost
REDIS_CLUSTER_HOSTS: 'localhost:7000 localhost:7001 localhost:7002 localhost:7003 localhost:7004 localhost:7005'
REDIS_SENTINEL_HOSTS: 'localhost:26379'
REDIS_SENTINEL_SERVICE: redis_sentinel
@ -165,10 +164,6 @@ jobs:
MESSENGER_AMQP_DSN: amqp://localhost/%2f/messages
MESSENGER_SQS_DSN: "sqs://localhost:9494/messages?sslmode=disable&poll_timeout=0.01"
MESSENGER_SQS_FIFO_QUEUE_DSN: "sqs://localhost:9494/messages.fifo?sslmode=disable&poll_timeout=0.01"
MEMCACHED_HOST: localhost
LDAP_HOST: localhost
LDAP_PORT: 3389
MONGODB_HOST: localhost
KAFKA_BROKER: 127.0.0.1:9092
POSTGRES_HOST: localhost

View File

@ -18,6 +18,7 @@
<env name="LDAP_HOST" value="localhost" />
<env name="LDAP_PORT" value="3389" />
<env name="REDIS_HOST" value="localhost" />
<env name="MESSENGER_REDIS_DSN" value="redis://localhost/messages" />
<env name="MEMCACHED_HOST" value="localhost" />
<env name="MONGODB_HOST" value="localhost" />
<env name="ZOOKEEPER_HOST" value="localhost" />

View File

@ -90,7 +90,7 @@ final class LockRegistry
$key = self::$files ? abs(crc32($item->getKey())) % \count(self::$files) : -1;
if ($key < 0 || (self::$lockedFiles[$key] ?? false) || !$lock = self::open($key)) {
if ($key < 0 || self::$lockedFiles || !$lock = self::open($key)) {
return $callback($item, $save);
}

View File

@ -1053,7 +1053,7 @@ class Crawler implements \Countable, \IteratorAggregate
private function parseHtml5(string $htmlContent, string $charset = 'UTF-8'): \DOMDocument
{
return $this->html5Parser->parse($this->convertToHtmlEntities($htmlContent, $charset), [], $charset);
return $this->html5Parser->parse($this->convertToHtmlEntities($htmlContent, $charset));
}
private function parseXhtml(string $htmlContent, string $charset = 'UTF-8'): \DOMDocument

View File

@ -87,7 +87,7 @@ class ServerBag extends ParameterBag
// PHP_AUTH_USER/PHP_AUTH_PW
if (isset($headers['PHP_AUTH_USER'])) {
$headers['AUTHORIZATION'] = 'Basic '.base64_encode($headers['PHP_AUTH_USER'].':'.$headers['PHP_AUTH_PW']);
$headers['AUTHORIZATION'] = 'Basic '.base64_encode($headers['PHP_AUTH_USER'].':'.($headers['PHP_AUTH_PW'] ?? ''));
} elseif (isset($headers['PHP_AUTH_DIGEST'])) {
$headers['AUTHORIZATION'] = $headers['PHP_AUTH_DIGEST'];
}

View File

@ -57,6 +57,16 @@ class ServerBagTest extends TestCase
], $bag->getHeaders());
}
public function testHttpPasswordIsOptionalWhenPassedWithHttpPrefix()
{
$bag = new ServerBag(['HTTP_PHP_AUTH_USER' => 'foo']);
$this->assertEquals([
'AUTHORIZATION' => 'Basic '.base64_encode('foo:'),
'PHP_AUTH_USER' => 'foo',
], $bag->getHeaders());
}
public function testHttpBasicAuthWithPhpCgi()
{
$bag = new ServerBag(['HTTP_AUTHORIZATION' => 'Basic '.base64_encode('foo:bar')]);

View File

@ -123,6 +123,11 @@ class RegisterControllerArgumentLocatorsPass implements CompilerPassInterface
$type = ltrim($target = (string) ProxyHelper::getTypeHint($r, $p), '\\');
$invalidBehavior = ContainerInterface::IGNORE_ON_INVALID_REFERENCE;
if (is_subclass_of($type, \UnitEnum::class)) {
// do not attempt to register enum typed arguments
continue;
}
if (isset($arguments[$r->name][$p->name])) {
$target = $arguments[$r->name][$p->name];
if ('?' !== $target[0]) {

View File

@ -24,6 +24,7 @@ use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\DependencyInjection\ServiceLocator;
use Symfony\Component\DependencyInjection\TypedReference;
use Symfony\Component\HttpKernel\DependencyInjection\RegisterControllerArgumentLocatorsPass;
use Symfony\Component\HttpKernel\Tests\Fixtures\Suit;
class RegisterControllerArgumentLocatorsPassTest extends TestCase
{
@ -400,6 +401,25 @@ class RegisterControllerArgumentLocatorsPassTest extends TestCase
$this->assertEqualsCanonicalizing([RegisterTestController::class.'::fooAction', 'foo::fooAction'], array_keys($locator));
}
/**
* @requires PHP 8.1
*/
public function testEnumArgumentIsIgnored()
{
$container = new ContainerBuilder();
$resolver = $container->register('argument_resolver.service')->addArgument([]);
$container->register('foo', NonNullableEnumArgumentWithDefaultController::class)
->addTag('controller.service_arguments')
;
$pass = new RegisterControllerArgumentLocatorsPass();
$pass->process($container);
$locator = $container->getDefinition((string) $resolver->getArgument(0))->getArgument(0);
$this->assertEmpty(array_keys($locator), 'enum typed argument is ignored');
}
public function testBindWithTarget()
{
$container = new ContainerBuilder();
@ -479,6 +499,13 @@ class ArgumentWithoutTypeController
}
}
class NonNullableEnumArgumentWithDefaultController
{
public function fooAction(Suit $suit = Suit::Spades)
{
}
}
class WithTarget
{
public function fooAction(

View File

@ -308,17 +308,18 @@ class SessionListenerTest extends TestCase
$this->assertSame('123456', $cookies[0]->getValue());
}
public function testUninitializedSession()
public function testUninitializedSessionUsingSessionFromRequest()
{
$kernel = $this->createMock(HttpKernelInterface::class);
$response = new Response();
$response->setSharedMaxAge(60);
$response->headers->set(AbstractSessionListener::NO_AUTO_CACHE_CONTROL_HEADER, 'true');
$container = new Container();
$request = new Request();
$request->setSession(new Session());
$listener = new SessionListener($container);
$listener->onKernelResponse(new ResponseEvent($kernel, new Request(), HttpKernelInterface::MAIN_REQUEST, $response));
$listener = new SessionListener(new Container());
$listener->onKernelResponse(new ResponseEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST, $response));
$this->assertFalse($response->headers->has('Expires'));
$this->assertTrue($response->headers->hasCacheControlDirective('public'));
$this->assertFalse($response->headers->hasCacheControlDirective('private'));

View File

@ -0,0 +1,20 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpKernel\Tests\Fixtures;
enum Suit: string
{
case Hearts = 'H';
case Diamonds = 'D';
case Clubs = 'C';
case Spades = 'S';
}

View File

@ -72,18 +72,28 @@ class PostgreSqlStore implements BlockingSharedLockStoreInterface, BlockingStore
// prevent concurrency within the same connection
$this->getInternalStore()->save($key);
$sql = 'SELECT pg_try_advisory_lock(:key)';
$stmt = $this->getConnection()->prepare($sql);
$stmt->bindValue(':key', $this->getHashedKey($key));
$result = $stmt->execute();
$lockAcquired = false;
// Check if lock is acquired
if (true === $stmt->fetchColumn()) {
$key->markUnserializable();
// release sharedLock in case of promotion
$this->unlockShared($key);
try {
$sql = 'SELECT pg_try_advisory_lock(:key)';
$stmt = $this->getConnection()->prepare($sql);
$stmt->bindValue(':key', $this->getHashedKey($key));
$result = $stmt->execute();
return;
// Check if lock is acquired
if (true === $stmt->fetchColumn()) {
$key->markUnserializable();
// release sharedLock in case of promotion
$this->unlockShared($key);
$lockAcquired = true;
return;
}
} finally {
if (!$lockAcquired) {
$this->getInternalStore()->delete($key);
}
}
throw new LockConflictedException();
@ -94,19 +104,29 @@ class PostgreSqlStore implements BlockingSharedLockStoreInterface, BlockingStore
// prevent concurrency within the same connection
$this->getInternalStore()->saveRead($key);
$sql = 'SELECT pg_try_advisory_lock_shared(:key)';
$stmt = $this->getConnection()->prepare($sql);
$lockAcquired = false;
$stmt->bindValue(':key', $this->getHashedKey($key));
$result = $stmt->execute();
try {
$sql = 'SELECT pg_try_advisory_lock_shared(:key)';
$stmt = $this->getConnection()->prepare($sql);
// Check if lock is acquired
if (true === $stmt->fetchColumn()) {
$key->markUnserializable();
// release lock in case of demotion
$this->unlock($key);
$stmt->bindValue(':key', $this->getHashedKey($key));
$result = $stmt->execute();
return;
// Check if lock is acquired
if (true === $stmt->fetchColumn()) {
$key->markUnserializable();
// release lock in case of demotion
$this->unlock($key);
$lockAcquired = true;
return;
}
} finally {
if (!$lockAcquired) {
$this->getInternalStore()->delete($key);
}
}
throw new LockConflictedException();

View File

@ -12,6 +12,7 @@
namespace Symfony\Component\Lock\Tests\Store;
use Symfony\Component\Lock\Exception\InvalidArgumentException;
use Symfony\Component\Lock\Exception\LockConflictedException;
use Symfony\Component\Lock\Key;
use Symfony\Component\Lock\PersistingStoreInterface;
use Symfony\Component\Lock\Store\PostgreSqlStore;
@ -50,4 +51,31 @@ class PostgreSqlStoreTest extends AbstractStoreTest
$this->expectExceptionMessage('The adapter "Symfony\Component\Lock\Store\PostgreSqlStore" does not support');
$store->exists(new Key('foo'));
}
public function testSaveAfterConflict()
{
$store1 = $this->getStore();
$store2 = $this->getStore();
$key = new Key(uniqid(__METHOD__, true));
$store1->save($key);
$this->assertTrue($store1->exists($key));
$lockConflicted = false;
try {
$store2->save($key);
} catch (LockConflictedException $lockConflictedException) {
$lockConflicted = true;
}
$this->assertTrue($lockConflicted);
$this->assertFalse($store2->exists($key));
$store1->delete($key);
$store2->save($key);
$this->assertTrue($store2->exists($key));
}
}

View File

@ -51,10 +51,10 @@ class Connection
private bool $autoSetup;
private int $maxEntries;
private int $redeliverTimeout;
private int $nextClaim = 0;
private mixed $claimInterval;
private mixed $deleteAfterAck;
private mixed $deleteAfterReject;
private float $nextClaim = 0.0;
private float $claimInterval;
private bool $deleteAfterAck;
private bool $deleteAfterReject;
private bool $couldHavePendingMessages = true;
public function __construct(array $configuration, array $connectionCredentials = [], array $redisOptions = [], \Redis|\RedisCluster $redis = null)
@ -104,7 +104,7 @@ class Connection
$this->deleteAfterAck = $configuration['delete_after_ack'] ?? self::DEFAULT_OPTIONS['delete_after_ack'];
$this->deleteAfterReject = $configuration['delete_after_reject'] ?? self::DEFAULT_OPTIONS['delete_after_reject'];
$this->redeliverTimeout = ($configuration['redeliver_timeout'] ?? self::DEFAULT_OPTIONS['redeliver_timeout']) * 1000;
$this->claimInterval = $configuration['claim_interval'] ?? self::DEFAULT_OPTIONS['claim_interval'];
$this->claimInterval = ($configuration['claim_interval'] ?? self::DEFAULT_OPTIONS['claim_interval']) / 1000;
}
/**
@ -320,7 +320,7 @@ class Connection
}
}
$this->nextClaim = $this->getCurrentTimeInMilliseconds() + $this->claimInterval;
$this->nextClaim = microtime(true) + $this->claimInterval;
}
public function get(): ?array
@ -328,36 +328,32 @@ class Connection
if ($this->autoSetup) {
$this->setup();
}
$now = microtime();
$now = substr($now, 11).substr($now, 2, 3);
try {
$queuedMessageCount = $this->connection->zcount($this->queue, 0, $this->getCurrentTimeInMilliseconds());
} catch (\RedisException $e) {
throw new TransportException($e->getMessage(), 0, $e);
}
$queuedMessageCount = $this->rawCommand('ZCOUNT', 0, $now);
if ($queuedMessageCount) {
for ($i = 0; $i < $queuedMessageCount; ++$i) {
try {
$queuedMessages = $this->connection->zpopmin($this->queue, 1);
} catch (\RedisException $e) {
throw new TransportException($e->getMessage(), 0, $e);
}
foreach ($queuedMessages as $queuedMessage => $time) {
$decodedQueuedMessage = json_decode($queuedMessage, true);
// if a futured placed message is actually popped because of a race condition with
// another running message consumer, the message is readded to the queue by add function
// else its just added stream and will be available for all stream consumers
$this->add(
\array_key_exists('body', $decodedQueuedMessage) ? $decodedQueuedMessage['body'] : $queuedMessage,
$decodedQueuedMessage['headers'] ?? [],
$time - $this->getCurrentTimeInMilliseconds()
);
}
while ($queuedMessageCount--) {
if (![$queuedMessage, $expiry] = $this->rawCommand('ZPOPMIN', 1)) {
break;
}
if (\strlen($expiry) === \strlen($now) ? $expiry > $now : \strlen($expiry) < \strlen($now)) {
// if a future-placed message is popped because of a race condition with
// another running consumer, the message is readded to the queue
if (!$this->rawCommand('ZADD', 'NX', $expiry, $queuedMessage)) {
throw new TransportException('Could not add a message to the redis stream.');
}
break;
}
$decodedQueuedMessage = json_decode($queuedMessage, true);
$this->add(\array_key_exists('body', $decodedQueuedMessage) ? $decodedQueuedMessage['body'] : $queuedMessage, $decodedQueuedMessage['headers'] ?? [], 0);
}
if (!$this->couldHavePendingMessages && $this->nextClaim <= $this->getCurrentTimeInMilliseconds()) {
if (!$this->couldHavePendingMessages && $this->nextClaim <= microtime(true)) {
$this->claimOldPendingMessages();
}
@ -448,7 +444,7 @@ class Connection
}
try {
if ($delayInMs > 0) { // the delay could be smaller 0 in a queued message
if ($delayInMs > 0) { // the delay is <= 0 for queued messages
$message = json_encode([
'body' => $body,
'headers' => $headers,
@ -460,8 +456,18 @@ class Connection
throw new TransportException(json_last_error_msg());
}
$score = $this->getCurrentTimeInMilliseconds() + $delayInMs;
$added = $this->connection->zadd($this->queue, ['NX'], $score, $message);
$now = explode(' ', microtime(), 2);
$now[0] = str_pad($delayInMs + substr($now[0], 2, 3), 3, '0', \STR_PAD_LEFT);
if (3 < \strlen($now[0])) {
$now[1] += substr($now[0], 0, -3);
$now[0] = substr($now[0], -3);
if (\is_float($now[1])) {
throw new TransportException("Message delay is too big: {$delayInMs}ms.");
}
}
$added = $this->rawCommand('ZADD', 'NX', $now[1].$now[0], $message);
} else {
$message = json_encode([
'body' => $body,
@ -542,6 +548,28 @@ class Connection
$this->connection->del($this->stream, $this->queue);
}
}
private function rawCommand(string $command, ...$arguments): mixed
{
try {
if ($this->connection instanceof \RedisCluster || $this->connection instanceof RedisClusterProxy) {
$result = $this->connection->rawCommand($this->queue, $command, $this->queue, ...$arguments);
} else {
$result = $this->connection->rawCommand($command, $this->queue, ...$arguments);
}
} catch (\RedisException $e) {
throw new TransportException($e->getMessage(), 0, $e);
}
if (false === $result) {
if ($error = $this->connection->getLastError() ?: null) {
$this->connection->clearLastError();
}
throw new TransportException($error ?? sprintf('Could not run "%s" on Redis queue.', $command));
}
return $result;
}
}
if (!class_exists(\Symfony\Component\Messenger\Transport\RedisExt\Connection::class, false)) {

View File

@ -62,7 +62,7 @@ final class DkimSigner
{
$options += $this->defaultOptions;
if (!\in_array($options['algorithm'], [self::ALGO_SHA256, self::ALGO_ED25519], true)) {
throw new InvalidArgumentException('Invalid DKIM signing algorithm "%s".', $options['algorithm']);
throw new InvalidArgumentException(sprintf('Invalid DKIM signing algorithm "%s".', $options['algorithm']));
}
$headersToIgnore['return-path'] = true;
$headersToIgnore['x-transport'] = true;
@ -202,7 +202,7 @@ final class DkimSigner
}
// Add trailing Line return if last line is non empty
if (\strlen($currentLine) > 0) {
if ('' !== $currentLine) {
hash_update($hash, "\r\n");
$length += \strlen("\r\n");
}

View File

@ -16,6 +16,7 @@ use Symfony\Bridge\PhpUnit\ClockMock;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Crypto\DkimSigner;
use Symfony\Component\Mime\Email;
use Symfony\Component\Mime\Message;
/**
* @group time-sensitive
@ -90,6 +91,21 @@ EOF;
];
}
public function testSignWithUnsupportedAlgorithm()
{
$message = $this->createMock(Message::class);
$signer = new DkimSigner(self::$pk, 'testdkim.symfony.net', 'sf', [
'algorithm' => 'unsupported-value',
]);
$this->expectExceptionObject(
new \LogicException('Invalid DKIM signing algorithm "unsupported-value".')
);
$signer->sign($message, []);
}
/**
* @dataProvider getCanonicalizeHeaderData
*/

View File

@ -3,8 +3,40 @@ Security Component - Core
Security provides an infrastructure for sophisticated authorization systems,
which makes it possible to easily separate the actual authorization logic from
so called user providers that hold the users credentials. It is inspired by
the Java Spring framework.
so called user providers that hold the users credentials.
Getting Started
---------------
```
$ composer require symfony/security-core
```
```php
use Symfony\Component\Security\Core\Authentication\AuthenticationTrustResolver;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Authorization\AccessDecisionManager;
use Symfony\Component\Security\Core\Authorization\Voter\AuthenticatedVoter;
use Symfony\Component\Security\Core\Authorization\Voter\RoleVoter;
use Symfony\Component\Security\Core\Authorization\Voter\RoleHierarchyVoter;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Security\Core\Role\RoleHierarchy;
$accessDecisionManager = new AccessDecisionManager([
new AuthenticatedVoter(new AuthenticationTrustResolver()),
new RoleVoter(),
new RoleHierarchyVoter(new RoleHierarchy([
'ROLE_ADMIN' => ['ROLE_USER'],
]))
]);
$user = new \App\Entity\User(...);
$token = new UsernamePasswordToken($user, 'main', $user->getRoles());
if (!$accessDecisionManager->decide($token, ['ROLE_ADMIN'])) {
throw new AccessDeniedException();
}
```
Sponsor
-------

View File

@ -0,0 +1,31 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Security\Core\Role;
/**
* Allows migrating session payloads from v4.
*
* @internal
*/
class Role
{
private $role;
private function __construct()
{
}
public function __toString(): string
{
return $this->role;
}
}

View File

@ -0,0 +1,23 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Security\Core\Role;
/**
* Allows migrating session payloads from v4.
*
* @internal
*/
class SwitchUserRole extends Role
{
private $deprecationTriggered;
private $source;
}

View File

@ -0,0 +1,28 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Security\Core\Tests\Role;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
class LegacyRoleTest extends TestCase
{
public function testPayloadFromV4CanBeUnserialized()
{
$serialized = 'C:74:"Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken":236:{a:3:{i:0;N;i:1;s:4:"main";i:2;a:5:{i:0;s:2:"sf";i:1;b:1;i:2;a:1:{i:0;O:41:"Symfony\Component\Security\Core\Role\Role":1:{s:47:"Symfony\Component\Security\Core\Role\Role'."\0".'role'."\0".'";s:9:"ROLE_USER";}}i:3;a:0:{}i:4;a:1:{i:0;s:9:"ROLE_USER";}}}}';
$token = unserialize($serialized);
$this->assertInstanceOf(UsernamePasswordToken::class, $token);
$this->assertSame(['ROLE_USER'], $token->getRoleNames());
}
}

View File

@ -1,10 +1,16 @@
Security Component - HTTP Integration
=====================================
Security provides an infrastructure for sophisticated authorization systems,
which makes it possible to easily separate the actual authorization logic from
so called user providers that hold the users credentials. It is inspired by
the Java Spring framework.
The Security HTTP component provides an HTTP integration of the Security Core
component. It allows securing (parts of) your application using firewalls and
provides authenticators to authenticate visitors.
Getting Started
---------------
```
$ composer require symfony/security-http
```
Sponsor
-------

View File

@ -138,7 +138,7 @@ final class LocoProvider implements ProviderInterface
foreach (array_keys($catalogue->all()) as $domain) {
foreach ($this->getAssetsIds($domain) as $id) {
$responses[$id] = $this->client->request('DELETE', sprintf('assets/%s.json', $id));
$responses[$id] = $this->client->request('DELETE', sprintf('assets/%s.json', rawurlencode($id)));
}
}
@ -200,7 +200,7 @@ final class LocoProvider implements ProviderInterface
$responses = [];
foreach ($translations as $id => $message) {
$responses[$id] = $this->client->request('POST', sprintf('translations/%s/%s', $id, $locale), [
$responses[$id] = $this->client->request('POST', sprintf('translations/%s/%s', rawurlencode($id), rawurlencode($locale)), [
'body' => $message,
]);
}
@ -218,13 +218,35 @@ final class LocoProvider implements ProviderInterface
$this->createTag($tag);
}
$response = $this->client->request('POST', sprintf('tags/%s.json', $tag), [
'body' => implode(',', $ids),
// Separate ids with and without comma.
$idsWithComma = $idsWithoutComma = [];
foreach ($ids as $id) {
if (false !== strpos($id, ',')) {
$idsWithComma[] = $id;
} else {
$idsWithoutComma[] = $id;
}
}
// Set tags for all ids without comma.
$response = $this->client->request('POST', sprintf('tags/%s.json', rawurlencode($tag)), [
'body' => implode(',', $idsWithoutComma),
]);
if (200 !== $response->getStatusCode()) {
$this->logger->error(sprintf('Unable to tag assets with "%s" on Loco: "%s".', $tag, $response->getContent(false)));
}
// Set tags for each id with comma one by one.
foreach ($idsWithComma as $id) {
$response = $this->client->request('POST', sprintf('assets/%s/tags', rawurlencode($id)), [
'body' => ['name' => $tag],
]);
if (200 !== $response->getStatusCode()) {
$this->logger->error(sprintf('Unable to tag asset "%s" with "%s" on Loco: "%s".', $id, $tag, $response->getContent(false)));
}
}
}
private function createTag(string $tag): void