feat: initial ACRIB WordPress deployment

- WordPress 6.9.4 (es_ES) with Kadence theme
- Homepage: Hero, La Asociación, Pilares, Beneficios, Eventos, Miembros, Hazte Miembro, Contacto
- Brand identity: #13294b navy, #a12932 burgundy, #c69c48 gold
- Fonts: Raleway (headings) + Source Sans 3 (body) + Lato (UI)
- Plugins: Kadence Blocks, Polylang, Contact Form 7
- Custom CSS with full brand styling and responsive layout
- HTTPS enforced via wp-config.php proxy detection
This commit is contained in:
Malin
2026-05-19 19:25:59 +02:00
commit f3ff7b7186
6119 changed files with 1984255 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
<?php declare(strict_types=1);
namespace KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\ImageDownloader\Exceptions;
use Exception;
/**
* Thrown when we encounter issues downloading images.
*/
final class ImageDownloadException extends Exception
{
}

View File

@@ -0,0 +1,75 @@
<?php declare(strict_types=1);
namespace KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\ImageDownloader;
use InvalidArgumentException;
use KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\ImageDownloader\Exceptions\ImageDownloadException;
use KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\ImageDownloader\Models\ResponseAdapter;
final class FileNameProcessor
{
private array $allowed_extensions;
public const SCALED_SIZE = 'scaled';
public function __construct(
array $allowed_extensions
) {
/** @var array<string, bool> */
$this->allowed_extensions = $allowed_extensions;
$this->allowed_extensions = array_change_key_case($this->allowed_extensions);
}
/**
* Construct an image file name from a Prophecy Image Service URI.
*
* @param string $src The fully qualified URI to the image.
* @param string $extension The file extension/image type.
* @param string $name An optional name for the file, without extension. Original src will be used otherwise.
*
* @throws \InvalidArgumentException
*/
public function build_image_file_name(string $src, string $extension, string $name = ''): string {
if (! $this->is_valid_image_extension($extension)) {
throw new InvalidArgumentException(sprintf('Invalid file extension: %s', $extension));
}
$filename = $name ?: pathinfo(rawurldecode($src), PATHINFO_FILENAME);
return sprintf('%s.%s', trim($filename), strtolower($extension));
}
/**
* Format the file name to save it as WordPress would.
*
* @param string $file_path The full server path to the saved image file.
*
* @throws ImageDownloadException
*/
public function format_file_path_for_wordpress(string $file_path, ResponseAdapter $response): string {
$info = pathinfo($file_path);
$dirname = $info['dirname'] ?? '';
$ext = $info['extension'] ?? '';
if (! $dirname || ! $ext) {
throw new ImageDownloadException('Missing dirname or extension');
}
// WordPress scaled images do not have width/height in their names.
if ($response->size === self::SCALED_SIZE) {
return str_replace(".$ext", sprintf('-%s.%s', self::SCALED_SIZE, $ext), "$dirname/$response->filename");
}
// @phpstan-ignore-next-line
[ $width, $height ] = getimagesize($file_path);
return str_replace(".$ext", "-{$width}x$height.$ext", "$dirname/$response->filename");
}
/**
* Check if the file extension is in the allowed types.
*/
private function is_valid_image_extension(string $extension): bool {
return $this->allowed_extensions[strtolower($extension)] ?? false;
}
}

View File

