Repository: shopware/shopware (PHP)

Issue:
Copy batch ignores visibility configuration for S3
### Shopware Version

6.6.10.3

### Affected area / extension

Platform(Default)

### Actual behaviour

With the following code in `src/Core/Framework/Adapter/Filesystem/Plugin/CopyBatch.php`:
```php
$adapter = self::getAdapter($filesystem);
if ($adapter instanceof WriteBatchInterface) {
    $adapter->writeBatch(...$files);
    return;
}
``` 

...and when the filesystem is configured to use S3 (tested with DigitalOcean Spaces) like this:

```yaml
# config/packages/shopware.yaml
shopware:
  filesystem:
    asset:
        type: "amazon-s3"
        visibility: "public"
        url: "%env(string:S3_URL)%/%env(string:S3_ROOT_DIR_PUBLIC)%"
        config:
            bucket: "%env(string:S3_BUCKET)%"
            region: "%env(string:S3_REGION)%"
            endpoint: "%env(string:S3_ENDPOINT)%"
            root: "%env(string:S3_ROOT_DIR_PUBLIC)%"
            credentials:
                key: "%env(string:S3_KEY)%"
                secret: "%env(string:S3_SECRET)%"
``` 

...then **visibility is ignored**, and all copied files are uploaded as `private`, even when they should be public.

Testable by republishing the assets `bin/console assets:install --force`.

**Removing the `writeBatch` code block above resolves the issue.**

### Expected behaviour

Copied assets should respect the configured visibility settings (e.g. "public").

### How to reproduce

1. Configure filesystem to use S3/Spaces with visibility: "public" in config/packages/shopware.yaml
2. Trigger an asset copy process `bin/console assets:install --force`
3. Check visibility of the files in the S3 bucket or try to load the admin dashboard
4. The dashboard will not load since asset files are private, network requests will get 403



Fix (patch):
diff --git a/.bc-exclude.php b/.bc-exclude.php
index 56790a8319c..b7d4f15d2a6 100644
--- a/.bc-exclude.php
+++ b/.bc-exclude.php
@@ -43,5 +43,7 @@
         // No break as the `{get,set}SeoLink()` changes have not been released
         'REMOVED: Property Shopware\\\\Core\\\\Content\\\\Category\\\\SalesChannel\\\\SalesChannelCategoryEntity#\$seoLink was removed',
         'REMOVED: Method Shopware\\\\Core\\\\Content\\\\Category\\\\SalesChannel\\\\SalesChannelCategoryEntity#(get|set)SeoLink\(\) was removed',
+
+        'ADDED: Parameter visibility was added to Method __construct\(\) of class Shopware\\\\Core\\\\Framework\\\\Adapter\\\\Filesystem\\\\Plugin\\\\CopyBatchInput'
     ],
 ];
diff --git a/src/Core/Framework/Adapter/Filesystem/Adapter/AsyncAwsS3WriteBatchAdapter.php b/src/Core/Framework/Adapter/Filesystem/Adapter/AsyncAwsS3WriteBatchAdapter.php
index 4d7f2518acc..7df99f941da 100644
--- a/src/Core/Framework/Adapter/Filesystem/Adapter/AsyncAwsS3WriteBatchAdapter.php
+++ b/src/Core/Framework/Adapter/Filesystem/Adapter/AsyncAwsS3WriteBatchAdapter.php
@@ -5,6 +5,7 @@
 use AsyncAws\Core\Result;
 use AsyncAws\S3\S3Client;
 use League\Flysystem\AsyncAwsS3\AsyncAwsS3Adapter;
+use League\Flysystem\AsyncAwsS3\PortableVisibilityConverter;
 use Shopware\Core\Framework\Adapter\Filesystem\Plugin\CopyBatchInput;
 use Shopware\Core\Framework\Adapter\Filesystem\Plugin\WriteBatchInterface;
 use Shopware\Core\Framework\Log\Package;
@@ -17,13 +18,16 @@ public function writeBatch(CopyBatchInput ...$files): void
         /** @var S3Client $s3Client */
         $s3Client = \Closure::bind(fn () => $this->client, $this, parent::class)();
 
-        // Extract the bucket name, mime type detector and path prefixer from the adapter.
+        // Extract the bucket name, mime type detector, path prefixer and visibility converter from the adapter.
         $bucketName = \Closure::bind(fn () => $this->bucket, $this, parent::class)();
 
         $mimeTypeDetector = \Closure::bind(fn () => $this->mimeTypeDetector, $this, parent::class)();
 
         $prefixer = \Closure::bind(fn () => $this->prefixer, $this, parent::class)();
 