@@ -0,0 +1,249 @@
<?php declare(strict_types=1);
namespace KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\ImageDownloader;
use KadenceWP\KadenceBlocks\Psr\Log\LoggerInterface;
use KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\ImageDownloader\Exceptions\ImageDownloadException;
use KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\ImageDownloader\Models\DownloadedImage;
use KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\ImageDownloader\Models\ResponseAdapter;
use KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\ImageDownloader\Sanitization\FileNameSanitizer;
use KadenceWP\KadenceBlocks\Symfony\Component\Filesystem\Filesystem;
use KadenceWP\KadenceBlocks\Symfony\Component\HttpFoundation\File\File;
use KadenceWP\KadenceBlocks\Symfony\Contracts\HttpClient\HttpClientInterface;
use Throwable;
final class ImageDownloader
{
/**
* @readonly
*/
private HttpClientInterface $client;
/**
* @readonly
*/
private Filesystem $filesystem;
/**
* @readonly
*/
private LoggerInterface $logger;
/**
* @readonly
*/
private FileNameProcessor $file;
/**
* @readonly
*/
private FileNameSanitizer $sanitizer;
/**
* @readonly
*/
private int $batch_size;
/**
* @readonly
*/
private bool $seo_names = true;
/**
* @var string[]
*
* @readonly
*/
private array $allowed_mime_types = [
'image/jpeg',
'image/gif',
'image/webp',
'image/png',
];
/**
* @var string[]
*
* @readonly
*/
private array $allowed_hosts = [
'images.pexels.com',
];
/**
* A collection of successfully downloaded images.
*
* @var array<int, array<string, DownloadedImage>>
*/
private array $collected = [];
/**
* A collection of nested exceptions.
*
* @var ImageDownloadException[]
*/
private array $errors = [];
/**
* @param string[] $allowed_mime_types The image mime types we allow to download.
* @param string[] $allowed_hosts The host names that we are allowed to download images from.
*/
public function __construct(HttpClientInterface $client, Filesystem $filesystem, LoggerInterface $logger, FileNameProcessor $file, FileNameSanitizer $sanitizer, int $batch_size, bool $seo_names = true, array $allowed_mime_types = [
'image/jpeg',
'image/gif',
'image/webp',
'image/png',
], array $allowed_hosts = [
'images.pexels.com',
]) {
$this->client = $client;
$this->filesystem = $filesystem;
$this->logger = $logger;
$this->file = $file;
$this->sanitizer = $sanitizer;
$this->batch_size = $batch_size;
$this->seo_names = $seo_names;
$this->allowed_mime_types = $allowed_mime_types;
$this->allowed_hosts = $allowed_hosts;
}
/**
* Download a collection of images to a path on the server.
*
* @param array<array{
* collection_slug: string,
* image_type: string,
* images: array<int, array{
* id: int,
* width: int,
* height: int,
* alt: string,
* url: string,
* photographer: string,
* photographer_url: string,
* avg_color: string,
* sizes: non-empty-array<int,array{name: string, src: string}>
* }>
* }> $image_response The response from our GraphQL images Query.
* @param string $path The directory to save images in.
*
* @throws ImageDownloadException|\Throwable
*
* @return array<int, array<string, DownloadedImage>> A list of arrays, indexed by their thumbnail size name.
*/
public function download(array $image_response, string $path = '/tmp/prophecy-images'): array {
$responses = [];
$batch = $total = 0;
$this->logger->notice(sprintf('Building requests in batches of %d...', $this->batch_size));
foreach ($image_response as $collection) {
foreach ($collection['images'] as $key => $image) {
foreach ($image['sizes'] as $size) {
// Use the alt tag as an SEO friendly filename. Prefix with the Pexels ID to ensure it remains unique.
$name = $this->seo_names ? $image['alt'] . '-' . $image['id'] : '';
$filename = $this->sanitizer->sanitize($this->file->build_image_file_name($size['src'], $collection['image_type'], $name));
try {
if ($this->allowed_hosts) {
$host = parse_url($size['src'], PHP_URL_HOST);
if (! $host || ! in_array($host, $this->allowed_hosts, true)) {
throw new ImageDownloadException(
sprintf('Unable to download image, host "%s" is not on allowed list.', $host)
);
}
}
$responses[] = new ResponseAdapter(
$image['id'],
$image['width'],
$image['height'],
$filename,
$size['name'],
$key,
$image['alt'],
$image['url'],
$image['photographer'],
$image['photographer_url'],
$this->client->request('GET', $size['src'])
);
} catch (Throwable $e) {
$this->errors[] = new ImageDownloadException(sprintf('The response contains an error for image: %s', $size['src']), 1, $e);
$this->logger->error($e->getMessage(), $e->getTrace());
}
$batch++;
if ($batch >= $this->batch_size) {
$this->logger->info(sprintf('Batch sized reached. Downloading %d images...', count($responses)));
$this->processResponses($responses, $path);
$total += $this->batch_size;
$this->logger->info(sprintf('Total processed: %d', $total));
$responses = [];
$batch = 0;
}
}
}
if (! empty($responses)) {
$this->logger->info(sprintf('Processing remaining responses: %d', count($responses)));
$this->processResponses($responses, $path);
}
}
if ($this->errors) {
throw new ImageDownloadException('Some errors were detected when downloading images.', 1, array_pop($this->errors));
}
return $this->collected;
}
/**
* Process a batch of responses.
*
* @param ResponseAdapter[] $responses
*
* @throws \Throwable
*/
private function processResponses(array $responses, string $path): void {
foreach ($responses as $response) {
$ext = pathinfo($response->filename, PATHINFO_EXTENSION);
$file_location = $path . '/' . md5($response->filename . $response->size . $response->id) . ".$ext";
try {
$data = $response->response->getContent();
// Save the file, so we can use getimagesize() to read the dimensions.
$this->filesystem->dumpFile($file_location, $data);
$file = new File($file_location);
$mime = $file->getMimeType();
if (! $mime || ! in_array($mime, $this->allowed_mime_types, true)) {
throw new ImageDownloadException(sprintf('Invalid mime type: %s', $mime));
}
$save_path = $this->file->format_file_path_for_wordpress($file_location, $response);
// Multiple thumbnail sizes can end up with the exact same file name.
if ($this->filesystem->exists($save_path)) {
$this->logger->warning(sprintf('Image already exists: %s', $save_path));
$this->filesystem->remove($file_location);
} else {
// Rename the file to the same format as WordPress.
$this->filesystem->rename($file_location, $save_path);
}
} catch (Throwable $e) {
$this->errors[] = new ImageDownloadException(sprintf('Failed to save image: %s', $file_location), 1, $e);
$this->logger->error($e->getMessage(), $e->getTrace());
$this->filesystem->remove($file_location);
continue;
}
$this->collected[$response->key][$response->size] = new DownloadedImage(
$response->id,
$response->width,
$response->height,
$save_path,
$response->size,
$response->alt,
$response->url,
$response->photographer,
$response->photographer_url,
);
}
}
}

View File

@@ -0,0 +1,39 @@
<?php declare(strict_types=1);
namespace KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\ImageDownloader;
use KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\Container\Contracts\Provider;
use KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\ImageDownloader\Sanitization\Contracts\Sanitizer;
use KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\ImageDownloader\Sanitization\Sanitizers\DefaultFileNameSanitizer;
use KadenceWP\KadenceBlocks\Symfony\Component\HttpClient\HttpClient;
use KadenceWP\KadenceBlocks\Symfony\Component\String\Slugger\AsciiSlugger;
use KadenceWP\KadenceBlocks\Symfony\Component\String\Slugger\SluggerInterface;
use KadenceWP\KadenceBlocks\Symfony\Contracts\HttpClient\HttpClientInterface;
final class ImageProvider extends Provider
{
/**
* {@inheritDoc}
*/
public function register(): void {
$this->container->bind(SluggerInterface::class, AsciiSlugger::class);
$this->container->bind(Sanitizer::class, DefaultFileNameSanitizer::class);
$this->container->when(FileNameProcessor::class)
->needs('$allowed_extensions')
->give([
'jpg' => true,
'jpeg' => true,
'webp' => true,
'png' => true,
]);
$this->container->when(ImageDownloader::class)
->needs(HttpClientInterface::class)
->give(HttpClient::create());
$this->container->when(ImageDownloader::class)
->needs('$batch_size')
->give((int) $this->config->get('image_batch_size'));
}
}

View File