+        /** @var PortableVisibilityConverter $visibilityConverter */
+        $visibilityConverter = \Closure::bind(fn () => $this->visibility, $this, parent::class)();
+
         // Copy the files in batches of 250 files. This is necessary to have open sockets and not run into the "Too many open files" error.
         foreach (array_chunk($files, 250) as $filesBatch) {
             $requests = [];
@@ -42,10 +46,14 @@ public function writeBatch(CopyBatchInput ...$files): void
                 $mimeType = $mimeTypeDetector->detectMimeType($file->getTargetFiles()[0], $sourceFile);
 
                 foreach ($file->getTargetFiles() as $targetFile) {
+                    /** @var 'private'|'public-read' $visibility */
+                    $visibility = $visibilityConverter->visibilityToAcl($file->visibility);
+
                     $options = [
                         'Bucket' => $bucketName,
                         'Key' => $prefixer->prefixPath($targetFile),
                         'Body' => $sourceFile,
+                        'ACL' => $visibility,
                     ];
 
                     if ($mimeType !== null) {
diff --git a/src/Core/Framework/Adapter/Filesystem/Plugin/CopyBatchInput.php b/src/Core/Framework/Adapter/Filesystem/Plugin/CopyBatchInput.php
index 1154c4fdb88..491b7e8f139 100644
--- a/src/Core/Framework/Adapter/Filesystem/Plugin/CopyBatchInput.php
+++ b/src/Core/Framework/Adapter/Filesystem/Plugin/CopyBatchInput.php
@@ -2,9 +2,13 @@
 
 namespace Shopware\Core\Framework\Adapter\Filesystem\Plugin;
 
+use League\Flysystem\Visibility;
 use Shopware\Core\Framework\Adapter\AdapterException;
 use Shopware\Core\Framework\Log\Package;
 
+/**
+ * @final
+ */
 #[Package('framework')]
 class CopyBatchInput
 {
@@ -19,7 +23,8 @@ class CopyBatchInput
      */
     public function __construct(
         $sourceFile,
-        private readonly array $targetFiles
+        private readonly array $targetFiles,
+        public readonly string $visibility = Visibility::PUBLIC,
     ) {
         if (!\is_resource($sourceFile) && !\is_string($sourceFile)) {
             throw AdapterException::invalidArgument(\sprintf(
diff --git a/src/Core/Framework/Adapter/Filesystem/Plugin/CopyBatchInputFactory.php b/src/Core/Framework/Adapter/Filesystem/Plugin/CopyBatchInputFactory.php
index c73257adb21..4930063155f 100644
--- a/src/Core/Framework/Adapter/Filesystem/Plugin/CopyBatchInputFactory.php
+++ b/src/Core/Framework/Adapter/Filesystem/Plugin/CopyBatchInputFactory.php
@@ -2,6 +2,7 @@
 
 namespace Shopware\Core\Framework\Adapter\Filesystem\Plugin;
 
+use League\Flysystem\Visibility;
 use Shopware\Core\Framework\Log\Package;
 use Symfony\Component\Filesystem\Path;
 use Symfony\Component\Finder\Finder;
@@ -18,7 +19,7 @@ class CopyBatchInputFactory
     /**
      * @return array<CopyBatchInput>
      */
-    public function fromDirectory(string $directory, string $target): array
+    public function fromDirectory(string $directory, string $target, string $visibility = Visibility::PUBLIC): array
     {
         if (!\is_dir($directory)) {
             return [];
@@ -31,7 +32,8 @@ public function fromDirectory(string $directory, string $target): array
         return array_values(array_map(
             fn (SplFileInfo $file) => new CopyBatchInput(
                 $file->getRealPath(),
-                [Path::join($target, $parentName, $file->getRelativePathname())]
+                [Path::join($target, $parentName, $file->getRelativePathname())],
+                $visibility
             ),
             iterator_to_array($files)
         ));
diff --git a/src/Core/Framework/Plugin/Util/AssetService.php b/src/Core/Framework/Plugin/Util/AssetService.php
index 425bbfa2587..0dcb2217161 100644
--- a/src/Core/Framework/Plugin/Util/AssetService.php
+++ b/src/Core/Framework/Plugin/Util/AssetService.php
@@ -3,6 +3,7 @@
 namespace Shopware\Core\Framework\Plugin\Util;
 
 use League\Flysystem\FilesystemOperator;
+use League\Flysystem\Visibility;
 use Shopware\Core\DevOps\Environment\EnvironmentHelper;
 use Shopware\Core\Framework\Adapter\Cache\CacheInvalidator;
 use Shopware\Core\Framework\Adapter\Filesystem\Plugin\CopyBatch;
@@ -224,7 +225,8 @@ private function sync(string $originDir, string $targetDirectory, array $localMa
         foreach ($uploads as $file) {
             $batches[] = new CopyBatchInput(
                 $originDir . '/' . $file,
-                [$targetDirectory . '/' . $file]
+                [$targetDirectory . '/' . $file],
+                $this->parameterBag->get('shopware.filesystem.asset.config')['visibility'] ?? Visibility::PUBLIC,
             );
         }
 
diff --git a/src/Storefront/DependencyInjection/theme.xml b/src/Storefront/DependencyInjection/theme.xml
index 9234724ea4f..c7b7bc36775 100644
--- a/src/Storefront/DependencyInjection/theme.xml
+++ b/src/Storefront/DependencyInjection/theme.xml
@@ -36,6 +36,7 @@
             <argument type="service" id="Shopware\Storefront\Theme\ScssPhpCompiler"/>
             <argument>%storefront.theme.allowed_scss_values%</argument>
             <argument>%storefront.theme.validate_on_compile%</argument>
+            <argument>%shopware.filesystem.theme.config%</argument>
         </service>
 
         <service id="Shopware\Storefront\Theme\ThemeLifecycleService">
diff --git a/src/Storefront/Theme/ThemeCompiler.php b/src/Storefront/Theme/ThemeCompiler.php
index 9da47828264..124a0d1492f 100644
--- a/src/Storefront/Theme/ThemeCompiler.php
+++ b/src/Storefront/Theme/ThemeCompiler.php
@@ -5,6 +5,7 @@
 use League\Flysystem\FilesystemException;
 use League\Flysystem\FilesystemOperator;
 use League\Flysystem\UnableToDeleteDirectory;
+use League\Flysystem\Visibility;
 use Psr\Log\LoggerInterface;
 use ScssPhp\ScssPhp\OutputStyle;
 use Shopware\Core\Framework\Adapter\Cache\CacheInvalidator;
@@ -36,6 +37,7 @@ class ThemeCompiler implements ThemeCompilerInterface
      *
      * @param array<string, AssetPackage> $packages
      * @param array<int, string> $customAllowedRegex
+     * @param array{visibility?: string} $themeFilesystemConfig
      */
     public function __construct(
         private readonly FilesystemOperator $filesystem,
@@ -51,7 +53,8 @@ public function __construct(
         private readonly AbstractThemePathBuilder $themePathBuilder,
         private readonly AbstractScssCompiler $scssCompiler,
         private readonly array $customAllowedRegex = [],
-        private readonly bool $validate = false
+        private readonly bool $validate = false,
+        private readonly array $themeFilesystemConfig = [],
     ) {
     }
 
@@ -191,7 +194,7 @@ private function copyScriptFilesToTheme(
             $targetPath = $themePath . '/js/' . $folderName;
             foreach ($files as $file) {
                 if (file_exists($file->getRealPath())) {
-                    $copyFiles[] = new CopyBatchInput($file->getRealPath(), [$targetPath . '/' . $file->getFilename()]);
+                    $copyFiles[] = new CopyBatchInput($file->getRealPath(), [$targetPath . '/' . $file->getFilename()], $this->themeFilesystemConfig['visibility'] ?? Visibility::PUBLIC);
                 }
             }
         }
@@ -265,7 +268,7 @@ private function getAssets(
                 $asset = $fs->path('Resources', $asset);
             }
 
-            $collected = [...$collected, ...$this->copyBatchInputFactory->fromDirectory($asset, $outputPath)];
+            $collected = [...$collected, ...$this->copyBatchInputFactory->fromDirectory($asset, $outputPath, $this->themeFilesystemConfig['visibility'] ?? Visibility::PUBLIC)];
         }
 
         return array_values($collected);
@@ -486,7 +489,8 @@ private function collectCompiledFiles(
                 $tempStream,
                 [
                     $compileLocation . \DIRECTORY_SEPARATOR . 'css' . \DIRECTORY_SEPARATOR . 'all.css',
-                ]
+                ],
+                $this->themeFilesystemConfig['visibility'] ?? Visibility::PUBLIC
             ),
         ];