@@ -0,0 +1,67 @@
<?php declare(strict_types=1);
namespace KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\ImageDownloader\Models;
/**
* Represents the results of a downloaded image.
*/
final class DownloadedImage
{
public int $id;
public int $width;
public int $height;
public string $file;
public string $size;
public string $alt;
public string $url;
public string $photographer;
public string $photographer_url;
/**
* @param int $id The unique Pexels ID.
* @param int $width The image's original max width.
* @param int $height The image's original max height.
* @param string $file The server path to the file.
* @param string $size The WordPress size name, e.g. thumbnail.
* @param string $alt The alt description for the image.
* @param string $url The Pexels attachment URL.
* @param string $photographer The photographer's name.
* @param string $photographer_url The photographer's Pexels URL.
*/
public function __construct(int $id, int $width, int $height, string $file, string $size, string $alt, string $url, string $photographer, string $photographer_url) {
$this->id = $id;
$this->width = $width;
$this->height = $height;
$this->file = $file;
$this->size = $size;
$this->alt = $alt;
$this->url = $url;
$this->photographer = $photographer;
$this->photographer_url = $photographer_url;
}
/**
* @return array{id: int, width: int, height: int, file: string, size: string, alt: string, url: string, photographer: string, photographer_url: string}
*/
public function toArray(): array {
return [
'id' => $this->id,
'width' => $this->width,
'height' => $this->height,
'file' => $this->file,
'size' => $this->size,
'alt' => $this->alt,
'url' => $this->url,
'photographer' => $this->photographer,
'photographer_url' => $this->photographer_url,
];
}
}

View File

@@ -0,0 +1,55 @@
<?php declare(strict_types=1);
namespace KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\ImageDownloader\Models;
use KadenceWP\KadenceBlocks\Symfony\Contracts\HttpClient\ResponseInterface;
final class ResponseAdapter
{
public int $id;
public int $width;
public int $height;
public string $filename;
public string $size;
public int $key;
public string $alt;
public string $url;
public string $photographer;
public string $photographer_url;
public ResponseInterface $response;
/**
* @param int $id The unique Pexels ID.
* @param int $width The image's original max width.
* @param int $height The image's original max height.
* @param string $filename The file name with extension.
* @param string $size The WordPress size name, e.g. thumbnail.
* @param int $key The index associated with the collection as we loop through it.
* @param string $alt The alt description for the image.
* @param string $url The Pexels attachment URL.
* @param string $photographer The photographer's name.
* @param string $photographer_url The photographer's Pexels URL.
*/
public function __construct(int $id, int $width, int $height, string $filename, string $size, int $key, string $alt, string $url, string $photographer, string $photographer_url, ResponseInterface $response) {
$this->id = $id;
$this->width = $width;
$this->height = $height;
$this->filename = $filename;
$this->size = $size;
$this->key = $key;
$this->alt = $alt;
$this->url = $url;
$this->photographer = $photographer;
$this->photographer_url = $photographer_url;
$this->response = $response;
}
}

View File

@@ -0,0 +1,74 @@
# Prophecy Image Downloader
> ⚠️ **This is a read-only repository!**
> For pull requests or issues, see [stellarwp/prophecy-monorepo](https://github.com/stellarwp/prophecy-monorepo).
A very fast, asynchronous concurrent image downloader built on [symfony/http-client](https://symfony.com/doc/current/http_client.html).
## Installation
Update your composer.json and add the following to your `repositories` object:
```json
{
"repositories": [
{
"type": "vcs",
"url": "git@github.com:stellarwp/prophecy-container.git"
},
{
"type": "vcs",
"url": "git@github.com:stellarwp/prophecy-log.git"
},
{
"type": "vcs",
"url": "git@github.com:stellarwp/prophecy-image-downloader.git"
}
]
}
```
Then, install:
```shell
composer require stellarwp/prophecy-image-downloader
```
If using the [di52 container](https://github.com/lucatume/di52), create a `config.php` and register
it in the container with:
```php
$this->container->bind(Dot::class, new Dot(require_once dirname(__FILE__) . '/config.php'));
```
The config.php file map environment variables, either from an `.env` file or manually set, e.g.
```php
<?php declare(strict_types=1);
return [
'image_batch_size' => $_ENV['APP_IMAGE_BATCH_SIZE'] ?? 10,
'storage_path' => $_ENV['APP_STORAGE_PATH'] ?? '/tmp', // This could be the path to your wp-content/uploads folder.
];
```
Then, include the [ImageProvider.php](./ImageProvider.php), or create a custom/modified version
of it and call the `register` method.
The downloader is meant to work with the [Prophecy Laravel Service](https://github.com/stellarwp/prophecy)'s API responses, however
if you format your array in the same way that [ImageDownloader::download()](./ImageDownloader.php) accepts, it will
fetch the files to your configured location:
## File Name Sanitization
The system provides some basic file name sanitization, but you can also use your framework's strategy or
make a custom strategy, as long as it implements the [Sanitizer Contract](./Sanitization/Contracts/Sanitizer.php).
For example, to use WordPress's file name sanitization, simply bind the interface to the provided WPFileNameStrategy, e.g. using
di52's container:
```php
$this->container->bind(Sanitizer::class, \StellarWP\ProphecyMonorepo\ImageDownloader\Sanitization\Sanitizers\WPFileNameSanitizer::class);
```

View File

@@ -0,0 +1,11 @@
<?php declare(strict_types=1);
namespace KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\ImageDownloader\Sanitization\Contracts;
interface Sanitizer
{
/**
* Sanitize a file name.
*/
public function __invoke(string $filename): string;
}

View File

@@ -0,0 +1,29 @@
<?php declare(strict_types=1);
namespace KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\ImageDownloader\Sanitization;
use KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\ImageDownloader\Sanitization\Contracts\Sanitizer;
/**
* Sanitizes a filename using a callable/Closure strategy.
*/
final class FileNameSanitizer
{
/**
* @readonly
*/
private Sanitizer $sanitizer;
/**
* @param Sanitizer $sanitizer The filename sanitizer Closure strategy.
*/
public function __construct(Sanitizer $sanitizer) {
$this->sanitizer = $sanitizer;
}
/**
* Sanitize a filename.
*/
public function sanitize(string $filename): string {
return ($this->sanitizer)($filename);
}
}

View File

@@ -0,0 +1,45 @@
<?php declare(strict_types=1);
namespace KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\ImageDownloader\Sanitization\Sanitizers;
use RuntimeException;
use KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\ImageDownloader\Sanitization\Contracts\Sanitizer;
use KadenceWP\KadenceBlocks\Symfony\Component\String\Slugger\SluggerInterface;
/**
* The default filename sanitizer.
*/
final class DefaultFileNameSanitizer implements Sanitizer
{
/**
* @readonly
*/
private SluggerInterface $slugger;
public function __construct(SluggerInterface $slugger) {
$this->slugger = $slugger;
}
/**
* {@inheritDoc}
*
* @throws RuntimeException
*/
public function __invoke(string $filename): string {
// Dot files are renamed regardless of security settings.
$filename = trim($filename, '.');
// Remove any null bytes. See
// http://php.net/manual/security.filesystem.nullbytes.php
$filename = str_replace(chr(0), '', $filename);
$ext = pathinfo($filename, PATHINFO_EXTENSION);
if (empty($ext)) {
throw new RuntimeException('Cannot sanitize dot files');
}
$original = pathinfo($filename, PATHINFO_FILENAME);
return $this->slugger->slug($original)->lower() . ".$ext";
}
}

View File

@@ -0,0 +1,21 @@
<?php declare(strict_types=1);
namespace KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\ImageDownloader\Sanitization\Sanitizers;
use KadenceWP\KadenceBlocks\StellarWP\ProphecyMonorepo\ImageDownloader\Sanitization\Contracts\Sanitizer;
/**
* The WordPress filename sanitizer.
*
* @codeCoverageIgnore
*/
final class WPFileNameSanitizer implements Sanitizer
{
/**
* {@inheritDoc}
*/
public function __invoke(string $filename): string {
// @phpstan-ignore-next-line
return strtolower(sanitize_file_name($filename));
}
}